diff --git a/docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md b/docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md new file mode 100644 index 0000000..a423dce --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md @@ -0,0 +1,1396 @@ +# 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 of `duration_seconds`, already excludes paused), `paused_seconds`, `pairs` (sum of `pair_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`/`to` as ISO instants spanning whole local days; server compares them against `start_time` inclusively. No server-side timezone logic. +- **Dependency-light:** no new runtime dependencies (no chart library). Reuse `lib/csv` (`quote`, `formatDuration`) and admin `lib/elapsed` (`formatTime`). +- **CSV byte-compatibility:** the refactor must keep `/api/export` output byte-identical (existing `apps/api/test/export.test.ts` is the regression guard). +- **Code style:** oxfmt — 2-space, single quotes, semicolons, width 100, trailing-comma `all`. Run `npx oxfmt ` 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` (add `SessionCsvRow` + `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 }): string` and the `SessionCsvRow` interface — consumed by Task 3. + +- [ ] **Step 1: Write the failing unit test** + +Create `apps/api/test/csv.test.ts`: + +```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`: + +```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`: + +```ts + 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** + +```bash +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` (add `parseReportQuery`, `buildSessionFilters`, the report route, imports) +- Test: `apps/api/test/report.test.ts` (new) + +**Interfaces:** +- Consumes: `baseSelect`, `workSessions`, `activities`, `user` (already in `admin.ts`). +- Produces: + - shared `ReportResponse` (+ `ReportTotals`, `ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow`) — consumed by Tasks 4-5. + - `parseReportQuery(c): ReportQuery | null` and `buildSessionFilters(q: ReportQuery): SQL[]` — consumed by Task 3. + - `type ReportQuery = { from: Date; to: Date; userId?: string; insoleType?: string; activityId?: number }`. + +- [ ] **Step 1: Add the shared contracts** + +Append to `packages/shared/src/index.ts`: + +```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; + +export const ReportWorkerRow = ReportTotals.extend({ + user_id: z.string(), + user_name: z.string(), +}); +export type ReportWorkerRow = z.infer; + +export const ReportActivityRow = ReportTotals.extend({ + activity_id: z.number().int(), + activity_name: z.string(), +}); +export type ReportActivityRow = z.infer; + +export const ReportTypeRow = ReportTotals.extend({ + insole_type: z.string(), +}); +export type ReportTypeRow = z.infer; + +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; +``` + +- [ ] **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. + +```ts +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 { + 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: + +```ts +import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm'; +``` + +Add the shared `InsoleType` import: + +```ts +import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared'; +``` + +Add (near the top, after `computeDuration`): + +```ts +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): + +```ts +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** + +```bash +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 + `buildSessionsCsv` import) +- Test: `apps/api/test/report.test.ts` (add an `admin export` describe 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`: + +```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: + +```ts +import { buildSessionsCsv } from '../lib/csv'; +``` + +Add the route after the report route: + +```ts +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** + +```bash +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_URL` from `../lib/api`; `getToken` from `../lib/auth-storage`; `ReportResponse` from `@solelog/shared`. +- Produces: + - `interface ReportFilters { from: string; to: string; userId?: string; insoleType?: string; activityId?: number }` + - `filtersToQuery(f: ReportFilters): string` + - `useReport(filters: ReportFilters)` (React Query) + - `downloadExport(filters: ReportFilters): Promise` — consumed by Task 5. + +- [ ] **Step 1: Write the failing test** + +Create `apps/admin/src/api/reports.test.ts`: + +```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).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`: + +```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(`/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 can't carry the token: +// fetch with the Authorization header, then download the resulting Blob. +export async function downloadExport(filters: ReportFilters): Promise { + 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** + +```bash +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 `/rapporten` route) +- 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: `localDayRange` helpers (see below) and the `` screen. + +- [ ] **Step 1: Write the failing date-range helper test** + +Create `apps/admin/src/lib/date-range.test.ts`: + +```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`: + +```ts +// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what 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`: + +```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( + + + , + ); +} + +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. + +```tsx +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(() => 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(null); + + async function onExport() { + setExportError(null); + try { + await reportsApi.downloadExport(filters); + } catch { + setExportError('Export mislukt. Probeer opnieuw.'); + } + } + + const t = reportQuery.data?.totals; + + return ( +
+
+

Rapporten

+ +
+ +
+
+ + + +
+ + + + + +
+ + {exportError &&

{exportError}

} + + {reportQuery.isLoading ? ( +

Laden…

+ ) : reportQuery.isError ? ( +

Kon rapport niet laden.

+ ) : ( + <> +
+ {formatTime(t?.worked_seconds ?? 0)} gewerkt ·{' '} + {t?.pairs ?? 0} zolen · {t?.sessions ?? 0} sessies ·{' '} + {formatTime(t?.paused_seconds ?? 0)} pauze +
+ + ({ key: r.user_id, name: r.user_name, ...r }))} + /> + ({ key: String(r.activity_id), name: r.activity_name, ...r }))} + /> + ({ key: r.insole_type, name: r.insole_type, ...r }))} + /> + + )} +
+ ); +} + +type BreakdownRow = ReportTotals & { key: string; name: string }; + +function BreakdownTable({ title, label, rows }: { title: string; label: string; rows: BreakdownRow[] }) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +

Geen gegevens.

+ ) : ( + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
{label}GewerktZolenSessiesPauze
{r.name}{formatTime(r.worked_seconds)}{r.pairs}{r.sessions}{formatTime(r.paused_seconds)}
+ )} +
+ ); +} +``` + +- [ ] **Step 8: Wire nav + route** + +In `apps/admin/src/components/Sidebar.tsx`, move Rapporten into `navItems` and shrink `soonItems`: + +```tsx +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: + +```tsx +import Reports from './screens/Reports'; +``` + +```tsx + } /> + } /> +``` + +- [ ] **Step 9: Add minimal styles** + +Append to `apps/admin/src/styles.css`: + +```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** + +```bash +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`, regression `export`). +- [ ] `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 --oneline` shows 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. `buildSessionsCsv` with `includeWorker`; regression guard via existing `export.test.ts`. ✓ +- **Spec B (contracts):** Task 2 Step 1. `ReportResponse` + row types. ✓ +- **Spec C (UI):** Task 5. Sidebar move, `/rapporten` route, filter bar + presets, headline totals, three tables, bearer download. ✓ Task 4 supplies `useReport`/`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) and `date-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. ✓