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:
@@ -124,7 +124,8 @@ export const weeks = {
|
||||
return request<ClosedWeek[]>('GET', `/weeks?${params}`);
|
||||
},
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
let weekKeys = pastWeekKeys(12);
|
||||
let closedWeeksMap: Record<string, ClosedWeek> = $state({});
|
||||
let closedDaysMap: Record<string, ClosedDay> = $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}<p class="error">{error}</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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user