From 8ca838fa6e40eab06e785434dd268591b74c0fc1 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Thu, 30 Apr 2026 20:01:24 +0200 Subject: [PATCH] feat: overall overtime balance on history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - ClosedWeekStore.SumDelta: single SQL aggregate returning total delta_ms and row count across all closed_weeks - WeekService.Balance: thin passthrough returning BalanceResult{TotalDeltaMs, ClosedWeekCount} - GET /api/weeks/balance handler; route registered alongside /weeks list/close/reopen - Tests: store-level SumDelta (empty + populated), service-level Balance (empty + 2 weeks) Frontend: - weeks.balance() added to API client - History page: balance card at top, fetched in parallel with existing data - Loading state shows '—'; once loaded shows formatDelta value in green/red/gray - Shows 'across N closed weeks' count alongside the value --- internal/handler/week_handler.go | 11 +++++ internal/service/week_service.go | 15 +++++++ internal/service/week_service_test.go | 45 ++++++++++++++++++++ internal/store/closed_week_store.go | 8 ++++ internal/store/closed_week_store_test.go | 53 +++++++++++++++++++++++ web/src/lib/api/client.ts | 3 +- web/src/routes/history/+page.svelte | 54 ++++++++++++++++++++++-- 7 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 internal/store/closed_week_store_test.go diff --git a/internal/handler/week_handler.go b/internal/handler/week_handler.go index 640060a..ea253bc 100644 --- a/internal/handler/week_handler.go +++ b/internal/handler/week_handler.go @@ -19,10 +19,21 @@ func NewWeekHandler(svc *service.WeekService) *WeekHandler { func (h *WeekHandler) Routes(r chi.Router) { r.Get("/weeks", h.List) + r.Get("/weeks/balance", h.Balance) r.Post("/weeks/{week_key}/close", h.Close) r.Delete("/weeks/{week_key}/close", h.Reopen) } +// Balance GET /api/weeks/balance +func (h *WeekHandler) Balance(w http.ResponseWriter, r *http.Request) { + bal, err := h.svc.Balance(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, bal) +} + // 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") diff --git a/internal/service/week_service.go b/internal/service/week_service.go index e783262..e4d999d 100644 --- a/internal/service/week_service.go +++ b/internal/service/week_service.go @@ -42,6 +42,21 @@ 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"` +} + +// Balance returns the sum of delta_ms across all closed weeks. +func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) { + total, count, err := s.closedWeeks.SumDelta(ctx) + if err != nil { + return BalanceResult{}, err + } + return BalanceResult{TotalDeltaMs: total, ClosedWeekCount: count}, nil +} + // WeekDayKeysExported is exported for testing. var WeekDayKeysExported = weekDayKeys diff --git a/internal/service/week_service_test.go b/internal/service/week_service_test.go index 3224997..7e8d87b 100644 --- a/internal/service/week_service_test.go +++ b/internal/service/week_service_test.go @@ -241,3 +241,48 @@ func TestWeekSnapshotUpdatesWhenDayReopened(t *testing.T) { t.Errorf("updated delta_ms: want %dms, got %dms", wantDelta, cw2.DeltaMs) } } + +func TestWeekServiceBalance(t *testing.T) { + ctx := context.Background() + _, daySvc, weekSvc, _ := newFullServices(t) + + // Empty — no closed weeks yet. + 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) + } + + // Close two weeks as holiday (worked = expected → delta = 0 each). + for _, spec := range []struct { + weekKey string + monday time.Time + }{ + {"2024-W03", time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)}, + {"2024-W04", time.Date(2024, 1, 22, 0, 0, 0, 0, time.UTC)}, + } { + for i := 0; i < 5; i++ { + dk := spec.monday.AddDate(0, 0, i).Format("2006-01-02") + if _, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday); err != nil { + t.Fatalf("MarkDay %s: %v", dk, err) + } + } + if _, err := weekSvc.CloseWeek(ctx, spec.weekKey); err != nil { + t.Fatalf("CloseWeek %s: %v", spec.weekKey, err) + } + } + + bal, err = weekSvc.Balance(ctx) + if err != nil { + t.Fatalf("Balance (populated): %v", err) + } + if bal.ClosedWeekCount != 2 { + t.Errorf("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) + } +} diff --git a/internal/store/closed_week_store.go b/internal/store/closed_week_store.go index 983b92f..a10ccc9 100644 --- a/internal/store/closed_week_store.go +++ b/internal/store/closed_week_store.go @@ -65,6 +65,14 @@ func (s *ClosedWeekStore) ListByRange(ctx context.Context, fromWeekKey, toWeekKe return result, rows.Err() } +// SumDelta returns the sum of delta_ms and the count of rows across all closed_weeks. +func (s *ClosedWeekStore) SumDelta(ctx context.Context) (totalDeltaMs int64, count int, err error) { + err = s.db.QueryRowContext(ctx, + `SELECT COALESCE(SUM(delta_ms), 0), COUNT(*) FROM closed_weeks`, + ).Scan(&totalDeltaMs, &count) + return +} + // SumWorkedMsForWeek sums worked_ms across closed_days for the given day keys. func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) { if len(dayKeys) == 0 { diff --git a/internal/store/closed_week_store_test.go b/internal/store/closed_week_store_test.go new file mode 100644 index 0000000..b915322 --- /dev/null +++ b/internal/store/closed_week_store_test.go @@ -0,0 +1,53 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +func TestClosedWeekStoreSumDelta(t *testing.T) { + db, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := store.NewClosedWeekStore(db) + ctx := context.Background() + now := time.Now().UnixMilli() + + // Empty table → 0, 0 + total, count, err := s.SumDelta(ctx) + if err != nil { + t.Fatalf("SumDelta on empty table: %v", err) + } + if total != 0 || count != 0 { + t.Fatalf("empty: want (0,0), got (%d,%d)", total, count) + } + + // Insert two weeks with known deltas. + for _, w := range []*domain.ClosedWeek{ + {WeekKey: "2024-W01", ExpectedMs: 144_000_000, WorkedMs: 147_600_000, DeltaMs: 3_600_000, ClosedAt: now, UpdatedAt: now}, + {WeekKey: "2024-W02", ExpectedMs: 144_000_000, WorkedMs: 142_200_000, DeltaMs: -1_800_000, ClosedAt: now, UpdatedAt: now}, + } { + if err := s.Upsert(ctx, w); err != nil { + t.Fatal(err) + } + } + + 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) // 1_800_000 + if total != want { + t.Errorf("total: want %d, got %d", want, total) + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index d00d194..d9a437e 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -124,7 +124,8 @@ export const weeks = { return request('GET', `/weeks?${params}`); }, close: (weekKey: string) => request('POST', `/weeks/${weekKey}/close`), - reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`) + reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`), + balance: () => request<{ total_delta_ms: number; closed_week_count: number }>('GET', '/weeks/balance') }; // ─── Settings ──────────────────────────────────────────────────────────────── diff --git a/web/src/routes/history/+page.svelte b/web/src/routes/history/+page.svelte index 1088bc9..5079e23 100644 --- a/web/src/routes/history/+page.svelte +++ b/web/src/routes/history/+page.svelte @@ -22,6 +22,7 @@ let weekKeys = pastWeekKeys(12); let closedWeeksMap: Record = $state({}); let closedDaysMap: Record = $state({}); + let balance: { total_delta_ms: number; closed_week_count: number } | null = $state(null); let error = $state(''); let loading = $state(false); @@ -30,14 +31,14 @@ try { const from = weekKeys[0]; const to = weekKeys[weekKeys.length - 1]; - // Estimate day range - const dayFrom = from.replace('-W', '') + '-01-01'; // rough - const [ws, ds] = await Promise.all([ + const [ws, ds, bal] = await Promise.all([ weeks.list(from, to), - days.list('2000-01-01', '2100-01-01') + days.list('2000-01-01', '2100-01-01'), + weeks.balance() ]); closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w])); closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d])); + balance = bal; } catch (e) { error = e instanceof ApiError ? e.message : String(e); } finally { @@ -51,6 +52,19 @@ {#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)} + + across {balance.closed_week_count} closed {balance.closed_week_count === 1 ? 'week' : 'weeks'} + {:else} + + {/if} +
+ @@ -102,6 +116,37 @@ h1 { margin: 0 0 1rem; } h2 { margin: 2rem 0 0.5rem; } .error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; } + + /* Balance card */ + .balance-card { + display: flex; + align-items: baseline; + gap: 0.75rem; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + } + .balance-label { + font-size: 0.85rem; + color: #6c757d; + font-weight: 500; + white-space: nowrap; + } + .balance-value { + font-size: 1.5rem; + font-weight: 700; + color: #6c757d; + letter-spacing: -0.5px; + } + .balance-value.positive { color: #27ae60; } + .balance-value.negative { color: #c0392b; } + .balance-meta { + font-size: 0.8rem; + color: #adb5bd; + } + table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; } th { background: #f8f9fa; padding: 0.6rem 1rem; text-align: left; font-size: 0.85rem; color: #6c757d; border-bottom: 1px solid #dee2e6; } td { padding: 0.5rem 1rem; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; } @@ -114,3 +159,4 @@ .badge[data-kind="vacation"] { background: #cce5ff; color: #004085; } .badge[data-kind="sick"] { background: #f8d7da; color: #721c24; } +