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:
@@ -19,10 +19,21 @@ func NewWeekHandler(svc *service.WeekService) *WeekHandler {
|
|||||||
|
|
||||||
func (h *WeekHandler) Routes(r chi.Router) {
|
func (h *WeekHandler) Routes(r chi.Router) {
|
||||||
r.Get("/weeks", h.List)
|
r.Get("/weeks", h.List)
|
||||||
|
r.Get("/weeks/balance", h.Balance)
|
||||||
r.Post("/weeks/{week_key}/close", h.Close)
|
r.Post("/weeks/{week_key}/close", h.Close)
|
||||||
r.Delete("/weeks/{week_key}/close", h.Reopen)
|
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
|
// List GET /api/weeks?from=YYYY-Www&to=YYYY-Www
|
||||||
func (h *WeekHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *WeekHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
from := r.URL.Query().Get("from")
|
from := r.URL.Query().Get("from")
|
||||||
|
|||||||
@@ -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.
|
// WeekDayKeysExported is exported for testing.
|
||||||
var WeekDayKeysExported = weekDayKeys
|
var WeekDayKeysExported = weekDayKeys
|
||||||
|
|
||||||
|
|||||||
@@ -241,3 +241,48 @@ func TestWeekSnapshotUpdatesWhenDayReopened(t *testing.T) {
|
|||||||
t.Errorf("updated delta_ms: want %dms, got %dms", wantDelta, cw2.DeltaMs)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ func (s *ClosedWeekStore) ListByRange(ctx context.Context, fromWeekKey, toWeekKe
|
|||||||
return result, rows.Err()
|
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.
|
// SumWorkedMsForWeek sums worked_ms across closed_days for the given day keys.
|
||||||
func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) {
|
func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) {
|
||||||
if len(dayKeys) == 0 {
|
if len(dayKeys) == 0 {
|
||||||
|
|||||||
53
internal/store/closed_week_store_test.go
Normal file
53
internal/store/closed_week_store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,8 @@ export const weeks = {
|
|||||||
return request<ClosedWeek[]>('GET', `/weeks?${params}`);
|
return request<ClosedWeek[]>('GET', `/weeks?${params}`);
|
||||||
},
|
},
|
||||||
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
|
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
|
||||||
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`)
|
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`),
|
||||||
|
balance: () => request<{ total_delta_ms: number; closed_week_count: number }>('GET', '/weeks/balance')
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
let weekKeys = pastWeekKeys(12);
|
let weekKeys = pastWeekKeys(12);
|
||||||
let closedWeeksMap: Record<string, ClosedWeek> = $state({});
|
let closedWeeksMap: Record<string, ClosedWeek> = $state({});
|
||||||
let closedDaysMap: Record<string, ClosedDay> = $state({});
|
let closedDaysMap: Record<string, ClosedDay> = $state({});
|
||||||
|
let balance: { total_delta_ms: number; closed_week_count: number } | null = $state(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
@@ -30,14 +31,14 @@
|
|||||||
try {
|
try {
|
||||||
const from = weekKeys[0];
|
const from = weekKeys[0];
|
||||||
const to = weekKeys[weekKeys.length - 1];
|
const to = weekKeys[weekKeys.length - 1];
|
||||||
// Estimate day range
|
const [ws, ds, bal] = await Promise.all([
|
||||||
const dayFrom = from.replace('-W', '') + '-01-01'; // rough
|
|
||||||
const [ws, ds] = await Promise.all([
|
|
||||||
weeks.list(from, to),
|
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]));
|
closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w]));
|
||||||
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
|
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
|
||||||
|
balance = bal;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -51,6 +52,19 @@
|
|||||||
{#if error}<p class="error">{error}</p>{/if}
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
{#if loading}<p>Loading…</p>{/if}
|
{#if loading}<p>Loading…</p>{/if}
|
||||||
|
|
||||||
|
<!-- Overall balance card -->
|
||||||
|
<div class="balance-card">
|
||||||
|
<span class="balance-label">Overall balance</span>
|
||||||
|
{#if balance !== null}
|
||||||
|
<span class="balance-value" class:positive={balance.total_delta_ms > 0} class:negative={balance.total_delta_ms < 0}>
|
||||||
|
{formatDelta(balance.total_delta_ms)}
|
||||||
|
</span>
|
||||||
|
<span class="balance-meta">across {balance.closed_week_count} closed {balance.closed_week_count === 1 ? 'week' : 'weeks'}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="balance-value">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -102,6 +116,37 @@
|
|||||||
h1 { margin: 0 0 1rem; }
|
h1 { margin: 0 0 1rem; }
|
||||||
h2 { margin: 2rem 0 0.5rem; }
|
h2 { margin: 2rem 0 0.5rem; }
|
||||||
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
|
.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; }
|
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; }
|
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; }
|
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="vacation"] { background: #cce5ff; color: #004085; }
|
||||||
.badge[data-kind="sick"] { background: #f8d7da; color: #721c24; }
|
.badge[data-kind="sick"] { background: #f8d7da; color: #721c24; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user