diff --git a/PLAN.md b/PLAN.md index 015d0b5..1067776 100644 --- a/PLAN.md +++ b/PLAN.md @@ -88,13 +88,25 @@ CREATE TABLE settings_history ( -- Sync event log (last-write-wins per entity row) CREATE TABLE sync_log ( - entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks' + entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks' | 'balance_adjustments' entity_id TEXT NOT NULL, op TEXT NOT NULL, -- 'upsert' | 'delete' version INTEGER NOT NULL, -- monotonic server-assigned payload TEXT NOT NULL, -- JSON snapshot PRIMARY KEY (entity, entity_id, version) ); + +-- Balance adjustments: manual corrective entries that modify the overall overtime balance +-- without touching week math. Signed delta_ms (positive = credit, negative = debit). +CREATE TABLE balance_adjustments ( + id TEXT PRIMARY KEY, -- client-generated UUIDv7 (sync-friendly) + delta_ms INTEGER NOT NULL, -- signed; non-zero enforced at service layer + note TEXT NOT NULL DEFAULT '', -- optional free-text reason + effective_at INTEGER NOT NULL, -- unix ms; backdatable + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at); ``` ### API Surface (REST/JSON) @@ -118,6 +130,13 @@ DELETE /api/days/{day_key}/close (reopen day) GET /api/weeks?from=YYYY-Www&to=YYYY-Www POST /api/weeks/{week_key}/close -> ClosedWeek DELETE /api/weeks/{week_key}/close (reopen week) +GET /api/weeks/balance -> BalanceSummary + +# Balance adjustments +GET /api/balance/adjustments -> []BalanceAdjustment +POST /api/balance/adjustments { delta_ms, note?, effective_at } -> BalanceAdjustment +PUT /api/balance/adjustments/{id} { delta_ms, note?, effective_at } -> BalanceAdjustment +DELETE /api/balance/adjustments/{id} # Settings GET /api/settings # current effective settings @@ -243,7 +262,11 @@ Staged implementation: | 4 | Backend guard: reject future-day intervals | pending | | 5 | Bottom nav Today shortcut; delete /today route | pending | -### M8 — Future +### M8 — Balance adjustments ✅ + +Manual corrective entries on the History page that adjust the overall overtime balance without touching week math. Separate `balance_adjustments` table with signed `delta_ms`, optional `note`, and `effective_at` (backdatable). Balance summary combines `Σ closed_weeks.delta_ms + Σ balance_adjustments.delta_ms`. + +### M9 — Future CSV/JSON export, monthly summary view. ## 7. Decisions & Rationale @@ -260,5 +283,7 @@ CSV/JSON export, monthly summary view. | Week close settings | Use close-time date, not week Monday | Settings changed mid-week apply to that week's close | | weekDayKeys formula | `(weekday+6)%7` for days-since-Monday | Avoids sign bug when Jan 4 is Sunday (affects year 2026) | | Merged Today+Week | Single `/week` route with day selection | Reduces duplication; week context always visible | -| Future intervals | Rejected by server (400) | Nonsensical to track time that hasn't happened | +| Balance adjustments | Separate `balance_adjustments` table | Avoids conflating measured deltas with manual corrections; preserves week math invariant (`delta_ms = worked - expected`) | +| Balance adjustment scope | Closed weeks only for auto balance | In-progress week delta shown separately in week view; mixing would make balance jitter | +| Balance adjustment IDs | TEXT (UUIDv7, client-generated) | Consistent with `entries`; allows offline creation and sync | | Test framework | Vitest (frontend) + Go testing (backend) | Automated coverage for capability logic and key utilities | diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go index c57511d..2c2e292 100644 --- a/cmd/wotra/main.go +++ b/cmd/wotra/main.go @@ -42,13 +42,14 @@ func main() { entryStore := store.NewEntryStore(db) closedDayStore := store.NewClosedDayStore(db) closedWeekStore := store.NewClosedWeekStore(db) + adjustmentStore := store.NewBalanceAdjustmentStore(db) settingsStore := store.NewSettingsStore(db) syncStore := store.NewSyncStore(db) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) settingsSvc := service.NewSettingsService(settingsStore) - weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) // Background goroutine: auto-stop entries that cross midnight ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 5693faf..fc0aeaf 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -109,6 +109,18 @@ func (s *Settings) IsWorkday(wd int) bool { return s.WorkdaysMask&bit != 0 } +// BalanceAdjustment is a manual corrective entry that adjusts the overall +// overtime balance without touching week math. DeltaMs is signed: positive +// credits time, negative debits time. +type BalanceAdjustment struct { + ID string `json:"id"` // UUIDv7, client-generated + DeltaMs int64 `json:"delta_ms"` // signed, non-zero + Note string `json:"note"` // optional free-text reason + EffectiveAt int64 `json:"effective_at"` // unix ms, backdatable + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + func popcount(n int) int { count := 0 for n != 0 { diff --git a/internal/handler/week_handler.go b/internal/handler/week_handler.go index ea253bc..3a27bb2 100644 --- a/internal/handler/week_handler.go +++ b/internal/handler/week_handler.go @@ -1,14 +1,18 @@ package handler import ( + "encoding/json" "errors" "net/http" + "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/service" ) -// WeekHandler serves /api/weeks routes. +// WeekHandler serves /api/weeks and /api/balance/adjustments routes. type WeekHandler struct { svc *service.WeekService } @@ -22,6 +26,11 @@ func (h *WeekHandler) Routes(r chi.Router) { r.Get("/weeks/balance", h.Balance) r.Post("/weeks/{week_key}/close", h.Close) r.Delete("/weeks/{week_key}/close", h.Reopen) + + r.Get("/balance/adjustments", h.ListAdjustments) + r.Post("/balance/adjustments", h.CreateAdjustment) + r.Put("/balance/adjustments/{id}", h.UpdateAdjustment) + r.Delete("/balance/adjustments/{id}", h.DeleteAdjustment) } // Balance GET /api/weeks/balance @@ -90,3 +99,107 @@ func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// ── Balance adjustments ─────────────────────────────────────────────────────── + +// ListAdjustments GET /api/balance/adjustments +func (h *WeekHandler) ListAdjustments(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.ListAdjustments(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if list == nil { + writeJSON(w, http.StatusOK, []*domain.BalanceAdjustment{}) + return + } + writeJSON(w, http.StatusOK, list) +} + +type adjustmentRequest struct { + DeltaMs int64 `json:"delta_ms"` + Note string `json:"note"` + EffectiveAt *int64 `json:"effective_at"` // optional; defaults to now +} + +// CreateAdjustment POST /api/balance/adjustments +func (h *WeekHandler) CreateAdjustment(w http.ResponseWriter, r *http.Request) { + var req adjustmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + now := time.Now().UnixMilli() + effectiveAt := now + if req.EffectiveAt != nil { + effectiveAt = *req.EffectiveAt + } + a := &domain.BalanceAdjustment{ + ID: uuid.New().String(), + DeltaMs: req.DeltaMs, + Note: req.Note, + EffectiveAt: effectiveAt, + CreatedAt: now, + UpdatedAt: now, + } + created, err := h.svc.CreateAdjustment(r.Context(), a) + if err != nil { + if errors.Is(err, service.ErrZeroAdjustment) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, created) +} + +// UpdateAdjustment PUT /api/balance/adjustments/{id} +func (h *WeekHandler) UpdateAdjustment(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req adjustmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + now := time.Now().UnixMilli() + effectiveAt := now + if req.EffectiveAt != nil { + effectiveAt = *req.EffectiveAt + } + a := &domain.BalanceAdjustment{ + ID: id, + DeltaMs: req.DeltaMs, + Note: req.Note, + EffectiveAt: effectiveAt, + UpdatedAt: now, + } + updated, err := h.svc.UpdateAdjustment(r.Context(), a) + if err != nil { + switch { + case errors.Is(err, service.ErrZeroAdjustment): + writeError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, service.ErrAdjustmentNotFound): + writeError(w, http.StatusNotFound, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, updated) +} + +// DeleteAdjustment DELETE /api/balance/adjustments/{id} +func (h *WeekHandler) DeleteAdjustment(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.svc.DeleteAdjustment(r.Context(), id); err != nil { + switch { + case errors.Is(err, service.ErrAdjustmentNotFound): + 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 index e4d999d..f0ff77b 100644 --- a/internal/service/week_service.go +++ b/internal/service/week_service.go @@ -11,13 +11,21 @@ import ( "github.com/wotra/wotra/internal/store" ) +// Sentinel errors for balance adjustments. +var ( + ErrZeroAdjustment = errors.New("delta_ms must be non-zero") + ErrAdjustmentNotFound = errors.New("balance adjustment not found") +) + // WeekService handles closing weeks and computing overtime/undertime. type WeekService struct { - closedDays *store.ClosedDayStore - closedWeeks *store.ClosedWeekStore - entries *store.EntryStore - settings *store.SettingsStore - db interface { + closedDays *store.ClosedDayStore + closedWeeks *store.ClosedWeekStore + adjustments *store.BalanceAdjustmentStore + syncStore *store.SyncStore + entries *store.EntryStore + settings *store.SettingsStore + db interface { QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row } rawDB *sql.DB @@ -29,12 +37,16 @@ func NewWeekService( closedWeeks *store.ClosedWeekStore, entries *store.EntryStore, settings *store.SettingsStore, + adjustments *store.BalanceAdjustmentStore, + syncStore *store.SyncStore, rawDB *sql.DB, tz *time.Location, ) *WeekService { return &WeekService{ closedDays: closedDays, closedWeeks: closedWeeks, + adjustments: adjustments, + syncStore: syncStore, entries: entries, settings: settings, rawDB: rawDB, @@ -44,17 +56,78 @@ func NewWeekService( // BalanceResult holds the overall overtime/undertime balance across all closed weeks. type BalanceResult struct { - TotalDeltaMs int64 `json:"total_delta_ms"` - ClosedWeekCount int `json:"closed_week_count"` + TotalDeltaMs int64 `json:"total_delta_ms"` + WeeksDeltaMs int64 `json:"weeks_delta_ms"` + AdjustmentsDeltaMs int64 `json:"adjustments_delta_ms"` + ClosedWeekCount int `json:"closed_week_count"` + AdjustmentCount int `json:"adjustment_count"` } -// Balance returns the sum of delta_ms across all closed weeks. +// Balance returns the combined overtime balance: closed weeks + manual adjustments. func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) { - total, count, err := s.closedWeeks.SumDelta(ctx) + weeksTotal, weekCount, err := s.closedWeeks.SumDelta(ctx) if err != nil { return BalanceResult{}, err } - return BalanceResult{TotalDeltaMs: total, ClosedWeekCount: count}, nil + adjTotal, adjCount, err := s.adjustments.SumDelta(ctx) + if err != nil { + return BalanceResult{}, err + } + return BalanceResult{ + TotalDeltaMs: weeksTotal + adjTotal, + WeeksDeltaMs: weeksTotal, + AdjustmentsDeltaMs: adjTotal, + ClosedWeekCount: weekCount, + AdjustmentCount: adjCount, + }, nil +} + +// ListAdjustments returns all balance adjustments ordered by effective_at DESC. +func (s *WeekService) ListAdjustments(ctx context.Context) ([]*domain.BalanceAdjustment, error) { + return s.adjustments.List(ctx) +} + +// CreateAdjustment creates and sync-logs a new balance adjustment. +func (s *WeekService) CreateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) { + if a.DeltaMs == 0 { + return nil, ErrZeroAdjustment + } + if err := s.adjustments.Create(ctx, a); err != nil { + return nil, err + } + _ = s.syncStore.LogBalanceAdjustment(ctx, a) // best-effort + return a, nil +} + +// UpdateAdjustment updates and sync-logs an existing balance adjustment. +func (s *WeekService) UpdateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) { + if a.DeltaMs == 0 { + return nil, ErrZeroAdjustment + } + if err := s.adjustments.Update(ctx, a); err != nil { + if errors.Is(err, store.ErrAdjustmentNotFound) { + return nil, ErrAdjustmentNotFound + } + return nil, err + } + got, err := s.adjustments.GetByID(ctx, a.ID) + if err != nil { + return nil, err + } + _ = s.syncStore.LogBalanceAdjustment(ctx, got) // best-effort + return got, nil +} + +// DeleteAdjustment deletes and sync-logs a balance adjustment. +func (s *WeekService) DeleteAdjustment(ctx context.Context, id string) error { + if err := s.adjustments.Delete(ctx, id); err != nil { + if errors.Is(err, store.ErrAdjustmentNotFound) { + return ErrAdjustmentNotFound + } + return err + } + _ = s.syncStore.LogBalanceAdjustmentDelete(ctx, id) // best-effort + return nil } // WeekDayKeysExported is exported for testing. diff --git a/internal/service/week_service_test.go b/internal/service/week_service_test.go index 7e8d87b..c4db370 100644 --- a/internal/service/week_service_test.go +++ b/internal/service/week_service_test.go @@ -22,12 +22,14 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, entryStore := store.NewEntryStore(db) closedDayStore := store.NewClosedDayStore(db) closedWeekStore := store.NewClosedWeekStore(db) + adjustmentStore := store.NewBalanceAdjustmentStore(db) + syncStore := store.NewSyncStore(db) settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) - weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz) + weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) settingsSvc := service.NewSettingsService(settingsStore) return entrySvc, daySvc, weekSvc, settingsSvc } @@ -246,13 +248,13 @@ func TestWeekServiceBalance(t *testing.T) { ctx := context.Background() _, daySvc, weekSvc, _ := newFullServices(t) - // Empty — no closed weeks yet. + // Empty — no closed weeks, no adjustments. bal, err := weekSvc.Balance(ctx) if err != nil { t.Fatalf("Balance (empty): %v", err) } - if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 { - t.Errorf("empty: want {0,0}, got {%d,%d}", bal.TotalDeltaMs, bal.ClosedWeekCount) + if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 || bal.AdjustmentCount != 0 { + t.Errorf("empty: want all zeros, got %+v", bal) } // Close two weeks as holiday (worked = expected → delta = 0 each). @@ -279,10 +281,73 @@ func TestWeekServiceBalance(t *testing.T) { t.Fatalf("Balance (populated): %v", err) } if bal.ClosedWeekCount != 2 { - t.Errorf("count: want 2, got %d", bal.ClosedWeekCount) + t.Errorf("closed_week_count: want 2, got %d", bal.ClosedWeekCount) } - // Both weeks: worked == expected (holiday marks = full hours), delta = 0. - if bal.TotalDeltaMs != 0 { - t.Errorf("total_delta_ms: want 0, got %d", bal.TotalDeltaMs) + if bal.TotalDeltaMs != 0 || bal.WeeksDeltaMs != 0 { + t.Errorf("weeks-only total: want 0, got %+v", bal) + } + + // Add a +2h adjustment. + now := time.Now().UnixMilli() + adj, err := weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{ + ID: "adj-1", DeltaMs: 7_200_000, Note: "carry-over", + EffectiveAt: now, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateAdjustment: %v", err) + } + if adj.ID != "adj-1" { + t.Errorf("CreateAdjustment ID: want adj-1, got %s", adj.ID) + } + + bal, err = weekSvc.Balance(ctx) + if err != nil { + t.Fatalf("Balance with adjustment: %v", err) + } + if bal.TotalDeltaMs != 7_200_000 { + t.Errorf("total with adj: want 7200000, got %d", bal.TotalDeltaMs) + } + if bal.AdjustmentsDeltaMs != 7_200_000 { + t.Errorf("adjustments_delta_ms: want 7200000, got %d", bal.AdjustmentsDeltaMs) + } + if bal.AdjustmentCount != 1 { + t.Errorf("adjustment_count: want 1, got %d", bal.AdjustmentCount) + } + + // Zero delta rejected. + _, err = weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{ + ID: "adj-zero", DeltaMs: 0, EffectiveAt: now, CreatedAt: now, UpdatedAt: now, + }) + if err != service.ErrZeroAdjustment { + t.Errorf("zero delta: want ErrZeroAdjustment, got %v", err) + } + + // Update adjustment. + adj.DeltaMs = 3_600_000 + adj.UpdatedAt = now + 1 + updated, err := weekSvc.UpdateAdjustment(ctx, adj) + if err != nil { + t.Fatalf("UpdateAdjustment: %v", err) + } + if updated.DeltaMs != 3_600_000 { + t.Errorf("updated delta_ms: want 3600000, got %d", updated.DeltaMs) + } + bal, _ = weekSvc.Balance(ctx) + if bal.TotalDeltaMs != 3_600_000 { + t.Errorf("balance after update: want 3600000, got %d", bal.TotalDeltaMs) + } + + // Delete adjustment. + if err := weekSvc.DeleteAdjustment(ctx, "adj-1"); err != nil { + t.Fatalf("DeleteAdjustment: %v", err) + } + bal, _ = weekSvc.Balance(ctx) + if bal.TotalDeltaMs != 0 || bal.AdjustmentCount != 0 { + t.Errorf("balance after delete: want 0/0, got %d/%d", bal.TotalDeltaMs, bal.AdjustmentCount) + } + + // Delete missing. + if err := weekSvc.DeleteAdjustment(ctx, "no-such"); err != service.ErrAdjustmentNotFound { + t.Errorf("delete missing: want ErrAdjustmentNotFound, got %v", err) } } diff --git a/internal/store/balance_adjustment_store.go b/internal/store/balance_adjustment_store.go new file mode 100644 index 0000000..f3fb543 --- /dev/null +++ b/internal/store/balance_adjustment_store.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "database/sql" + "errors" + + "github.com/wotra/wotra/internal/domain" +) + +// BalanceAdjustmentStore handles persistence for balance_adjustments. +type BalanceAdjustmentStore struct { + db *sql.DB +} + +func NewBalanceAdjustmentStore(db *sql.DB) *BalanceAdjustmentStore { + return &BalanceAdjustmentStore{db: db} +} + +func (s *BalanceAdjustmentStore) Create(ctx context.Context, a *domain.BalanceAdjustment) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO balance_adjustments (id, delta_ms, note, effective_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + a.ID, a.DeltaMs, a.Note, a.EffectiveAt, a.CreatedAt, a.UpdatedAt, + ) + return err +} + +func (s *BalanceAdjustmentStore) Update(ctx context.Context, a *domain.BalanceAdjustment) error { + res, err := s.db.ExecContext(ctx, + `UPDATE balance_adjustments SET delta_ms=?, note=?, effective_at=?, updated_at=? WHERE id=?`, + a.DeltaMs, a.Note, a.EffectiveAt, a.UpdatedAt, a.ID, + ) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrAdjustmentNotFound + } + return nil +} + +func (s *BalanceAdjustmentStore) Delete(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM balance_adjustments WHERE id=?`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrAdjustmentNotFound + } + return nil +} + +func (s *BalanceAdjustmentStore) GetByID(ctx context.Context, id string) (*domain.BalanceAdjustment, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, delta_ms, note, effective_at, created_at, updated_at + FROM balance_adjustments WHERE id=?`, id) + var a domain.BalanceAdjustment + err := row.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAdjustmentNotFound + } + if err != nil { + return nil, err + } + return &a, nil +} + +// List returns all adjustments ordered by effective_at DESC. +func (s *BalanceAdjustmentStore) List(ctx context.Context) ([]*domain.BalanceAdjustment, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, delta_ms, note, effective_at, created_at, updated_at + FROM balance_adjustments ORDER BY effective_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []*domain.BalanceAdjustment + for rows.Next() { + var a domain.BalanceAdjustment + if err := rows.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, err + } + result = append(result, &a) + } + return result, rows.Err() +} + +// SumDelta returns the sum of delta_ms and row count across all adjustments. +func (s *BalanceAdjustmentStore) SumDelta(ctx context.Context) (totalDeltaMs int64, count int, err error) { + err = s.db.QueryRowContext(ctx, + `SELECT COALESCE(SUM(delta_ms), 0), COUNT(*) FROM balance_adjustments`, + ).Scan(&totalDeltaMs, &count) + return +} + +// ErrAdjustmentNotFound is returned when no balance_adjustment row matches the given ID. +var ErrAdjustmentNotFound = errors.New("balance adjustment not found") diff --git a/internal/store/balance_adjustment_store_test.go b/internal/store/balance_adjustment_store_test.go new file mode 100644 index 0000000..3d8cef1 --- /dev/null +++ b/internal/store/balance_adjustment_store_test.go @@ -0,0 +1,128 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +func TestBalanceAdjustmentStoreCRUD(t *testing.T) { + db, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := store.NewBalanceAdjustmentStore(db) + ctx := context.Background() + now := time.Now().UnixMilli() + + // SumDelta on empty table. + total, count, err := s.SumDelta(ctx) + if err != nil { + t.Fatalf("SumDelta empty: %v", err) + } + if total != 0 || count != 0 { + t.Fatalf("empty: want (0,0), got (%d,%d)", total, count) + } + + // List on empty table returns nil slice, no error. + list, err := s.List(ctx) + if err != nil { + t.Fatalf("List empty: %v", err) + } + if len(list) != 0 { + t.Errorf("List empty: want 0, got %d", len(list)) + } + + // Create two adjustments. + a1 := &domain.BalanceAdjustment{ + ID: "id-1", DeltaMs: 3_600_000, Note: "carry-over", + EffectiveAt: now - 86400000, CreatedAt: now, UpdatedAt: now, + } + a2 := &domain.BalanceAdjustment{ + ID: "id-2", DeltaMs: -1_800_000, Note: "", + EffectiveAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := s.Create(ctx, a1); err != nil { + t.Fatalf("Create a1: %v", err) + } + if err := s.Create(ctx, a2); err != nil { + t.Fatalf("Create a2: %v", err) + } + + // GetByID round-trip. + got, err := s.GetByID(ctx, "id-1") + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.DeltaMs != a1.DeltaMs || got.Note != a1.Note { + t.Errorf("GetByID: want {%d,%q}, got {%d,%q}", a1.DeltaMs, a1.Note, got.DeltaMs, got.Note) + } + + // GetByID missing. + _, err = s.GetByID(ctx, "no-such") + if err != store.ErrAdjustmentNotFound { + t.Errorf("GetByID missing: want ErrAdjustmentNotFound, got %v", err) + } + + // SumDelta with two rows. + total, count, err = s.SumDelta(ctx) + if err != nil { + t.Fatalf("SumDelta: %v", err) + } + if count != 2 { + t.Errorf("count: want 2, got %d", count) + } + want := int64(3_600_000 - 1_800_000) + if total != want { + t.Errorf("total: want %d, got %d", want, total) + } + + // List returns newest effective_at first (a2 has now, a1 has now-1day). + list, err = s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 2 { + t.Fatalf("List len: want 2, got %d", len(list)) + } + if list[0].ID != "id-2" { + t.Errorf("List[0]: want id-2, got %s", list[0].ID) + } + + // Update a1. + a1.DeltaMs = 7_200_000 + a1.Note = "updated" + a1.UpdatedAt = now + 1000 + if err := s.Update(ctx, a1); err != nil { + t.Fatalf("Update: %v", err) + } + got, _ = s.GetByID(ctx, "id-1") + if got.DeltaMs != 7_200_000 || got.Note != "updated" { + t.Errorf("after Update: want {7200000,updated}, got {%d,%q}", got.DeltaMs, got.Note) + } + + // Update missing. + missing := &domain.BalanceAdjustment{ID: "no-such", DeltaMs: 1, UpdatedAt: now} + if err := s.Update(ctx, missing); err != store.ErrAdjustmentNotFound { + t.Errorf("Update missing: want ErrAdjustmentNotFound, got %v", err) + } + + // Delete a2. + if err := s.Delete(ctx, "id-2"); err != nil { + t.Fatalf("Delete: %v", err) + } + total, count, _ = s.SumDelta(ctx) + if count != 1 || total != 7_200_000 { + t.Errorf("after Delete: want (7200000,1), got (%d,%d)", total, count) + } + + // Delete missing. + if err := s.Delete(ctx, "no-such"); err != store.ErrAdjustmentNotFound { + t.Errorf("Delete missing: want ErrAdjustmentNotFound, got %v", err) + } +} diff --git a/internal/store/db.go b/internal/store/db.go index 4a8be2f..b8ea699 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -3,15 +3,18 @@ package store import ( "context" "database/sql" - _ "embed" "fmt" + "io/fs" + "sort" "strings" _ "modernc.org/sqlite" + + "embed" ) -//go:embed 001_initial.sql -var schema string +//go:embed migrations/*.sql +var migrationsFS embed.FS // Open opens (or creates) the SQLite database at path and runs migrations. func Open(path string) (*sql.DB, error) { @@ -31,22 +34,51 @@ func Open(path string) (*sql.DB, error) { return db, nil } -// migrate runs embedded SQL migrations. Simple single-file approach: we track -// a user_version pragma and apply the schema once if version == 0. +// migrate applies all SQL migration files in migrations/ in filename order. +// user_version tracks the last applied migration index (1-based). func migrate(db *sql.DB) error { var version int if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { return err } - if version >= 1 { - return nil + + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) } + // Sort by name so 001_*, 002_* apply in order. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + ctx := context.Background() - // Strip migration comments and split into individual statements - stmts := splitStatements(schema) - for _, stmt := range stmts { + for i, entry := range entries { + migrationNum := i + 1 // 1-based + if version >= migrationNum { + continue + } + + data, err := migrationsFS.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read %s: %w", entry.Name(), err) + } + + if err := applySchema(db, ctx, string(data)); err != nil { + return fmt.Errorf("migration %d (%s): %w", migrationNum, entry.Name(), err) + } + + if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", migrationNum)); err != nil { + return fmt.Errorf("set user_version = %d: %w", migrationNum, err) + } + } + + return nil +} + +func applySchema(db *sql.DB, ctx context.Context, sql string) error { + for _, stmt := range splitStatements(sql) { stmt = strings.TrimSpace(stmt) if stmt == "" { continue @@ -55,12 +87,6 @@ func migrate(db *sql.DB) error { return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err) } } - - // PRAGMA user_version cannot be set inside a regular transaction in all SQLite versions; - // execute it as a standalone statement. - if _, err := db.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil { - return err - } return nil } diff --git a/internal/store/db_test.go b/internal/store/db_test.go index c15c36e..526b348 100644 --- a/internal/store/db_test.go +++ b/internal/store/db_test.go @@ -13,7 +13,7 @@ func TestMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"} + tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log", "balance_adjustments"} for _, tbl := range tables { var name string err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name) diff --git a/internal/store/001_initial.sql b/internal/store/migrations/001_initial.sql similarity index 100% rename from internal/store/001_initial.sql rename to internal/store/migrations/001_initial.sql diff --git a/internal/store/migrations/002_balance_adjustments.sql b/internal/store/migrations/002_balance_adjustments.sql new file mode 100644 index 0000000..2cff8a9 --- /dev/null +++ b/internal/store/migrations/002_balance_adjustments.sql @@ -0,0 +1,14 @@ +-- +migrate Up +CREATE TABLE balance_adjustments ( + id TEXT PRIMARY KEY, + delta_ms INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + effective_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at); + +-- +migrate Down +DROP INDEX IF EXISTS idx_balance_adjustments_effective_at; +DROP TABLE IF EXISTS balance_adjustments; diff --git a/internal/store/sync_store.go b/internal/store/sync_store.go index 06932e4..fcf7eb3 100644 --- a/internal/store/sync_store.go +++ b/internal/store/sync_store.go @@ -96,6 +96,21 @@ func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) err return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload)) } +// LogBalanceAdjustment appends a balance_adjustment upsert to the sync log. +func (s *SyncStore) LogBalanceAdjustment(ctx context.Context, a *domain.BalanceAdjustment) error { + payload, err := json.Marshal(a) + if err != nil { + return err + } + return s.log(ctx, "balance_adjustments", a.ID, "upsert", string(payload)) +} + +// LogBalanceAdjustmentDelete appends a balance_adjustment delete to the sync log. +func (s *SyncStore) LogBalanceAdjustmentDelete(ctx context.Context, id string) error { + payload := fmt.Sprintf(`{"id":%q}`, id) + return s.log(ctx, "balance_adjustments", id, "delete", payload) +} + func (s *SyncStore) log(ctx context.Context, entity, entityID, op, payload string) error { version, err := s.nextVersion(ctx) if err != nil { diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index d9a437e..a4054a4 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -81,6 +81,23 @@ export interface Settings { created_at: number; } +export interface BalanceAdjustment { + id: string; + delta_ms: number; + note: string; + effective_at: number; // unix ms + created_at: number; + updated_at: number; +} + +export interface BalanceSummary { + total_delta_ms: number; + weeks_delta_ms: number; + adjustments_delta_ms: number; + closed_week_count: number; + adjustment_count: number; +} + // ─── Entries ───────────────────────────────────────────────────────────────── export const entries = { @@ -125,7 +142,18 @@ export const weeks = { }, close: (weekKey: string) => request('POST', `/weeks/${weekKey}/close`), reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`), - balance: () => request<{ total_delta_ms: number; closed_week_count: number }>('GET', '/weeks/balance') + balance: () => request('GET', '/weeks/balance') +}; + +// ─── Balance adjustments ───────────────────────────────────────────────────── + +export const balance = { + list: () => request('GET', '/balance/adjustments'), + create: (body: { delta_ms: number; note?: string; effective_at?: number }) => + request('POST', '/balance/adjustments', body), + update: (id: string, body: { delta_ms: number; note?: string; effective_at?: number }) => + request('PUT', `/balance/adjustments/${id}`, body), + delete: (id: string) => request('DELETE', `/balance/adjustments/${id}`) }; // ─── Settings ──────────────────────────────────────────────────────────────── diff --git a/web/src/lib/stores/db.ts b/web/src/lib/stores/db.ts index 2a4b277..939ffb7 100644 --- a/web/src/lib/stores/db.ts +++ b/web/src/lib/stores/db.ts @@ -1,9 +1,9 @@ import Dexie, { type Table } from 'dexie'; -import type { Entry, ClosedDay, ClosedWeek, Settings } from '$lib/api/client'; +import type { Entry, ClosedDay, ClosedWeek, Settings, BalanceAdjustment } from '$lib/api/client'; export interface OutboxItem { id?: number; // auto-increment - entity: string; // 'entries' | 'closed_days' | 'closed_weeks' | 'settings' + entity: string; // 'entries' | 'closed_days' | 'closed_weeks' | 'settings' | 'balance_adjustments' entity_id: string; op: 'upsert' | 'delete'; payload: string; // JSON @@ -15,6 +15,7 @@ export class WotraDB extends Dexie { closed_days!: Table; closed_weeks!: Table; settings_history!: Table; + balance_adjustments!: Table; outbox!: Table; meta!: Table<{ key: string; value: string }, string>; @@ -28,6 +29,9 @@ export class WotraDB extends Dexie { outbox: '++id, entity, entity_id', meta: 'key' }); + this.version(2).stores({ + balance_adjustments: 'id, effective_at, updated_at' + }); } } diff --git a/web/src/lib/stores/sync.ts b/web/src/lib/stores/sync.ts index fd34265..35e17f9 100644 --- a/web/src/lib/stores/sync.ts +++ b/web/src/lib/stores/sync.ts @@ -67,6 +67,7 @@ async function applyUpsert(entity: string, data: unknown) { case 'closed_days': await db.closed_days.put(data as any); break; case 'closed_weeks': await db.closed_weeks.put(data as any); break; case 'settings_history': await db.settings_history.put(data as any); break; + case 'balance_adjustments': await db.balance_adjustments.put(data as any); break; } } @@ -75,6 +76,7 @@ async function applyDelete(entity: string, id: string) { case 'entries': await db.entries.delete(id); break; case 'closed_days': await db.closed_days.delete(id); break; case 'closed_weeks': await db.closed_weeks.delete(id); break; + case 'balance_adjustments': await db.balance_adjustments.delete(id); break; } } diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts index f1539eb..b7b0cf7 100644 --- a/web/src/lib/utils.test.ts +++ b/web/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { dayCapabilities, weekDayKeys, isWorkday } from './utils'; +import { dayCapabilities, weekDayKeys, isWorkday, composeDeltaMs, decomposeDeltaMs } from './utils'; describe('dayCapabilities', () => { const today = '2026-04-30'; @@ -82,3 +82,35 @@ describe('isWorkday', () => { it('Saturday is not a workday', () => expect(isWorkday('2026-05-02', monToFri)).toBe(false)); it('Sunday is not a workday', () => expect(isWorkday('2026-05-03', monToFri)).toBe(false)); }); + +describe('composeDeltaMs', () => { + it('positive: 2h 30m → +9000000', () => { + expect(composeDeltaMs('+', 2, 30)).toBe(9_000_000); + }); + it('negative: 1h 15m → -4500000', () => { + expect(composeDeltaMs('-', 1, 15)).toBe(-4_500_000); + }); + it('zero hours and minutes → 0', () => { + expect(composeDeltaMs('+', 0, 0)).toBe(0); + }); + it('negative zero → 0 (no negative zero)', () => { + expect(composeDeltaMs('-', 0, 0)).toBe(0); + }); +}); + +describe('decomposeDeltaMs', () => { + it('positive 3h 24m', () => { + expect(decomposeDeltaMs(12_240_000)).toEqual({ sign: '+', hours: 3, minutes: 24 }); + }); + it('negative 1h 30m', () => { + expect(decomposeDeltaMs(-5_400_000)).toEqual({ sign: '-', hours: 1, minutes: 30 }); + }); + it('zero', () => { + expect(decomposeDeltaMs(0)).toEqual({ sign: '+', hours: 0, minutes: 0 }); + }); + it('round-trips through compose', () => { + const original = -7_320_000; // -2h 2m + const { sign, hours, minutes } = decomposeDeltaMs(original); + expect(composeDeltaMs(sign, hours, minutes)).toBe(original); + }); +}); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 7b9c831..423d087 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -71,6 +71,28 @@ export function formatDelta(ms: number): string { return `${sign}${formatDurationShort(Math.abs(ms))}`; } +/** + * Compose a signed delta_ms from UI inputs. + * sign: '+' for credit, '-' for debit. + * hours and minutes must be non-negative integers. + */ +export function composeDeltaMs(sign: '+' | '-', hours: number, minutes: number): number { + const magnitude = (Math.floor(hours) * 3_600_000) + (Math.floor(minutes) * 60_000); + if (magnitude === 0) return 0; + return sign === '-' ? -magnitude : magnitude; +} + +/** + * Decompose a signed delta_ms into UI-friendly parts. + * Returns sign, whole hours, and remaining minutes (0–59). + */ +export function decomposeDeltaMs(ms: number): { sign: '+' | '-'; hours: number; minutes: number } { + const sign: '+' | '-' = ms < 0 ? '-' : '+'; + const abs = Math.abs(ms); + const totalMinutes = Math.floor(abs / 60_000); + return { sign, hours: Math.floor(totalMinutes / 60), minutes: totalMinutes % 60 }; +} + /** Parse "HH:MM" time string on a given dayKey (YYYY-MM-DD) into unix ms (local time). */ export function parseTimeInput(dayKey: string, hhmm: string): number | null { if (!/^\d{2}:\d{2}$/.test(hhmm)) return null; diff --git a/web/src/routes/history/+page.svelte b/web/src/routes/history/+page.svelte index 5079e23..8168a95 100644 --- a/web/src/routes/history/+page.svelte +++ b/web/src/routes/history/+page.svelte @@ -1,7 +1,11 @@
@@ -52,19 +180,26 @@ {#if error}

{error}

{/if} {#if loading}

Loading…

{/if} - +
Overall balance - {#if balance !== null} - 0} class:negative={balance.total_delta_ms < 0}> - {formatDelta(balance.total_delta_ms)} + {#if summary !== null} + 0} class:negative={summary.total_delta_ms < 0}> + {formatDelta(summary.total_delta_ms)} + + + across {summary.closed_week_count} closed {summary.closed_week_count === 1 ? 'week' : 'weeks'} + {#if summary.adjustment_count > 0} + · {summary.adjustment_count} {summary.adjustment_count === 1 ? 'adjustment' : 'adjustments'} + (weeks {formatDelta(summary.weeks_delta_ms)}, adj {formatDelta(summary.adjustments_delta_ms)}) + {/if} - across {balance.closed_week_count} closed {balance.closed_week_count === 1 ? 'week' : 'weeks'} {:else} {/if}
+ @@ -91,6 +226,7 @@
+

Recent Days

@@ -110,53 +246,185 @@ {/each}
+ + +
+

Balance adjustments

+ {#if !showAddForm} + + {/if} +
+ + {#if showAddForm} +
+ {#if addError}

{addError}

{/if} +
+ + + + + +
+
+ + +
+
+ {/if} + + {#if adjustments.length > 0} + + + + + + + + + + + {#each adjustments as a (a.id)} + {#if editingId === a.id} + + + + {:else} + + + + + + + {/if} + {/each} + +
EffectiveDeltaNote
+ {#if editError}

{editError}

{/if} +
+ + + + + +
+
+ + +
+
{msToDateInput(a.effective_at)} 0} class:negative={a.delta_ms < 0}>{formatDelta(a.delta_ms)}{a.note} + + +
+ {:else if !showAddForm} +

No adjustments yet.

+ {/if}
-