docs(spec): phase 3b.2 reports + all-users export design

This commit is contained in:
Bas van Rossem
2026-06-24 15:46:24 +02:00
parent 84677080fc
commit 3eccdef313

View File

@@ -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** (Montoday); 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_<from-date>_<to-date>.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 `<Route path="/rapporten" element={<Reports />} />`.
- **`screens/Reports.tsx`** — the screen:
- **Filter bar:** from/to `date` inputs; preset buttons **Deze week** (default on mount) /
**Deze maand** / **Alles**; worker `<select>` (from `useAdminUsers`); insole-type `<select>`
(Kurk/Berk/3D); activity `<select>` (from the admin activities hook). Changing any control
updates the filter state → query refetches.
- **Headline card:** "Totaal: {worked} gewerkt · {pairs} zolen · {sessions} sessies ·
{paused} pauze" for the chosen period.
- **Three tables:** Per medewerker / Per handeling / Per type — each row shows the grouping
label + the four metrics (durations via a shared `formatTime`). Empty state per table.
- **Exporteer CSV** button → `downloadExport(filters)`.
- **`api/reports.ts`**:
- `useReport(filters)``useQuery` keyed `['admin','report', filters]`, calls
`GET /api/admin/report?…` via `apiFetch`.
- `downloadExport(filters)` — because the endpoint is bearer-auth'd, do a raw `fetch` with the
`Authorization` header (token from the same place `apiFetch` reads it), read the `Blob`,
create an object URL, click a transient `<a download>`, then revoke the URL. (A plain
`<a href>` can't attach the bearer token.)
- A small `filtersToQuery(filters)` builds the querystring (omits empty optional filters; maps
the local-day date pickers to ISO instants).
- Reuses `useAdminUsers` (built in 3b·1) and the existing admin activities query hook for the
dropdowns. No new shared UI library.
## Error handling
- Report query error → the screen shows "Kon rapport niet laden." (mirrors other admin screens).
- Export failure (non-2xx) → a brief inline notice near the button; no download is triggered.
- Invalid/missing range is prevented client-side (presets always set a valid range; manual inputs
are clamped so `to ≥ from`), and the API still rejects a bad range with 400 as a backstop.
## Testing
- **API** (`admin.test.ts` / a new `report.test.ts`):
- report totals correct and equal to the sum of each breakdown; completed-only (active/discarded
excluded); date-range boundary (a session exactly at `from`/`to` included; just outside
excluded); worker / type / activity filters each narrow correctly; empty range → zeros +
empty arrays; null `insole_type` bucketed, not crashing.
- export returns all-users rows with a leading Worker column; respects the same filters; filename
carries the range; both endpoints 401 (no session) / 403 (non-admin).
- `buildSessionsCsv`: `includeWorker:false` output identical to the pre-refactor worker export
(regression guard); `includeWorker:true` prepends the Worker column.
- **Admin** (vitest + Testing Library):
- Reports renders headline totals + the three tables from a mocked report payload.
- changing a filter / clicking a preset refetches with the expected query params.
- the Exporteer CSV button calls the download helper with the current filters (fetch mocked).
## Out of scope (later cycle / deliberately excluded)
- The **Gebruikers** screen and any user create / role / deactivate (the third 3b cycle).
- Charts / visualization (tables-only chosen).
- Real-time/auto-refresh of the report (it's on-demand per filter; React Query refetch-on-focus is
enough).
- Per-day time-series breakdown — the three chosen breakdowns (worker/activity/type) cover the
MVP; a by-day table can be added later if wanted.
## Build approach
spec → `writing-plans` → one **Workflow** (~67 TDD tasks, commit per task, final verify),
sequential (dependent tasks share one working tree). Tracked as a Plane epic with one child task
per workflow task.