# 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} Gewerkt Zolen Sessies Pauze
{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. ✓