feat: overall overtime balance on history page

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
This commit is contained in:
2026-04-30 20:01:24 +02:00
parent 15bf3c3a18
commit 8ca838fa6e
7 changed files with 184 additions and 5 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}