diff --git a/docs/superpowers/specs/2026-06-24-phase-3b2-reports-export-design.md b/docs/superpowers/specs/2026-06-24-phase-3b2-reports-export-design.md new file mode 100644 index 0000000..7b4cb2d --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-phase-3b2-reports-export-design.md @@ -0,0 +1,173 @@ +# Phase 3b·2 — Reports + All-Users Export — Design + +- **Created:** 2026-06-24 +- **Status:** Approved (brainstorming) — ready for implementation plan +- **Tracker:** Plane (workspace `solelog`, project SoleLog) +- **Cycle:** Second of three Phase 3b cycles (after manual-sessions 3b·1, before user management) +- **Touches:** `packages/shared`, `apps/api`, `apps/admin` + +## Goal + +An admin can open a **Rapporten** screen, pick a period (and optionally narrow by worker / +insole type / activity), and see headline production totals plus three breakdowns — **per +medewerker**, **per handeling**, **per type** — and export the underlying detail rows (all +workers) to CSV. Today's `/api/export` is **self-scoped** to the logged-in user; this cycle adds +the cross-user, filterable reporting + export the admin needs to review a week. + +_Done when:_ an admin can choose a date range (with Deze week / Deze maand / Alles presets) and +optional worker/type/activity filters, see correct headline totals and three breakdown tables, and +download a CSV of every matching completed session (all workers, with a Worker column) that +respects the same filters. + +## Scope decisions (confirmed during brainstorming, 2026-06-24) + +1. **Both lenses on one screen** — a period summary with breakdowns **per worker AND per + activity AND per type** (same underlying query, grouped three ways), plus headline totals. +2. **Filters:** date range (from/to, the spine) + optional worker + optional insole type + + optional activity. Default period on open = **this week** (Mon–today); presets Deze week / + Deze maand / Alles. +3. **What counts:** only `status='completed'` sessions contribute to totals and export (active = + still running, no duration; discarded = cancelled). Mirrors the current export. +4. **Metrics (all four):** gewerkte tijd (worked seconds, excl. paused), aantal zolen (sum of + `pair_count`), aantal sessies (count), pauzetijd (paused seconds). Shown both as headline + totals and in every breakdown row. +5. **CSV = detail rows, all workers** — every filtered completed session as a row, like today's + export plus a leading **Worker** column. (Not the aggregated summary — that's visible + on-screen.) +6. **Presentation: tables only** — headline totals card + three breakdown tables, numbers only. + No chart library, no CSS bars — keeps the dependency-light ethos and is fastest to ship. + +## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`) + +Both new endpoints accept the same query params and share one filtered-query helper so report and +export can never drift: + +- `from` — ISO instant, inclusive lower bound on `start_time`. +- `to` — ISO instant, inclusive upper bound on `start_time`. +- `user_id?` — restrict to one worker. +- `insole_type?` — one of `Kurk | Berk | 3D`. +- `activity_id?` — restrict to one handeling. + +All queries additionally force `status='completed'`. A shared helper +`buildSessionFilters({ from, to, user_id, insole_type, activity_id })` returns the Drizzle `where` +condition array (completed + range + any provided optional filters), used by both endpoints. + +### `GET /api/admin/report` +Fetches the filtered rows once (joined to `activities` + `user` for names) and aggregates **in JS** +in a single pass — small data, gives names for free, one code path. Returns `ReportResponse`: + +``` +range: { from, to } // echoes the requested ISO instants +totals: { worked_seconds, paused_seconds, pairs, sessions } +by_worker: [{ user_id, user_name, worked_seconds, paused_seconds, pairs, sessions }] +by_activity: [{ activity_id, activity_name, worked_seconds, paused_seconds, pairs, sessions }] +by_type: [{ insole_type, worked_seconds, paused_seconds, pairs, sessions }] +``` + +- `worked_seconds` sums `duration_seconds` (already excludes paused); `paused_seconds` sums + `paused_seconds`; `pairs` sums `pair_count`; `sessions` counts rows. +- Headline `totals` equals the sum across any one breakdown (invariant worth a test). +- Breakdown arrays are sorted by `worked_seconds` descending. Empty range → all-zero `totals` and + empty breakdown arrays. +- A row whose `insole_type` is null is bucketed under a `'Onbekend'`-style key in `by_type` + (defensive — manual edits allow null type). A row missing an activity/user name falls back to a + readable label, never crashes the grouping. + +### `GET /api/admin/export` +Same filters; returns CSV detail rows (all workers). Columns: +**Worker**, ID, Task, Insole Type, No. of Insoles, Date, Total Duration, Paused Duration, Start +Time, End Time. `Content-Type: text/csv; charset=utf-8`; `Content-Disposition: attachment; +filename="solelog-report__.csv"` (dates as `YYYY-MM-DD`). Ordered by +`start_time` ascending. + +### DRY refactor (light, in `lib/csv.ts`) +Extract the row/header builder currently inline in `sessions.ts`'s `/api/export` into a shared +`buildSessionsCsv(rows, { includeWorker })`: +- `includeWorker: false` → existing 9-column format, used by the self-scoped worker export + (`/api/export`) — output byte-identical to today, so existing tests still pass. +- `includeWorker: true` → prepends a `Worker` column, used by `/api/admin/export`. + +Reuses the existing `quote` + `formatDuration` helpers. One source of truth for the CSV format. + +### Validation / errors +`from`/`to` required and must parse as dates with `to ≥ from` → else 400. `insole_type` (if given) +must be a valid `InsoleType`; `activity_id`/`user_id` (if given) are applied as filters (an +unknown id simply yields an empty result, not an error). Admin guard already returns 401 +(no session) / 403 (non-admin) for the whole `/api/admin/*` surface. + +## B. Shared contracts (`@solelog/shared`) + +Add zod schemas + inferred types: +- `ReportTotals` — `{ worked_seconds, paused_seconds, pairs, sessions }` (all int). +- `ReportWorkerRow`, `ReportActivityRow`, `ReportTypeRow` — `ReportTotals` plus the grouping key(s) + (`user_id`+`user_name`; `activity_id`+`activity_name`; `insole_type`). +- `ReportResponse` — `{ range: { from, to }, totals, by_worker, by_activity, by_type }`. + +Query params are validated in the route (not a shared schema). The **client** is responsible for +sending `from`/`to` as ISO instants spanning whole local days (start-of-from-day … +end-of-to-day in the admin's browser tz), so server-side timezone handling is unnecessary. + +## C. Admin UI (`apps/admin`) + +- **`components/Sidebar.tsx`** — move `'Rapporten'` from `soonItems` into `navItems` + (`{ to: '/rapporten', label: 'Rapporten' }`); `soonItems` becomes `['Gebruikers']`. +- **`App.tsx`** — add `} />`. +- **`screens/Reports.tsx`** — the screen: + - **Filter bar:** from/to `date` inputs; preset buttons **Deze week** (default on mount) / + **Deze maand** / **Alles**; worker `` + (Kurk/Berk/3D); activity `