50 KiB
Phase 3b·2 — Reports + All-Users Export — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give the admin a Rapporten screen with period + worker/type/activity filters showing headline totals and three breakdowns (per worker / activity / type), plus an all-users CSV export of the matching completed sessions.
Architecture: Two new endpoints under the existing /api/admin/* admin guard share one filtered-query helper. GET /api/admin/report fetches the filtered joined rows and aggregates in JS into ReportResponse. GET /api/admin/export streams the same rows as CSV via a shared buildSessionsCsv(rows, {includeWorker}) extracted from the existing self-scoped /api/export. A tables-only Reports screen drives both via React Query; the bearer-auth'd CSV download uses a raw fetch → Blob.
Tech Stack: Hono, Drizzle (libsql/SQLite), zod (@solelog/shared), React + React Query + Vite (admin), Vitest + Testing Library.
Global Constraints
- Completed-only: every total and exported row is
status='completed'. Active/discarded never counts. (Verbatim from spec §scope-decision 3.) - Metrics (all four):
worked_seconds(sum ofduration_seconds, already excludes paused),paused_seconds,pairs(sum ofpair_count),sessions(row count). - Admin-gated: both endpoints live under the existing
adminRoutes.use('/api/admin/*', …)guard → 401 (no session) / 403 (non-admin) for free. - Date semantics: client sends
from/toas ISO instants spanning whole local days; server compares them againststart_timeinclusively. No server-side timezone logic. - Dependency-light: no new runtime dependencies (no chart library). Reuse
lib/csv(quote,formatDuration) and adminlib/elapsed(formatTime). - CSV byte-compatibility: the refactor must keep
/api/exportoutput byte-identical (existingapps/api/test/export.test.tsis the regression guard). - Code style: oxfmt — 2-space, single quotes, semicolons, width 100, trailing-comma
all. Runnpx oxfmt <files>before committing.
Task 1: DRY CSV builder — extract buildSessionsCsv
Extract the inline header/row builder from the self-scoped /api/export into a reusable function so the new admin export shares one format. Output for the existing route stays byte-identical.
Files:
- Modify:
apps/api/src/lib/csv.ts(addSessionCsvRow+buildSessionsCsv) - Modify:
apps/api/src/routes/sessions.ts:12-67(use the new builder) - Test:
apps/api/test/csv.test.ts(new — unit test for the builder) - Regression:
apps/api/test/export.test.ts(must still pass unchanged)
Interfaces:
-
Produces:
buildSessionsCsv(rows: SessionCsvRow[], opts?: { includeWorker?: boolean }): stringand theSessionCsvRowinterface — consumed by Task 3. -
Step 1: Write the failing unit test
Create apps/api/test/csv.test.ts:
import { describe, it, expect } from 'vitest';
import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv';
const row: SessionCsvRow = {
id: 7,
activityName: 'Frezen',
userName: 'Jan',
insoleType: 'Kurk',
pairCount: 2,
startTime: new Date('2026-06-17T08:00:00Z'),
endTime: new Date('2026-06-17T08:01:30Z'),
durationSeconds: 90,
pausedSeconds: 0,
};
describe('buildSessionsCsv', () => {
it('omits the Worker column by default (legacy header)', () => {
const lines = buildSessionsCsv([row]).split('\n');
expect(lines[0]).toBe(
'"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines[1]).toContain('"Frezen"');
expect(lines[1]).toContain('"00:01:30"');
expect(lines[1]).not.toContain('"Jan"');
});
it('prepends a Worker column when includeWorker is true', () => {
const lines = buildSessionsCsv([row], { includeWorker: true }).split('\n');
expect(lines[0]).toBe(
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines[1].startsWith('"Jan"')).toBe(true);
});
});
- Step 2: Run it to verify it fails
Run: yarn workspace @solelog/api test csv
Expected: FAIL — buildSessionsCsv is not exported.
- Step 3: Implement the builder
Append to apps/api/src/lib/csv.ts:
// One completed session, flattened for CSV. Times accept Date | number | string.
export interface SessionCsvRow {
id: number;
activityName: string | null;
userName?: string | null;
insoleType: string | null;
pairCount: number;
startTime: Date | number | string;
endTime: Date | number | string | null;
durationSeconds: number | null;
pausedSeconds: number | null;
}
// Build the CSV body shared by the worker self-export and the admin all-users export.
// includeWorker prepends a Worker column; everything else matches the legacy format byte-for-byte.
export function buildSessionsCsv(
rows: SessionCsvRow[],
opts: { includeWorker?: boolean } = {},
): string {
const includeWorker = opts.includeWorker ?? false;
const header = [
...(includeWorker ? ['Worker'] : []),
'ID',
'Task',
'Insole Type',
'No. of Insoles',
'Date',
'Total Duration',
'Paused Duration',
'Start Time',
'End Time',
]
.map(quote)
.join(',');
const dataLines = rows.map((row) => {
const start = new Date(row.startTime);
const end = row.endTime ? new Date(row.endTime) : null;
return [
...(includeWorker ? [row.userName ?? ''] : []),
row.id,
row.activityName ?? '',
row.insoleType ?? 'Kurk',
row.pairCount ?? 2,
start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
formatDuration(row.durationSeconds ?? 0),
formatDuration(row.pausedSeconds ?? 0),
start.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
end
? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '',
]
.map(quote)
.join(',');
});
return [header, ...dataLines].join('\n');
}
- Step 4: Rewire the self-scoped export to use the builder
In apps/api/src/routes/sessions.ts, replace the body of the /api/export handler (the header/dataLines/csv block, current lines ~23-61) so it builds via the shared function. The query stays the same; map its rows into SessionCsvRow:
const csv = buildSessionsCsv(
rows.map(({ session, activityName }) => ({
id: session.id,
activityName,
insoleType: session.insoleType,
pairCount: session.pairCount,
startTime: session.startTime,
endTime: session.endTime,
durationSeconds: session.durationSeconds,
pausedSeconds: session.pausedSeconds,
})),
);
return c.body(csv, 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="insole-production-report.csv"',
});
Update the import line in sessions.ts from import { quote, formatDuration } from '../lib/csv'; to import { buildSessionsCsv } from '../lib/csv'; (the route no longer references quote/formatDuration directly).
- Step 5: Run the new unit test + the regression suite
Run: yarn workspace @solelog/api test csv export
Expected: PASS — both csv.test.ts and the unchanged export.test.ts are green (byte-identical output).
- Step 6: Typecheck, format, commit
yarn workspace @solelog/api typecheck
npx oxfmt apps/api/src/lib/csv.ts apps/api/src/routes/sessions.ts apps/api/test/csv.test.ts
git add apps/api/src/lib/csv.ts apps/api/src/routes/sessions.ts apps/api/test/csv.test.ts
git commit -m "refactor(api): extract buildSessionsCsv shared by worker + admin exports"
Task 2: Shared report contracts + /api/admin/report
Add the ReportResponse contracts and the report endpoint with its shared filter helper. Aggregation happens in JS over the filtered joined rows.
Files:
- Modify:
packages/shared/src/index.ts(append Report* schemas) - Modify:
apps/api/src/routes/admin.ts(addparseReportQuery,buildSessionFilters, the report route, imports) - Test:
apps/api/test/report.test.ts(new)
Interfaces:
-
Consumes:
baseSelect,workSessions,activities,user(already inadmin.ts). -
Produces:
- shared
ReportResponse(+ReportTotals,ReportWorkerRow,ReportActivityRow,ReportTypeRow) — consumed by Tasks 4-5. parseReportQuery(c): ReportQuery | nullandbuildSessionFilters(q: ReportQuery): SQL[]— consumed by Task 3.type ReportQuery = { from: Date; to: Date; userId?: string; insoleType?: string; activityId?: number }.
- shared
-
Step 1: Add the shared contracts
Append to packages/shared/src/index.ts:
export const ReportTotals = z.object({
worked_seconds: z.number().int(),
paused_seconds: z.number().int(),
pairs: z.number().int(),
sessions: z.number().int(),
});
export type ReportTotals = z.infer<typeof ReportTotals>;
export const ReportWorkerRow = ReportTotals.extend({
user_id: z.string(),
user_name: z.string(),
});
export type ReportWorkerRow = z.infer<typeof ReportWorkerRow>;
export const ReportActivityRow = ReportTotals.extend({
activity_id: z.number().int(),
activity_name: z.string(),
});
export type ReportActivityRow = z.infer<typeof ReportActivityRow>;
export const ReportTypeRow = ReportTotals.extend({
insole_type: z.string(),
});
export type ReportTypeRow = z.infer<typeof ReportTypeRow>;
export const ReportResponse = z.object({
range: z.object({ from: z.string(), to: z.string() }),
totals: ReportTotals,
by_worker: z.array(ReportWorkerRow),
by_activity: z.array(ReportActivityRow),
by_type: z.array(ReportTypeRow),
});
export type ReportResponse = z.infer<typeof ReportResponse>;
- Step 2: Write the failing endpoint test
Create apps/api/test/report.test.ts. Reuse the completedSession pattern (start → backdate → stop) so durations are exact. Helper builds a wide from/to window.
import { describe, it, expect } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
async function completed(
app: Hono,
token: string,
activityId: number,
insoleType: string,
durationSeconds: number,
pairCount = 2,
): Promise<number> {
const startRes = await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ activity_id: activityId, insole_type: insoleType, pair_count: pairCount }),
});
const started = await startRes.json();
await db
.update(workSessions)
.set({ startTime: new Date(Date.now() - durationSeconds * 1000) })
.where(eq(workSessions.id, started.id));
await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) });
return started.id as number;
}
describe('GET /api/admin/report', () => {
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
const workerTok = await authToken(app, 'report-worker@example.com');
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) });
expect(res.status).toBe(403);
});
it('400s on a missing or inverted range', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-badrange@example.com', 'admin');
expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe(400);
const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
expect(
(await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status,
).toBe(400);
});
it('aggregates totals that equal the sum of each breakdown', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin');
const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker'
const frezen = await seedActivity('Frezen');
const lijmen = await seedActivity('Lijmen');
await completed(app, workerTok, frezen, 'Kurk', 100, 2);
await completed(app, workerTok, lijmen, 'Berk', 50, 3);
const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.totals.worked_seconds).toBe(150);
expect(body.totals.pairs).toBe(5);
expect(body.totals.sessions).toBe(2);
const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) =>
rows.reduce(
(a, r) => ({
worked_seconds: a.worked_seconds + r.worked_seconds,
pairs: a.pairs + r.pairs,
sessions: a.sessions + r.sessions,
}),
{ worked_seconds: 0, pairs: 0, sessions: 0 },
);
expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
expect(body.by_activity).toHaveLength(2);
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([
'Berk',
'Kurk',
]);
});
it('excludes active/discarded and respects the user and type filters', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin');
const a = await authToken(app, 'report-filter-a@example.com');
const b = await authToken(app, 'report-filter-b@example.com');
const act = await seedActivity('Slijpen');
await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk
await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk
// A: an active session (no duration) — must be excluded.
await app.request('/api/sessions/start', {
method: 'POST',
headers: bearer(a),
body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }),
});
// Resolve A's user id via the roster.
const roster = await (
await app.request('/api/admin/users', { headers: bearer(adminTok) })
).json();
const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com');
const res = await app.request(
`/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`,
{ headers: bearer(adminTok) },
);
const body = await res.json();
expect(body.totals.sessions).toBe(1); // only A's completed Kurk session
expect(body.totals.worked_seconds).toBe(30);
});
it('returns zeros and empty arrays for an empty range', async () => {
const app = createApp();
const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin');
const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`;
const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) });
const body = await res.json();
expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 });
expect(body.by_worker).toEqual([]);
expect(body.by_activity).toEqual([]);
expect(body.by_type).toEqual([]);
});
});
- Step 3: Run it to verify it fails
Run: yarn workspace @solelog/api test report
Expected: FAIL — /api/admin/report returns 404 (route not defined).
- Step 4: Implement the filter helper + route
In apps/api/src/routes/admin.ts:
Update the drizzle import to add and, gte, lte, and the SQL type:
import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
Add the shared InsoleType import:
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
Add (near the top, after computeDuration):
type ReportQuery = {
from: Date;
to: Date;
userId?: string;
insoleType?: string;
activityId?: number;
};
// Parse + validate the shared report/export query params. Returns null on a bad range.
function parseReportQuery(c: { req: { query: (k: string) => string | undefined } }): ReportQuery | null {
const fromRaw = c.req.query('from');
const toRaw = c.req.query('to');
if (!fromRaw || !toRaw) return null;
const from = new Date(fromRaw);
const to = new Date(toRaw);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to < from) return null;
const insoleType = c.req.query('insole_type') || undefined;
if (insoleType && !InsoleType.safeParse(insoleType).success) return null;
const activityIdRaw = c.req.query('activity_id');
let activityId: number | undefined;
if (activityIdRaw) {
activityId = Number.parseInt(activityIdRaw, 10);
if (Number.isNaN(activityId)) return null;
}
return { from, to, userId: c.req.query('user_id') || undefined, insoleType, activityId };
}
// completed + date-range + optional worker/type/activity — shared by report and export.
function buildSessionFilters(q: ReportQuery): SQL[] {
const conds: SQL[] = [
eq(workSessions.status, 'completed'),
gte(workSessions.startTime, q.from),
lte(workSessions.startTime, q.to),
];
if (q.userId) conds.push(eq(workSessions.userId, q.userId));
if (q.insoleType) conds.push(eq(workSessions.insoleType, q.insoleType));
if (q.activityId !== undefined) conds.push(eq(workSessions.activityId, q.activityId));
return conds;
}
Add the route (after the existing /api/admin/users route is fine):
adminRoutes.get('/api/admin/report', async (c) => {
const q = parseReportQuery(c);
if (!q) return c.json({ error: 'Invalid query' }, 400);
const rows = await db
.select(baseSelect)
.from(workSessions)
.leftJoin(activities, eq(workSessions.activityId, activities.id))
.leftJoin(user, eq(workSessions.userId, user.id))
.where(and(...buildSessionFilters(q)));
const totals = { worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
const workers = new Map<
string,
{ user_id: string; user_name: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
>();
const acts = new Map<
number,
{ activity_id: number; activity_name: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
>();
const types = new Map<
string,
{ insole_type: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
>();
for (const r of rows) {
const worked = r.session.durationSeconds ?? 0;
const paused = r.session.pausedSeconds ?? 0;
const pairs = r.session.pairCount ?? 0;
totals.worked_seconds += worked;
totals.paused_seconds += paused;
totals.pairs += pairs;
totals.sessions += 1;
const uid = r.session.userId;
const w =
workers.get(uid) ??
{ user_id: uid, user_name: r.userName ?? 'Onbekend', worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
w.worked_seconds += worked;
w.paused_seconds += paused;
w.pairs += pairs;
w.sessions += 1;
workers.set(uid, w);
const aid = r.session.activityId;
const a =
acts.get(aid) ??
{ activity_id: aid, activity_name: r.activityName ?? 'Onbekend', worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
a.worked_seconds += worked;
a.paused_seconds += paused;
a.pairs += pairs;
a.sessions += 1;
acts.set(aid, a);
const tkey = r.session.insoleType ?? 'Onbekend';
const t =
types.get(tkey) ??
{ insole_type: tkey, worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
t.worked_seconds += worked;
t.paused_seconds += paused;
t.pairs += pairs;
t.sessions += 1;
types.set(tkey, t);
}
const byWorked = (x: { worked_seconds: number }, y: { worked_seconds: number }) =>
y.worked_seconds - x.worked_seconds;
return c.json({
range: { from: q.from.toISOString(), to: q.to.toISOString() },
totals,
by_worker: [...workers.values()].sort(byWorked),
by_activity: [...acts.values()].sort(byWorked),
by_type: [...types.values()].sort(byWorked),
});
});
- Step 5: Run the test to verify it passes
Run: yarn workspace @solelog/api test report
Expected: PASS — all five cases green.
- Step 6: Typecheck, format, commit
yarn workspace @solelog/api typecheck
npx oxfmt packages/shared/src/index.ts apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git add packages/shared/src/index.ts apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git commit -m "feat(api): admin report endpoint + ReportResponse contracts"
Task 3: /api/admin/export — all-users filtered CSV
Reuse buildSessionFilters (Task 2) and buildSessionsCsv (Task 1) to emit the detail CSV with a Worker column.
Files:
- Modify:
apps/api/src/routes/admin.ts(add the export route +buildSessionsCsvimport) - Test:
apps/api/test/report.test.ts(add anadmin exportdescribe block — same file)
Interfaces:
-
Consumes:
parseReportQuery,buildSessionFilters(Task 2),buildSessionsCsv(Task 1). -
Step 1: Add the failing export tests
Append to apps/api/test/report.test.ts:
describe('GET /api/admin/export', () => {
it('401s without a token and 403s for a worker', async () => {
const app = createApp();
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
const workerTok = await authToken(app, 'export-admin-worker@example.com');
const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) });
expect(res.status).toBe(403);
});
it('exports all workers with a leading Worker column, range in the filename', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
const a = await authToken(app, 'export-allusers-a@example.com');
const b = await authToken(app, 'export-allusers-b@example.com');
const act = await seedActivity('Frezen');
await completed(app, a, act, 'Kurk', 90, 2);
await completed(app, b, act, 'Berk', 60, 2);
const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(adminTok) });
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/csv');
expect(res.headers.get('content-disposition')).toMatch(
/attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/,
);
const lines = (await res.text()).split('\n');
expect(lines[0]).toBe(
'"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
);
expect(lines).toHaveLength(3); // header + 2 workers' sessions
expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true);
expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true);
});
it('respects the type filter', async () => {
const app = createApp();
const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin');
const a = await authToken(app, 'export-typefilter-a@example.com');
const act = await seedActivity('Lijmen');
await completed(app, a, act, 'Kurk', 30, 2);
await completed(app, a, act, 'Berk', 40, 2);
const res = await app.request(`/api/admin/export?${WIDE}&insole_type=Berk`, {
headers: bearer(adminTok),
});
const lines = (await res.text()).split('\n');
expect(lines).toHaveLength(2); // header + only the Berk row
expect(lines[1]).toContain('"Berk"');
});
});
- Step 2: Run it to verify it fails
Run: yarn workspace @solelog/api test report
Expected: FAIL — /api/admin/export returns 404.
- Step 3: Implement the export route
In apps/api/src/routes/admin.ts, add buildSessionsCsv to the csv import. There is currently no csv import in admin.ts, so add:
import { buildSessionsCsv } from '../lib/csv';
Add the route after the report route:
adminRoutes.get('/api/admin/export', async (c) => {
const q = parseReportQuery(c);
if (!q) return c.json({ error: 'Invalid query' }, 400);
const rows = await db
.select(baseSelect)
.from(workSessions)
.leftJoin(activities, eq(workSessions.activityId, activities.id))
.leftJoin(user, eq(workSessions.userId, user.id))
.where(and(...buildSessionFilters(q)))
.orderBy(asc(workSessions.startTime));
const csv = buildSessionsCsv(
rows.map((r) => ({
id: r.session.id,
activityName: r.activityName,
userName: r.userName,
insoleType: r.session.insoleType,
pairCount: r.session.pairCount,
startTime: r.session.startTime,
endTime: r.session.endTime,
durationSeconds: r.session.durationSeconds,
pausedSeconds: r.session.pausedSeconds,
})),
{ includeWorker: true },
);
const fromDate = q.from.toISOString().slice(0, 10);
const toDate = q.to.toISOString().slice(0, 10);
return c.body(csv, 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="solelog-report_${fromDate}_${toDate}.csv"`,
});
});
- Step 4: Run the test to verify it passes
Run: yarn workspace @solelog/api test report
Expected: PASS — report + export describes all green.
- Step 5: Full API suite, typecheck, format, commit
yarn workspace @solelog/api test
yarn workspace @solelog/api typecheck
npx oxfmt apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git add apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git commit -m "feat(api): admin all-users filtered CSV export"
Task 4: Admin API client — useReport + downloadExport
The React Query hook for the report and the bearer-auth'd CSV download helper.
Files:
- Create:
apps/admin/src/api/reports.ts - Test:
apps/admin/src/api/reports.test.ts
Interfaces:
-
Consumes:
apiFetch,API_URLfrom../lib/api;getTokenfrom../lib/auth-storage;ReportResponsefrom@solelog/shared. -
Produces:
interface ReportFilters { from: string; to: string; userId?: string; insoleType?: string; activityId?: number }filtersToQuery(f: ReportFilters): stringuseReport(filters: ReportFilters)(React Query)downloadExport(filters: ReportFilters): Promise<void>— consumed by Task 5.
-
Step 1: Write the failing test
Create apps/admin/src/api/reports.test.ts:
import { afterEach, describe, expect, it, vi } from 'vitest';
import { filtersToQuery, downloadExport, type ReportFilters } from './reports';
vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' }));
const FILTERS: ReportFilters = {
from: '2026-06-15T00:00:00.000Z',
to: '2026-06-21T22:00:00.000Z',
userId: 'u1',
insoleType: 'Kurk',
activityId: 7,
};
describe('filtersToQuery', () => {
it('serializes all params', () => {
const q = new URLSearchParams(filtersToQuery(FILTERS));
expect(q.get('from')).toBe(FILTERS.from);
expect(q.get('to')).toBe(FILTERS.to);
expect(q.get('user_id')).toBe('u1');
expect(q.get('insole_type')).toBe('Kurk');
expect(q.get('activity_id')).toBe('7');
});
it('omits empty optional filters', () => {
const q = new URLSearchParams(filtersToQuery({ from: 'a', to: 'b' }));
expect(q.has('user_id')).toBe(false);
expect(q.has('insole_type')).toBe(false);
expect(q.has('activity_id')).toBe(false);
});
});
describe('downloadExport', () => {
afterEach(() => vi.restoreAllMocks());
it('fetches with the bearer token and triggers a download', async () => {
const blob = new Blob(['csv'], { type: 'text/csv' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(blob),
headers: new Headers({
'content-disposition': 'attachment; filename="solelog-report_2026-06-15_2026-06-21.csv"',
}),
});
vi.stubGlobal('fetch', fetchMock);
const createUrl = vi.fn(() => 'blob:x');
const revokeUrl = vi.fn();
vi.stubGlobal('URL', { ...URL, createObjectURL: createUrl, revokeObjectURL: revokeUrl });
const click = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(click);
await downloadExport(FILTERS);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/admin/export?');
expect(url).toContain('insole_type=Kurk');
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok-123');
expect(createUrl).toHaveBeenCalledWith(blob);
expect(click).toHaveBeenCalledTimes(1);
expect(revokeUrl).toHaveBeenCalledWith('blob:x');
});
it('throws on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(downloadExport(FILTERS)).rejects.toThrow();
});
});
- Step 2: Run it to verify it fails
Run: yarn workspace @solelog/admin test reports
Expected: FAIL — ./reports module not found.
- Step 3: Implement the client
Create apps/admin/src/api/reports.ts:
import { useQuery } from '@tanstack/react-query';
import type { ReportResponse } from '@solelog/shared';
import { API_URL, apiFetch } from '../lib/api';
import { getToken } from '../lib/auth-storage';
export interface ReportFilters {
from: string; // ISO instant (start of from-day, local tz)
to: string; // ISO instant (end of to-day, local tz)
userId?: string;
insoleType?: string;
activityId?: number;
}
// Build the querystring; omit empty optional filters.
export function filtersToQuery(f: ReportFilters): string {
const params = new URLSearchParams();
params.set('from', f.from);
params.set('to', f.to);
if (f.userId) params.set('user_id', f.userId);
if (f.insoleType) params.set('insole_type', f.insoleType);
if (f.activityId !== undefined) params.set('activity_id', String(f.activityId));
return params.toString();
}
export function useReport(filters: ReportFilters) {
return useQuery({
queryKey: ['admin', 'report', filters],
queryFn: () => apiFetch<ReportResponse>(`/api/admin/report?${filtersToQuery(filters)}`),
});
}
function filenameFromResponse(res: Response): string {
const cd = res.headers.get('content-disposition');
const m = cd?.match(/filename="(.+?)"/);
return m ? m[1] : 'solelog-report.csv';
}
// The export endpoint is bearer-auth'd, so a plain <a href> can't carry the token:
// fetch with the Authorization header, then download the resulting Blob.
export async function downloadExport(filters: ReportFilters): Promise<void> {
const token = getToken();
const res = await fetch(`${API_URL}/api/admin/export?${filtersToQuery(filters)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Export mislukt: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFromResponse(res);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
- Step 4: Run the test to verify it passes
Run: yarn workspace @solelog/admin test reports
Expected: PASS — both describes green.
- Step 5: Typecheck, format, commit
yarn workspace @solelog/admin typecheck
npx oxfmt apps/admin/src/api/reports.ts apps/admin/src/api/reports.test.ts
git add apps/admin/src/api/reports.ts apps/admin/src/api/reports.test.ts
git commit -m "feat(admin): reports API client (useReport + downloadExport)"
Task 5: Rapporten screen + nav wiring
The tables-only screen: filter bar with presets, headline totals, three breakdown tables, and the export button. Plus the sidebar/route wiring.
Files:
- Create:
apps/admin/src/screens/Reports.tsx - Create:
apps/admin/src/lib/date-range.ts(preset/date helpers — pure, unit-tested) - Modify:
apps/admin/src/components/Sidebar.tsx(move Rapporten into nav) - Modify:
apps/admin/src/App.tsx(add/rapportenroute) - Modify:
apps/admin/src/styles.css(report table styles — minimal, reuse existing classes where possible) - Test:
apps/admin/src/lib/date-range.test.ts,apps/admin/src/screens/Reports.test.tsx
Interfaces:
-
Consumes:
useReport,downloadExport,ReportFilters(Task 4);useAdminUsers(../api/admin-sessions);useActivities(../api/activities);formatTime(../lib/elapsed). -
Produces:
localDayRangehelpers (see below) and the<Reports />screen. -
Step 1: Write the failing date-range helper test
Create apps/admin/src/lib/date-range.test.ts:
import { describe, expect, it } from 'vitest';
import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range';
describe('date-range helpers', () => {
it('dayStartISO/dayEndISO bound a local day', () => {
const start = new Date(dayStartISO('2026-06-17'));
const end = new Date(dayEndISO('2026-06-17'));
expect(start.getHours()).toBe(0);
expect(start.getMinutes()).toBe(0);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
expect(end.getTime()).toBeGreaterThan(start.getTime());
});
it('thisWeek returns Monday..today (from/to are YYYY-MM-DD)', () => {
const ref = new Date('2026-06-17T12:00:00'); // a Wednesday
const { from, to } = thisWeek(ref);
expect(from).toBe('2026-06-15'); // Monday
expect(to).toBe('2026-06-17');
});
it('thisMonth returns the 1st..today', () => {
const ref = new Date('2026-06-17T12:00:00');
const { from, to } = thisMonth(ref);
expect(from).toBe('2026-06-01');
expect(to).toBe('2026-06-17');
});
});
- Step 2: Run it to verify it fails
Run: yarn workspace @solelog/admin test date-range
Expected: FAIL — ./date-range not found.
- Step 3: Implement the date-range helpers
Create apps/admin/src/lib/date-range.ts:
// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what <input type="date"> uses);
// the *ISO instants* sent to the API are derived in the admin's local timezone so a picked
// day means that whole local day.
function pad(n: number): string {
return String(n).padStart(2, '0');
}
export function toDateStr(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Start of the given local day, as an ISO instant.
export function dayStartISO(dateStr: string): string {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
}
// End of the given local day, as an ISO instant.
export function dayEndISO(dateStr: string): string {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
}
export function thisWeek(ref: Date): { from: string; to: string } {
const day = ref.getDay(); // 0=Sun..6=Sat
const diffToMonday = (day + 6) % 7;
const monday = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - diffToMonday);
return { from: toDateStr(monday), to: toDateStr(ref) };
}
export function thisMonth(ref: Date): { from: string; to: string } {
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
return { from: toDateStr(first), to: toDateStr(ref) };
}
// 'Alles' — a wide window from epoch-ish to today.
export function allTime(ref: Date): { from: string; to: string } {
return { from: '2000-01-01', to: toDateStr(ref) };
}
- Step 4: Run the helper test to verify it passes
Run: yarn workspace @solelog/admin test date-range
Expected: PASS.
- Step 5: Write the failing Reports screen test
Create apps/admin/src/screens/Reports.test.tsx:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReportResponse } from '@solelog/shared';
import Reports from './Reports';
import { apiFetch } from '../lib/api';
import * as reportsApi from '../api/reports';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' }));
const mockApiFetch = vi.mocked(apiFetch);
const REPORT: ReportResponse = {
range: { from: '2026-06-15T00:00:00.000Z', to: '2026-06-17T21:59:59.999Z' },
totals: { worked_seconds: 7800, paused_seconds: 600, pairs: 84, sessions: 36 },
by_worker: [
{ user_id: 'u1', user_name: 'Jan', worked_seconds: 4500, paused_seconds: 300, pairs: 48, sessions: 21 },
{ user_id: 'u2', user_name: 'An', worked_seconds: 3300, paused_seconds: 300, pairs: 36, sessions: 15 },
],
by_activity: [
{ activity_id: 1, activity_name: 'Frezen', worked_seconds: 4900, paused_seconds: 200, pairs: 40, sessions: 18 },
{ activity_id: 2, activity_name: 'Lijmen', worked_seconds: 2900, paused_seconds: 400, pairs: 44, sessions: 18 },
],
by_type: [
{ insole_type: 'Kurk', worked_seconds: 5000, paused_seconds: 300, pairs: 50, sessions: 20 },
{ insole_type: 'Berk', worked_seconds: 2800, paused_seconds: 300, pairs: 34, sessions: 16 },
],
};
function mockEndpoints() {
mockApiFetch.mockImplementation((path: string) => {
if (path.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never);
if (path === '/api/admin/users')
return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never);
if (path === '/api/activities') return Promise.resolve([] as never);
return Promise.resolve([] as never);
});
}
function renderReports() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Reports />
</QueryClientProvider>,
);
}
describe('Reports', () => {
beforeEach(() => mockApiFetch.mockReset());
afterEach(() => vi.clearAllMocks());
it('renders headline totals and the three breakdown tables', async () => {
mockEndpoints();
renderReports();
// Headline: 7800s = 02:10:00 worked, 84 zolen, 36 sessies.
expect(await screen.findByText(/02:10:00/)).toBeInTheDocument();
expect(screen.getByText(/84/)).toBeInTheDocument();
expect(screen.getByText(/36/)).toBeInTheDocument();
expect(screen.getByText('Per medewerker')).toBeInTheDocument();
expect(screen.getByText('Per handeling')).toBeInTheDocument();
expect(screen.getByText('Per type')).toBeInTheDocument();
expect(screen.getByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Kurk')).toBeInTheDocument();
});
it('refetches with new params when a preset is clicked', async () => {
mockEndpoints();
renderReports();
await screen.findByText('Jan');
const callsBefore = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
).length;
await userEvent.click(screen.getByRole('button', { name: 'Deze maand' }));
await waitFor(() => {
const reportCalls = mockApiFetch.mock.calls.filter((c) =>
String(c[0]).startsWith('/api/admin/report'),
);
expect(reportCalls.length).toBeGreaterThan(callsBefore);
});
});
it('calls downloadExport with the current filters when Exporteer CSV is clicked', async () => {
mockEndpoints();
const spy = vi.spyOn(reportsApi, 'downloadExport').mockResolvedValue();
renderReports();
await screen.findByText('Jan');
await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(spy).toHaveBeenCalledTimes(1);
const arg = spy.mock.calls[0][0];
expect(arg.from).toBeTruthy();
expect(arg.to).toBeTruthy();
});
});
- Step 6: Run it to verify it fails
Run: yarn workspace @solelog/admin test Reports
Expected: FAIL — ./Reports not found.
- Step 7: Implement the screen
Create apps/admin/src/screens/Reports.tsx. It imports downloadExport via the module namespace so the test's vi.spyOn(reportsApi, 'downloadExport') intercepts it.
import { useMemo, useState } from 'react';
import type { InsoleType, ReportTotals } from '@solelog/shared';
import { useReport, type ReportFilters } from '../api/reports';
import * as reportsApi from '../api/reports';
import { useAdminUsers } from '../api/admin-sessions';
import { useActivities } from '../api/activities';
import { formatTime } from '../lib/elapsed';
import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek, toDateStr } from '../lib/date-range';
type DateRange = { from: string; to: string }; // YYYY-MM-DD
const TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
export default function Reports() {
const [range, setRange] = useState<DateRange>(() => thisWeek(new Date()));
const [userId, setUserId] = useState('');
const [insoleType, setInsoleType] = useState('');
const [activityId, setActivityId] = useState('');
const filters: ReportFilters = useMemo(
() => ({
from: dayStartISO(range.from),
to: dayEndISO(range.to),
userId: userId || undefined,
insoleType: insoleType || undefined,
activityId: activityId ? Number(activityId) : undefined,
}),
[range, userId, insoleType, activityId],
);
const reportQuery = useReport(filters);
const usersQuery = useAdminUsers();
const activitiesQuery = useActivities();
const [exportError, setExportError] = useState<string | null>(null);
async function onExport() {
setExportError(null);
try {
await reportsApi.downloadExport(filters);
} catch {
setExportError('Export mislukt. Probeer opnieuw.');
}
}
const t = reportQuery.data?.totals;
return (
<div className="screen">
<div className="reports-head">
<h1 className="screen-title">Rapporten</h1>
<button type="button" className="btn-primary" onClick={onExport}>
Exporteer CSV
</button>
</div>
<div className="reports-filters">
<div className="reports-presets">
<button type="button" onClick={() => setRange(thisWeek(new Date()))}>
Deze week
</button>
<button type="button" onClick={() => setRange(thisMonth(new Date()))}>
Deze maand
</button>
<button type="button" onClick={() => setRange(allTime(new Date()))}>
Alles
</button>
</div>
<label>
Van
<input
type="date"
value={range.from}
max={range.to}
onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))}
/>
</label>
<label>
Tot
<input
type="date"
value={range.to}
min={range.from}
max={toDateStr(new Date())}
onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))}
/>
</label>
<label>
Medewerker
<select value={userId} onChange={(e) => setUserId(e.target.value)}>
<option value="">Alle</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label>
Type
<select value={insoleType} onChange={(e) => setInsoleType(e.target.value)}>
<option value="">Alle</option>
{TYPES.map((ty) => (
<option key={ty} value={ty}>
{ty}
</option>
))}
</select>
</label>
<label>
Handeling
<select value={activityId} onChange={(e) => setActivityId(e.target.value)}>
<option value="">Alle</option>
{(activitiesQuery.data ?? []).map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
</label>
</div>
{exportError && <p className="form-error">{exportError}</p>}
{reportQuery.isLoading ? (
<p className="muted">Laden…</p>
) : reportQuery.isError ? (
<p className="muted">Kon rapport niet laden.</p>
) : (
<>
<div className="reports-totals">
<strong>{formatTime(t?.worked_seconds ?? 0)}</strong> gewerkt ·{' '}
<strong>{t?.pairs ?? 0}</strong> zolen · <strong>{t?.sessions ?? 0}</strong> sessies ·{' '}
<strong>{formatTime(t?.paused_seconds ?? 0)}</strong> pauze
</div>
<BreakdownTable
title="Per medewerker"
label="Medewerker"
rows={(reportQuery.data?.by_worker ?? []).map((r) => ({ key: r.user_id, name: r.user_name, ...r }))}
/>
<BreakdownTable
title="Per handeling"
label="Handeling"
rows={(reportQuery.data?.by_activity ?? []).map((r) => ({ key: String(r.activity_id), name: r.activity_name, ...r }))}
/>
<BreakdownTable
title="Per type"
label="Type"
rows={(reportQuery.data?.by_type ?? []).map((r) => ({ key: r.insole_type, name: r.insole_type, ...r }))}
/>
</>
)}
</div>
);
}
type BreakdownRow = ReportTotals & { key: string; name: string };
function BreakdownTable({ title, label, rows }: { title: string; label: string; rows: BreakdownRow[] }) {
return (
<section className="reports-section">
<h2 className="reports-section-title">{title}</h2>
{rows.length === 0 ? (
<p className="muted">Geen gegevens.</p>
) : (
<table className="reports-table">
<thead>
<tr>
<th>{label}</th>
<th>Gewerkt</th>
<th>Zolen</th>
<th>Sessies</th>
<th>Pauze</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.key}>
<td>{r.name}</td>
<td>{formatTime(r.worked_seconds)}</td>
<td>{r.pairs}</td>
<td>{r.sessions}</td>
<td>{formatTime(r.paused_seconds)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
- Step 8: Wire nav + route
In apps/admin/src/components/Sidebar.tsx, move Rapporten into navItems and shrink soonItems:
const navItems = [
{ to: '/', label: 'Live' },
{ to: '/handelingen', label: 'Handelingen' },
{ to: '/sessies', label: 'Sessies' },
{ to: '/rapporten', label: 'Rapporten' },
] as const;
// Sections planned for the final Phase 3b cycle — shown muted/disabled.
const soonItems = ['Gebruikers'] as const;
In apps/admin/src/App.tsx, import and add the route:
import Reports from './screens/Reports';
<Route path="/sessies" element={<Sessions />} />
<Route path="/rapporten" element={<Reports />} />
- Step 9: Add minimal styles
Append to apps/admin/src/styles.css:
.reports-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.reports-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
margin: 1rem 0;
}
.reports-filters label {
display: flex;
flex-direction: column;
font-size: 0.8rem;
gap: 0.25rem;
}
.reports-presets {
display: flex;
gap: 0.5rem;
}
.reports-totals {
padding: 0.75rem 1rem;
background: var(--surface, #f4f4f5);
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.reports-section {
margin-bottom: 1.5rem;
}
.reports-section-title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.reports-table {
width: 100%;
border-collapse: collapse;
}
.reports-table th,
.reports-table td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border, #e4e4e7);
}
.reports-table td:not(:first-child),
.reports-table th:not(:first-child) {
text-align: right;
}
- Step 10: Run the screen + helper tests
Run: yarn workspace @solelog/admin test Reports date-range
Expected: PASS — all three Reports cases + date-range cases green.
- Step 11: Full admin suite, typecheck, build, format, commit
yarn workspace @solelog/admin test
yarn workspace @solelog/admin typecheck
yarn workspace @solelog/admin build
npx oxfmt apps/admin/src/screens/Reports.tsx apps/admin/src/screens/Reports.test.tsx apps/admin/src/lib/date-range.ts apps/admin/src/lib/date-range.test.ts apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx
git add apps/admin/src/screens/Reports.tsx apps/admin/src/screens/Reports.test.tsx apps/admin/src/lib/date-range.ts apps/admin/src/lib/date-range.test.ts apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx apps/admin/src/styles.css
git commit -m "feat(admin): Rapporten screen with filters, totals, breakdowns + CSV export"
Final verification (after all tasks)
yarn workspace @solelog/api test— all green (incl.csv,report, regressionexport).yarn workspace @solelog/admin test— all green (incl.reports,date-range,Reports).yarn workspace @solelog/api typecheck&&yarn workspace @solelog/admin typecheck— clean.yarn workspace @solelog/admin build— succeeds.npx oxlint— clean.git log --onelineshows five task commits.
Self-review notes (plan vs spec)
- Spec A (report endpoint): Task 2. Filtered query helper, JS aggregation, sort by worked desc, empty→zeros, null type bucketed. ✓
- Spec A (export endpoint): Task 3. All-users, Worker column, range filename, filter-respecting. ✓
- Spec A (DRY refactor): Task 1.
buildSessionsCsvwithincludeWorker; regression guard via existingexport.test.ts. ✓ - Spec B (contracts): Task 2 Step 1.
ReportResponse+ row types. ✓ - Spec C (UI): Task 5. Sidebar move,
/rapportenroute, filter bar + presets, headline totals, three tables, bearer download. ✓ Task 4 suppliesuseReport/downloadExport/filtersToQuery. ✓ - Metrics (all four): worked/paused/pairs/sessions in totals + every breakdown row + table columns. ✓
- Completed-only / admin-gated / date semantics: Global Constraints; enforced in
buildSessionFilters+parseReportQuery(server) anddate-range.ts(client). ✓ - Testing: API aggregation/boundary/filter/gating + export; admin client + screen + date helpers. ✓
- No new deps; tables-only: confirmed — only
URLSearchParams, Blob, existing helpers. ✓