diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go index 6a6fd3f..5bc8578 100644 --- a/cmd/wotra/main.go +++ b/cmd/wotra/main.go @@ -43,13 +43,15 @@ func main() { settingsStore := store.NewSettingsStore(db) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) + settingsSvc := service.NewSettingsService(settingsStore) // 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) + router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc) srv := &http.Server{ Addr: ":" + cfg.Port, diff --git a/internal/handler/day_handler.go b/internal/handler/day_handler.go new file mode 100644 index 0000000..68b8847 --- /dev/null +++ b/internal/handler/day_handler.go @@ -0,0 +1,101 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/service" +) + +// DayHandler serves /api/days routes. +type DayHandler struct { + svc *service.DayService +} + +func NewDayHandler(svc *service.DayService) *DayHandler { + return &DayHandler{svc: svc} +} + +func (h *DayHandler) Routes(r chi.Router) { + r.Get("/days", h.List) + r.Post("/days/{day_key}/close", h.Close) + r.Post("/days/{day_key}/mark", h.Mark) + r.Delete("/days/{day_key}/close", h.Reopen) +} + +// List GET /api/days?from=YYYY-MM-DD&to=YYYY-MM-DD +func (h *DayHandler) List(w http.ResponseWriter, r *http.Request) { + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { + from = "0000-01-01" + } + if to == "" { + to = "9999-12-31" + } + days, err := h.svc.ListDays(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if days == nil { + writeJSON(w, http.StatusOK, []struct{}{}) + return + } + writeJSON(w, http.StatusOK, days) +} + +// Close POST /api/days/{day_key}/close +func (h *DayHandler) Close(w http.ResponseWriter, r *http.Request) { + dayKey := chi.URLParam(r, "day_key") + cd, err := h.svc.CloseDay(r.Context(), dayKey) + if err != nil { + switch { + case errors.Is(err, service.ErrDayAlreadyClosed): + writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrRunningEntryOnDay): + writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrDayHasNoEntries): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, cd) +} + +// Mark POST /api/days/{day_key}/mark +func (h *DayHandler) Mark(w http.ResponseWriter, r *http.Request) { + dayKey := chi.URLParam(r, "day_key") + var body struct { + Kind domain.DayKind `json:"kind"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + cd, err := h.svc.MarkDay(r.Context(), dayKey, body.Kind) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + writeJSON(w, http.StatusOK, cd) +} + +// Reopen DELETE /api/days/{day_key}/close +func (h *DayHandler) Reopen(w http.ResponseWriter, r *http.Request) { + dayKey := chi.URLParam(r, "day_key") + if err := h.svc.ReopenDay(r.Context(), dayKey); err != nil { + switch { + case errors.Is(err, service.ErrDayNotClosed): + writeError(w, http.StatusNotFound, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/handler/router.go b/internal/handler/router.go index 200c7fd..fe2fcf5 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) http.Handler { +func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) @@ -27,6 +27,12 @@ func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler { entryH := NewEntryHandler(entrySvc) entryH.Routes(r) + + dayH := NewDayHandler(daySvc) + dayH.Routes(r) + + settingsH := NewSettingsHandler(settingsSvc) + settingsH.Routes(r) }) return r diff --git a/internal/handler/settings_handler.go b/internal/handler/settings_handler.go new file mode 100644 index 0000000..7cea519 --- /dev/null +++ b/internal/handler/settings_handler.go @@ -0,0 +1,78 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wotra/wotra/internal/service" +) + +// SettingsHandler serves /api/settings routes. +type SettingsHandler struct { + svc *service.SettingsService +} + +func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler { + return &SettingsHandler{svc: svc} +} + +func (h *SettingsHandler) Routes(r chi.Router) { + r.Get("/settings", h.Current) + r.Put("/settings", h.Upsert) + r.Get("/settings/history", h.History) +} + +// Current GET /api/settings +func (h *SettingsHandler) Current(w http.ResponseWriter, r *http.Request) { + set, err := h.svc.Current(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, set) +} + +// Upsert PUT /api/settings +func (h *SettingsHandler) Upsert(w http.ResponseWriter, r *http.Request) { + var body struct { + EffectiveFrom string `json:"effective_from"` + HoursPerWeek float64 `json:"hours_per_week"` + WorkdaysMask int `json:"workdays_mask"` + Timezone string `json:"timezone"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + set, err := h.svc.Upsert(r.Context(), service.UpsertSettingsInput{ + EffectiveFrom: body.EffectiveFrom, + HoursPerWeek: body.HoursPerWeek, + WorkdaysMask: body.WorkdaysMask, + Timezone: body.Timezone, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, set) +} + +// History GET /api/settings/history +func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) { + history, err := h.svc.History(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if history == nil { + writeJSON(w, http.StatusOK, []struct{}{}) + return + } + writeJSON(w, http.StatusOK, history) +} diff --git a/internal/service/day_service.go b/internal/service/day_service.go new file mode 100644 index 0000000..d7a8e3a --- /dev/null +++ b/internal/service/day_service.go @@ -0,0 +1,167 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +var ( + ErrWeekAlreadyClosed = errors.New("week is already closed") + ErrWeekNotClosed = errors.New("week is not closed") + ErrWeekHasUnclosedDays = errors.New("all workdays in the week must be closed before closing the week") + ErrDayHasNoEntries = errors.New("no completed entries found for this day") +) + +// DayService handles closing and marking days. +type DayService struct { + entries *store.EntryStore + closedDays *store.ClosedDayStore + settings *store.SettingsStore + tz *time.Location +} + +func NewDayService( + entries *store.EntryStore, + closedDays *store.ClosedDayStore, + settings *store.SettingsStore, + tz *time.Location, +) *DayService { + return &DayService{ + entries: entries, + closedDays: closedDays, + settings: settings, + tz: tz, + } +} + +// CloseDay merges all completed entries for the given day key into a ClosedDay. +// Returns ErrRunningEntryOnDay if a running entry exists. +// Returns ErrDayAlreadyClosed if already closed. +func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) { + // Already closed? + existing, err := s.closedDays.GetByDayKey(ctx, dayKey) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if existing != nil { + return nil, ErrDayAlreadyClosed + } + + // Running entry? + running, err := s.entries.RunningEntryForDay(ctx, dayKey) + if err != nil { + return nil, err + } + if running != nil { + return nil, ErrRunningEntryOnDay + } + + // Load all completed entries for the day + entries, err := s.entries.ListByDayKey(ctx, dayKey) + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, ErrDayHasNoEntries + } + + var minStart, maxEnd, totalMs int64 + minStart = entries[0].StartTime + for _, e := range entries { + if e.EndTime == nil { + continue + } + if e.StartTime < minStart { + minStart = e.StartTime + } + if *e.EndTime > maxEnd { + maxEnd = *e.EndTime + } + totalMs += e.DurationMs() + } + + now := time.Now().UnixMilli() + cd := &domain.ClosedDay{ + DayKey: dayKey, + StartTime: &minStart, + EndTime: &maxEnd, + WorkedMs: totalMs, + Kind: domain.DayKindWork, + ClosedAt: now, + UpdatedAt: now, + } + if err := s.closedDays.Upsert(ctx, cd); err != nil { + return nil, err + } + return cd, nil +} + +// MarkDay closes a day as holiday, vacation, or sick. +// worked_ms is set to the expected daily ms from settings at that date. +func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.DayKind) (*domain.ClosedDay, error) { + if !kind.Valid() || kind == domain.DayKindWork { + return nil, errors.New("kind must be one of: holiday, vacation, sick") + } + + // Lookup settings effective on that day + set, err := s.settings.Current(ctx, dayKey) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + var workedMs int64 + if set != nil { + // Parse the weekday from dayKey + t, err := time.ParseInLocation("2006-01-02", dayKey, s.tz) + if err == nil && set.IsWorkday(int(t.Weekday())) { + workedMs = set.DailyExpectedMs() + } + } + + now := time.Now().UnixMilli() + cd := &domain.ClosedDay{ + DayKey: dayKey, + WorkedMs: workedMs, + Kind: kind, + ClosedAt: now, + UpdatedAt: now, + } + if err := s.closedDays.Upsert(ctx, cd); err != nil { + return nil, err + } + return cd, nil +} + +// ReopenDay deletes the closed_days row for a day, making it editable again. +func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error { + existing, err := s.closedDays.GetByDayKey(ctx, dayKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrDayNotClosed + } + return err + } + if existing == nil { + return ErrDayNotClosed + } + return s.closedDays.Delete(ctx, dayKey) +} + +// GetDay returns the closed day for a given day key, or nil if open. +func (s *DayService) GetDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) { + cd, err := s.closedDays.GetByDayKey(ctx, dayKey) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return cd, err +} + +// ListDays returns closed days within a date range. +func (s *DayService) ListDays(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) { + return s.closedDays.ListByDateRange(ctx, fromDayKey, toDayKey) +} diff --git a/internal/service/day_service_test.go b/internal/service/day_service_test.go new file mode 100644 index 0000000..aca96ad --- /dev/null +++ b/internal/service/day_service_test.go @@ -0,0 +1,162 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/service" + "github.com/wotra/wotra/internal/store" +) + +func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayService, *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) + settingsStore := store.NewSettingsStore(db) + tz, _ := time.LoadLocation("UTC") + + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) + settingsSvc := service.NewSettingsService(settingsStore) + return entrySvc, daySvc, settingsSvc +} + +func TestCloseDayBasic(t *testing.T) { + ctx := context.Background() + entrySvc, daySvc, _ := newTestDayServices(t) + + // Start and stop an entry + _, err := entrySvc.Start(ctx, "work") + if err != nil { + t.Fatal(err) + } + _, err = entrySvc.Stop(ctx) + if err != nil { + t.Fatal(err) + } + + today := time.Now().UTC().Format("2006-01-02") + cd, err := daySvc.CloseDay(ctx, today) + if err != nil { + t.Fatalf("CloseDay: %v", err) + } + if cd.Kind != domain.DayKindWork { + t.Errorf("expected kind=work, got %s", cd.Kind) + } + if cd.WorkedMs < 0 { + t.Error("expected non-negative worked_ms") + } +} + +func TestCloseDayWithRunningEntryFails(t *testing.T) { + ctx := context.Background() + entrySvc, daySvc, _ := newTestDayServices(t) + + _, err := entrySvc.Start(ctx, "") + if err != nil { + t.Fatal(err) + } + + today := time.Now().UTC().Format("2006-01-02") + _, err = daySvc.CloseDay(ctx, today) + if err == nil { + t.Fatal("expected error closing day with running entry") + } + if err != service.ErrRunningEntryOnDay { + t.Fatalf("expected ErrRunningEntryOnDay, got %v", err) + } +} + +func TestCloseDayTwiceFails(t *testing.T) { + ctx := context.Background() + entrySvc, daySvc, _ := newTestDayServices(t) + + entrySvc.Start(ctx, "") + entrySvc.Stop(ctx) + today := time.Now().UTC().Format("2006-01-02") + daySvc.CloseDay(ctx, today) + + _, err := daySvc.CloseDay(ctx, today) + if err != service.ErrDayAlreadyClosed { + t.Fatalf("expected ErrDayAlreadyClosed, got %v", err) + } +} + +func TestMarkDayHoliday(t *testing.T) { + ctx := context.Background() + _, daySvc, _ := newTestDayServices(t) + + today := time.Now().UTC().Format("2006-01-02") + cd, err := daySvc.MarkDay(ctx, today, domain.DayKindHoliday) + if err != nil { + t.Fatalf("MarkDay: %v", err) + } + if cd.Kind != domain.DayKindHoliday { + t.Errorf("expected kind=holiday, got %s", cd.Kind) + } + // Monday-Friday = 40h/5 = 8h = 28800000ms expected + if today == time.Now().UTC().Format("2006-01-02") { + wd := int(time.Now().UTC().Weekday()) + // workdays Mon-Fri (mask=31): weekdays 1-5 + if wd >= 1 && wd <= 5 { + if cd.WorkedMs != 8*3600*1000 { + t.Errorf("expected 8h worked_ms for holiday on workday, got %d", cd.WorkedMs) + } + } + } +} + +func TestReopenDay(t *testing.T) { + ctx := context.Background() + entrySvc, daySvc, _ := newTestDayServices(t) + + entrySvc.Start(ctx, "") + entrySvc.Stop(ctx) + today := time.Now().UTC().Format("2006-01-02") + daySvc.CloseDay(ctx, today) + + if err := daySvc.ReopenDay(ctx, today); err != nil { + t.Fatalf("ReopenDay: %v", err) + } + + // Should be closeable again + _, err := daySvc.CloseDay(ctx, today) + if err != nil { + t.Fatalf("CloseDay after reopen: %v", err) + } +} + +func TestSettingsUpsertAndHistory(t *testing.T) { + ctx := context.Background() + _, _, settingsSvc := newTestDayServices(t) + + set, err := settingsSvc.Upsert(ctx, service.UpsertSettingsInput{ + EffectiveFrom: "2024-01-01", + HoursPerWeek: 38.5, + WorkdaysMask: 31, + Timezone: "Europe/Berlin", + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if set.HoursPerWeek != 38.5 { + t.Errorf("expected 38.5 h/week, got %f", set.HoursPerWeek) + } + + history, err := settingsSvc.History(ctx) + if err != nil { + t.Fatal(err) + } + // Seeded default + our new one + if len(history) < 2 { + t.Fatalf("expected >=2 history entries, got %d", len(history)) + } +} diff --git a/internal/service/settings_service.go b/internal/service/settings_service.go new file mode 100644 index 0000000..8551953 --- /dev/null +++ b/internal/service/settings_service.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +var ( + ErrNoSettings = errors.New("no settings found") + ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127") + ErrInvalidHours = errors.New("hours_per_week must be > 0") +) + +// SettingsService manages settings with effective-from history. +type SettingsService struct { + store *store.SettingsStore +} + +func NewSettingsService(s *store.SettingsStore) *SettingsService { + return &SettingsService{store: s} +} + +// Current returns settings effective as of today. +func (s *SettingsService) Current(ctx context.Context) (*domain.Settings, error) { + today := time.Now().UTC().Format("2006-01-02") + set, err := s.store.Current(ctx, today) + if err != nil { + return nil, ErrNoSettings + } + return set, nil +} + +// AsOf returns settings effective on the given day key (YYYY-MM-DD). +func (s *SettingsService) AsOf(ctx context.Context, dayKey string) (*domain.Settings, error) { + set, err := s.store.Current(ctx, dayKey) + if err != nil { + return nil, ErrNoSettings + } + return set, nil +} + +// History returns all settings rows, newest first. +func (s *SettingsService) History(ctx context.Context) ([]*domain.Settings, error) { + return s.store.History(ctx) +} + +// UpsertInput is the payload for creating a new settings version. +type UpsertSettingsInput struct { + EffectiveFrom string + HoursPerWeek float64 + WorkdaysMask int + Timezone string +} + +// Upsert creates a new settings row (always inserts; effective_from allows retroactive changes). +func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) (*domain.Settings, error) { + if input.HoursPerWeek <= 0 { + return nil, ErrInvalidHours + } + if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 { + return nil, ErrInvalidWorkdaysMask + } + if input.Timezone == "" { + input.Timezone = "UTC" + } + if _, err := time.LoadLocation(input.Timezone); err != nil { + return nil, fmt.Errorf("invalid timezone: %w", err) + } + if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil { + return nil, fmt.Errorf("invalid effective_from: %w", err) + } + + set := &domain.Settings{ + EffectiveFrom: input.EffectiveFrom, + HoursPerWeek: input.HoursPerWeek, + WorkdaysMask: input.WorkdaysMask, + Timezone: input.Timezone, + CreatedAt: time.Now().UnixMilli(), + } + if err := s.store.Insert(ctx, set); err != nil { + return nil, err + } + return set, nil +}