diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go index 5bc8578..cea6f55 100644 --- a/cmd/wotra/main.go +++ b/cmd/wotra/main.go @@ -40,18 +40,20 @@ func main() { entryStore := store.NewEntryStore(db) closedDayStore := store.NewClosedDayStore(db) + closedWeekStore := store.NewClosedWeekStore(db) settingsStore := store.NewSettingsStore(db) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) settingsSvc := service.NewSettingsService(settingsStore) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz) // Background goroutine: auto-stop entries that cross midnight ctx, cancel := context.WithCancel(context.Background()) defer cancel() go runMidnightGuard(ctx, entrySvc) - router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc) + router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc, weekSvc) srv := &http.Server{ Addr: ":" + cfg.Port, diff --git a/internal/handler/router.go b/internal/handler/router.go index fe2fcf5..99aa995 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -9,7 +9,7 @@ import ( ) // NewRouter builds the full HTTP router. -func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService) http.Handler { +func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService, weekSvc *service.WeekService) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) @@ -33,6 +33,9 @@ func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service settingsH := NewSettingsHandler(settingsSvc) settingsH.Routes(r) + + weekH := NewWeekHandler(weekSvc) + weekH.Routes(r) }) return r diff --git a/internal/handler/week_handler.go b/internal/handler/week_handler.go new file mode 100644 index 0000000..640060a --- /dev/null +++ b/internal/handler/week_handler.go @@ -0,0 +1,81 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wotra/wotra/internal/service" +) + +// WeekHandler serves /api/weeks routes. +type WeekHandler struct { + svc *service.WeekService +} + +func NewWeekHandler(svc *service.WeekService) *WeekHandler { + return &WeekHandler{svc: svc} +} + +func (h *WeekHandler) Routes(r chi.Router) { + r.Get("/weeks", h.List) + r.Post("/weeks/{week_key}/close", h.Close) + r.Delete("/weeks/{week_key}/close", h.Reopen) +} + +// List GET /api/weeks?from=YYYY-Www&to=YYYY-Www +func (h *WeekHandler) List(w http.ResponseWriter, r *http.Request) { + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { + from = "0000-W01" + } + if to == "" { + to = "9999-W53" + } + weeks, err := h.svc.ListWeeks(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if weeks == nil { + writeJSON(w, http.StatusOK, []struct{}{}) + return + } + writeJSON(w, http.StatusOK, weeks) +} + +// Close POST /api/weeks/{week_key}/close +func (h *WeekHandler) Close(w http.ResponseWriter, r *http.Request) { + weekKey := chi.URLParam(r, "week_key") + cw, err := h.svc.CloseWeek(r.Context(), weekKey) + if err != nil { + switch { + case errors.Is(err, service.ErrWeekAlreadyClosed): + writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrWeekHasUnclosedDays): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + case errors.Is(err, service.ErrNoSettings): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, cw) +} + +// Reopen DELETE /api/weeks/{week_key}/close +func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) { + weekKey := chi.URLParam(r, "week_key") + if err := h.svc.ReopenWeek(r.Context(), weekKey); err != nil { + switch { + case errors.Is(err, service.ErrWeekNotClosed): + writeError(w, http.StatusNotFound, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/service/week_service.go b/internal/service/week_service.go new file mode 100644 index 0000000..86a0d06 --- /dev/null +++ b/internal/service/week_service.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +// WeekService handles closing weeks and computing overtime/undertime. +type WeekService struct { + closedDays *store.ClosedDayStore + closedWeeks *store.ClosedWeekStore + settings *store.SettingsStore + db interface { + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + } + rawDB *sql.DB + tz *time.Location +} + +func NewWeekService( + closedDays *store.ClosedDayStore, + closedWeeks *store.ClosedWeekStore, + settings *store.SettingsStore, + rawDB *sql.DB, + tz *time.Location, +) *WeekService { + return &WeekService{ + closedDays: closedDays, + closedWeeks: closedWeeks, + settings: settings, + rawDB: rawDB, + tz: tz, + } +} + +// WeekDayKeysExported is exported for testing. +var WeekDayKeysExported = weekDayKeys + +// weekDayKeys returns the YYYY-MM-DD keys for Mon-Sun of the ISO week encoded as weekKey. +// weekKey format: "YYYY-Www" (e.g. "2024-W03"). +func weekDayKeys(weekKey string, tz *time.Location) ([]string, error) { + var year, week int + if _, err := fmt.Sscanf(weekKey, "%d-W%02d", &year, &week); err != nil { + return nil, fmt.Errorf("invalid week_key %q: expected YYYY-Www", weekKey) + } + // Find the Monday of that ISO week. + // Jan 4 is always in week 1 of its year. + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, tz) + _, jan4Week := jan4.ISOWeek() + monday := jan4.AddDate(0, 0, -int(jan4.Weekday()-time.Monday)+(week-jan4Week)*7) + + keys := make([]string, 7) + for i := 0; i < 7; i++ { + keys[i] = monday.AddDate(0, 0, i).Format("2006-01-02") + } + return keys, nil +} + +// CloseWeek closes an ISO week. All workdays must already be closed. +func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) { + // Already closed? + existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if existing != nil { + return nil, ErrWeekAlreadyClosed + } + + dayKeys, err := weekDayKeys(weekKey, s.tz) + if err != nil { + return nil, err + } + + // Get settings at the start of the week (Monday) + mondayKey := dayKeys[0] + set, err := s.settings.Current(ctx, mondayKey) + if err != nil { + return nil, ErrNoSettings + } + + // Compute expected ms for the week (from settings frozen at week start) + expectedMs := int64(set.HoursPerWeek * 3_600_000) + + // Verify all workdays are closed; collect worked ms + var totalWorkedMs int64 + for _, dk := range dayKeys { + t, _ := time.ParseInLocation("2006-01-02", dk, s.tz) + if !set.IsWorkday(int(t.Weekday())) { + continue // weekend or non-workday — skip + } + cd, err := s.closedDays.GetByDayKey(ctx, dk) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk) + } + return nil, err + } + totalWorkedMs += cd.WorkedMs + } + + now := time.Now().UnixMilli() + cw := &domain.ClosedWeek{ + WeekKey: weekKey, + ExpectedMs: expectedMs, + WorkedMs: totalWorkedMs, + DeltaMs: totalWorkedMs - expectedMs, + ClosedAt: now, + UpdatedAt: now, + } + if err := s.closedWeeks.Upsert(ctx, cw); err != nil { + return nil, err + } + return cw, nil +} + +// ReopenWeek deletes the closed_weeks record, making it editable again. +// Individual closed days are NOT automatically reopened. +func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error { + existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrWeekNotClosed + } + return err + } + if existing == nil { + return ErrWeekNotClosed + } + return s.closedWeeks.Delete(ctx, weekKey) +} + +// ListWeeks returns closed weeks within a range. +func (s *WeekService) ListWeeks(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) { + return s.closedWeeks.ListByRange(ctx, fromWeekKey, toWeekKey) +} + +// GetWeek returns a single closed week. +func (s *WeekService) GetWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) { + cw, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return cw, err +} diff --git a/internal/service/week_service_test.go b/internal/service/week_service_test.go new file mode 100644 index 0000000..e073f23 --- /dev/null +++ b/internal/service/week_service_test.go @@ -0,0 +1,144 @@ +package service_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/service" + "github.com/wotra/wotra/internal/store" +) + +func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, *service.WeekService, *service.SettingsService) { + t.Helper() + db, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + entryStore := store.NewEntryStore(db) + closedDayStore := store.NewClosedDayStore(db) + closedWeekStore := store.NewClosedWeekStore(db) + settingsStore := store.NewSettingsStore(db) + tz, _ := time.LoadLocation("UTC") + + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz) + settingsSvc := service.NewSettingsService(settingsStore) + return entrySvc, daySvc, weekSvc, settingsSvc +} + +func TestWeekDayKeys(t *testing.T) { + // 2024-W03 = Jan 15-21, 2024 + tz, _ := time.LoadLocation("UTC") + keys, err := service.WeekDayKeysExported("2024-W03", tz) + if err != nil { + t.Fatal(err) + } + if len(keys) != 7 { + t.Fatalf("expected 7 keys, got %d", len(keys)) + } + if keys[0] != "2024-01-15" { + t.Errorf("expected Monday 2024-01-15, got %s", keys[0]) + } + if keys[6] != "2024-01-21" { + t.Errorf("expected Sunday 2024-01-21, got %s", keys[6]) + } +} + +func TestCloseWeekBasic(t *testing.T) { + ctx := context.Background() + entrySvc, daySvc, weekSvc, _ := newFullServices(t) + + // Find the ISO week for "this week" using a known Mon-Fri + // Use a fixed Monday to make the test deterministic + monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) // 2024-W03, Monday + weekKey := "2024-W03" + + // Close Mon-Fri by marking as holiday (easiest — no entries needed) + for i := 0; i < 5; i++ { + dk := monday.AddDate(0, 0, i).Format("2006-01-02") + _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) + if err != nil { + t.Fatalf("MarkDay %s: %v", dk, err) + } + } + + cw, err := weekSvc.CloseWeek(ctx, weekKey) + if err != nil { + t.Fatalf("CloseWeek: %v", err) + } + // Expected: 40h = 144000000 ms + if cw.ExpectedMs != 40*3_600_000 { + t.Errorf("expected 40h expected_ms, got %d ms", cw.ExpectedMs) + } + // Worked: each holiday day = 8h = 5 * 8h = 40h + if cw.WorkedMs != 40*3_600_000 { + t.Errorf("expected 40h worked_ms, got %d ms", cw.WorkedMs) + } + if cw.DeltaMs != 0 { + t.Errorf("expected 0 delta_ms, got %d", cw.DeltaMs) + } + fmt.Printf("WeekKey=%s Expected=%dh Worked=%dh Delta=%dms\n", + cw.WeekKey, cw.ExpectedMs/3_600_000, cw.WorkedMs/3_600_000, cw.DeltaMs) + _ = entrySvc +} + +func TestCloseWeekMissingDayFails(t *testing.T) { + ctx := context.Background() + _, daySvc, weekSvc, _ := newFullServices(t) + + // Only close Mon-Thu, leave Friday open + monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + for i := 0; i < 4; i++ { + dk := monday.AddDate(0, 0, i).Format("2006-01-02") + daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) + } + + _, err := weekSvc.CloseWeek(ctx, "2024-W03") + if err == nil { + t.Fatal("expected error closing week with unclosed day") + } +} + +func TestCloseWeekTwiceFails(t *testing.T) { + ctx := context.Background() + _, daySvc, weekSvc, _ := newFullServices(t) + + monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + dk := monday.AddDate(0, 0, i).Format("2006-01-02") + daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) + } + weekSvc.CloseWeek(ctx, "2024-W03") + + _, err := weekSvc.CloseWeek(ctx, "2024-W03") + if err != service.ErrWeekAlreadyClosed { + t.Fatalf("expected ErrWeekAlreadyClosed, got %v", err) + } +} + +func TestReopenWeek(t *testing.T) { + ctx := context.Background() + _, daySvc, weekSvc, _ := newFullServices(t) + + monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + dk := monday.AddDate(0, 0, i).Format("2006-01-02") + daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) + } + weekSvc.CloseWeek(ctx, "2024-W03") + + if err := weekSvc.ReopenWeek(ctx, "2024-W03"); err != nil { + t.Fatalf("ReopenWeek: %v", err) + } + // Should be closeable again + _, err := weekSvc.CloseWeek(ctx, "2024-W03") + if err != nil { + t.Fatalf("CloseWeek after reopen: %v", err) + } +}