Files
solelog/docs/reference/domain-test-checklist.md
Bas van Rossem 75c5a9f350 test(admin): Activities + app/nav — fill gaps + lifecycle test
Fill coverage gaps and lock in behavior for the Activities screen and
app/nav (Sidebar) domain (plan task B-D6). All four domain src files
(screens/Activities.tsx, api/activities.ts, App.tsx, components/Sidebar.tsx)
now at 100% stmts/branch/funcs/lines.

- Activities.test.tsx: insole-type toggle select/deselect (both branches),
  reduced-subset create, add disabled when no types, edit insole types,
  cancel-without-save, empty-types-defaults-all editor, empty state,
  error-falls-back-to-empty, and the create->edit types->reorder->delete
  lifecycle test.
- Sidebar.test.tsx (new): brand + nav links + email, active-route highlight,
  end-match keeps Live inactive off-root, nav click switches screen + moves
  highlight, Uitloggen calls signOut, email hidden without useMe data.
- Activities.tsx: three /* c8 ignore next */ annotations on the defensive
  early-return guards in handleAdd/handleSave/handleMove (each mirrored by a
  disabled button, so unreachable via the UI). Comment-only, no behavior change.

No bugs found — the domain code was already correct; the tests lock it in.
2026-07-23 15:18:59 +02:00

37 KiB
Raw Blame History

Domain test checklist — guaranteed behaviors

Living definition-of-done. Each domain lists the behaviors its tests guarantee. When you change a domain, its box must still hold (or you update this list on purpose). Baseline established 2026-07-22 (see plan 2026-07-22-per-domain-test-safety-net). Tracked as Plane epic SL-70.

Coverage baseline (Phase A, 2026-07-22)

Workspace % Stmts % Branch % Funcs % Lines
api 96.94 74.29 100 96.94
worker 89.62 87.42 70.58 89.62
admin 92.43 84.91 78.75 92.43

Target after the sweep: near-100% per domain; remaining uncovered lines annotated /* c8 ignore next -- <reason> */, not left silent.

Infra (health/db/schema/seed/env/migrate)

Src covered: src/routes/health.ts, src/db/client.ts, src/db/ensure-dir.ts, src/db/migrate.ts, src/db/seed.ts, src/env.ts — all at 100% stmts/branch/funcs/lines. (src/db/schema.ts is generated and excluded from coverage by config.)

Guaranteed behaviors:

  • GET /health returns { status: 'ok' }.
  • env.ts applies every dev default (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, PORT, WEB_ORIGINS) when the vars are unset, and reads each override when set (PORT coerced to a number).
  • env.WEB_ORIGINS parses CORS_ORIGINS into a list, trimming whitespace and dropping empty entries; falls back to the default localhost origins when CORS_ORIGINS is empty/unset.
  • runMigrations() is idempotent — re-running it against an already-migrated DB does not throw.
  • ensureDbDir() is a no-op for non-file: URLs and for a bare filename in the current dir.
  • seed() seeds the reference activities idempotently and seeds the dev worker + dev admin with the correct roles, idempotently.
  • seed() never creates the dev users when NODE_ENV=production (reference activities still seeded).
  • The migrate.ts / seed.ts CLI direct-run blocks are c8 ignored — unreachable under the test runner (exercised only via tsx src/db/{migrate,seed}.ts).

Auth & /me

Src covered: src/auth.ts, src/routes/me.ts, src/lib/require-user.ts, and the CORS middleware in src/app.ts — all at 100% stmts/branch/funcs/lines.

Guaranteed behaviors:

  • Sign-in with valid credentials returns a bearer token (set-auth-token header); sign-in with a wrong password fails (status ≥ 400).
  • Public sign-up is rejected (disableSignUp — admin creates users).
  • GET /api/me returns 401 without a token, and { user: { id, email, name, role } } with a valid bearer; role reflects the user (worker / admin).
  • GET /api/me falls back to role 'worker' when the session user has no role set.
  • getSessionUser maps id, role, and banned from the session; defaults role to 'worker' and banned to false when unset; returns null when there is no session.
  • isAdmin is true only for the admin role (false for worker and for null).
  • adminGuard returns 401 without a session, 403 for a non-admin, and 403 for a banned admin that still holds a valid token; it passes through for an active admin. (Exercised through the /api/admin/* surface in admin-users.test.ts.)
  • CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the access-control-allow-origin header, OPTIONS preflight is answered with the allowed methods, and set-auth-token is exposed so the SPA can read the bearer on sign-in.

Activities

Src covered: src/routes/activities.ts — 100% stmts/branch/funcs/lines. The one unreachable branch (row.sortOrder ?? 0 in toActivity; sort_order is NOT NULL DEFAULT 0) is c8 ignored with a reason, not left silent.

Guaranteed behaviors:

  • GET /api/activities requires a session (401 without a token); any authenticated user — worker or admin — may read.
  • The list is ordered by sort_order then name, and ?insole_type=<type> filters to activities whose insole_types include that type.
  • POST/PUT/DELETE/PUT /reorder are admin-only: 401 without a token, 403 for a worker.
  • Create validates a non-empty (trimmed) name (400 otherwise) and defaults insole_types to all three (Kurk, Berk, 3D) when omitted.
  • Create appends the new activity with a sort_order one higher than the current maximum.
  • insole_types round-trips through create/list as the exact stored subset.
  • Update (PUT /:id) 404s for a non-numeric or unknown id, 400s on an invalid body, and returns the updated activity otherwise.
  • Delete (DELETE /:id) 404s for a non-numeric id, removes the activity and its work-session rows (no FK cascade), and returns { success: true }.
  • Reorder (PUT /reorder) sets sort_order from the ids array position; it 400s on an empty array (zod min(1)) and when the ids are not exactly the full known set (wrong count or an unknown id).
  • Lifecycle: an admin creates three activities → reorders them → a worker's GET reflects the new order → the admin deletes one → it is gone from the list.

Work-sessions

Src covered: src/routes/sessions.ts and src/lib/work-session.ts — both at 100% stmts/branch/funcs/lines. The two row.pausedSeconds ?? 0 guards in sessions.ts (resume/stop) are c8 ignored with a reason — paused_seconds is NOT NULL DEFAULT 0, so the null branch is unreachable via any route.

Guaranteed behaviors:

  • start, pause, resume, stop, discard all require a session (401 without a token), as do the reads GET /api/sessions and GET /api/sessions/active.
  • POST /api/sessions/start: 200 creates an active, source: 'app' session (null end_time / duration_seconds); 400 on an invalid body and on a malformed-JSON body; 404 when the activity_id does not exist.
  • POST /:id/pause: 200 sets paused_at on a running session; 404 for a non-numeric or unknown id; 409 on a double pause (already paused) and 409 when the session is no longer active.
  • POST /:id/resume: 200 clears paused_at and accumulates paused_seconds; 404 for a non-numeric or unknown id; 409 when the session is running (not paused) and 409 when it is no longer active.
  • POST /:id/stop: 200 completes the session, folds an open pause span into paused_seconds, and computes duration_seconds = wall-clock − paused_seconds (never negative); a never-paused stop keeps paused_seconds = 0 and duration = wall-clock; 404 for a non-numeric/unknown id; 409 when already closed.
  • POST /:id/discard: 200 marks the session discarded with a null duration_seconds; 404 for a non-numeric/unknown id; 409 when already closed.
  • Ownership scoping: user B can neither stop nor discard user A's session — both return 404 and leave A's session untouched (still active).
  • Reads: GET /api/sessions returns the caller's sessions only, newest-first, joined to the activity name; GET /api/sessions/active returns only that user's active sessions.
  • toWorkSession maps every field to the API contract (incl. paused_at, paused_seconds, notes, created_at), threads activity_name / user_name / user_email opts through (undefined when omitted), maps a null insoleType / durationSeconds / notes to null, and coalesces a nullish paused_seconds to 0.
  • Lifecycle: start → pause → (backdate) resume accumulates paused time → (backdate) stop excludes paused time from worked duration → the completed session appears in history with its activity name → GET /api/export returns a CSV with exactly the header + one data row.

Worker export/CSV

Src covered: src/lib/csv.ts — 100% stmts/branch/funcs/lines; the /api/export route in src/routes/sessions.ts — 100%. No c8 ignores needed (every fallback branch is exercised).

Guaranteed behaviors:

  • buildSessionsCsv(rows) (worker self-export, includeWorker defaults to false) emits the exact legacy header ID,Task,Insole Type,No. of Insoles,Date,Total Duration,Paused Duration,Start Time,End Time (each cell quoted) with no Worker column.
  • buildSessionsCsv(rows, { includeWorker: true }) prepends a Worker column carrying userName.
  • Every field is quoted; quote escapes embedded double quotes (doubled) and keeps commas and newlines inside the quoted cell — a comma/quote/newline in an activity name never breaks the column or row structure.
  • Legacy fallbacks: a null activityName/userName renders '', null insoleType renders Kurk, null pairCount renders 2, null durationSeconds/pausedSeconds render 00:00:00, and a null endTime renders an empty End Time cell.
  • formatDuration renders zero-padded HH:MM:SS, handles zero (00:00:00), and lets hours exceed 99 (360000 → 100:00:00).
  • GET /api/export: 401 without a token; includes only the caller's completed sessions (active/discarded and other users' rows excluded); rows ordered by start time ascending; sets the text/csv content-type and the attachment; filename="insole-production-report.csv" disposition header.
  • Lifecycle: covered by the worker-session anchor (test/integration/worker-session-lifecycle.test.ts), whose final step asserts GET /api/export returns the header + exactly one completed-session row.

Admin sessions

Src covered: the session parts of src/routes/admin.ts (cross-user list, active list, manual create/edit, admin stop/discard) — all session-part statements, lines, and branches covered, so admin.ts is at 100% stmts/lines. The one row.pausedSeconds ?? 0 guard in the admin stop route is c8 ignored with a reason — paused_seconds is NOT NULL DEFAULT 0, so the null branch is DB-impossible. (The report/export parts of admin.ts — parseReportQuery, the aggregation ?? fallbacks, and the export invalid-query branch — are owned and covered by the Reports domain.)

Guaranteed behaviors:

  • Every /api/admin/* session route is gated: 401 without a token, 403 for a worker.
  • GET /api/admin/sessions returns all users' sessions newest-first, each joined to activity_name, user_name, and user_email; GET /api/admin/sessions/active is the same but filtered to active sessions only.
  • POST /api/admin/sessions (manual create): creates a completed, source: 'manual' session with duration_seconds = span − paused_seconds and null paused_at; 400 on a schema-invalid body, on end_time < start_time, and when paused_seconds exceeds the worked span; 404 for an unknown user_id and for an unknown activity_id.
  • PUT /api/admin/sessions/:id (edit): recomputes duration_seconds when end_time is present and sets it null when end_time is null (re-activating); 404 for a non-numeric or unknown id; 400 on a schema-invalid body, on an unparseable start_time, on end_time < start_time, and when paused_seconds exceeds the span; 404 for an unknown activity_id.
  • POST /api/admin/sessions/:id/stop: completes another user's active session, folds an open pause span into paused_seconds, clears paused_at, and computes duration_seconds excluding paused time; 404 for a non-numeric or unknown id; 409 when the session is already closed.
  • POST /api/admin/sessions/:id/discard: marks another user's active session discarded with an end_time; 404 for a non-numeric or unknown id; 409 when the session is already closed.
  • Lifecycle (test/integration/admin-worker-session.test.ts): a worker starts a session → an admin stops it via the admin endpoint → the completed session appears in GET /api/admin/report (totals, by_worker, by_activity, by_type all matching the stored row) and in GET /api/admin/export (header + exactly one row, with the leading Worker column).

Reports + admin export

Src covered: the report/export parts of src/routes/admin.ts (parseReportQuery, buildSessionFilters, GET /api/admin/report, GET /api/admin/export) — admin.ts is at 100% stmts/branch/funcs/lines. Three defensive aggregation ?? guards are c8 ignored with reasons rather than left silent: pausedSeconds/pairCount are NOT NULL (DB-impossible nulls), and the user_name/activity_name fallbacks are unreachable because the user_id FK cascades on delete and the activity_id FK is enforced (every session always joins to a real user and activity).

Guaranteed behaviors:

  • Both GET /api/admin/report and GET /api/admin/export are gated: 401 without a token, 403 for a worker.
  • Query parsing (parseReportQuery, shared by report + export): 400 when from/to is missing, unparseable, or inverted (to < from); 400 on an insole_type outside Kurk/Berk/3D; 400 on a non-numeric activity_id; the export route rejects the same bad queries with 400.
  • Report scope: only completed sessions inside [from, to] (by start_time) are counted — active/discarded rows are excluded.
  • Filters narrow the result set: user_id, insole_type, and activity_id each restrict the counted sessions (individually and combined).
  • Totals sum worked_seconds, paused_seconds, pairs, and sessions across every matched row, and equal the sum of each breakdown.
  • Breakdowns by_worker, by_activity, and by_type group correctly and are each sorted by worked_seconds descending.
  • A completed session with a null insole_type is bucketed under "Onbekend" in by_type (worker and activity buckets stay real); a completed session with a null duration_seconds contributes 0 worked seconds.
  • An empty range yields zero totals and empty by_worker/by_activity/by_type arrays.
  • Admin export: emits an all-users CSV with the leading Worker column; the text/csv content-type and a Content-Disposition filename of solelog-report_<from>_<to>.csv (dates sliced to YYYY-MM-DD); rows ordered by start time ascending; honors the same filters as the report.
  • Lifecycle (test/integration/report-aggregation.test.ts): seed known completed sessions for two workers × two activities × two insole types → assert exact totals and every by_worker, by_activity, by_type row (values and descending order) → apply a user_id filter → assert the totals and breakdowns narrow to that worker only.

Admin users

Src covered: src/routes/admin-users.ts — 100% stmts/branch/funcs/lines. The two last-admin guards (in the role and deactivate routes) are c8 ignored with reasons: they are unreachable over HTTP because the self-demote / self-deactivate guards above already handle caller === target, so any caller reaching them is a distinct active admin and activeAdminsExcluding(target) >= 1; the guard logic itself is unit-tested through activeAdminsExcluding.

Guaranteed behaviors:

  • Every /api/admin/users* route is gated: 401 without a token, 403 for a worker, and 403 for a banned admin that still holds a valid token (adminGuard defense-in-depth).
  • GET /api/admin/users returns every user ordered by name, each mapped by toListItem to { id, email, name, role, status, created_at }; a null DB role falls back to worker and a banned row surfaces as status: 'inactive'.
  • POST /api/admin/users creates a user who can then sign in; 409 on a duplicate email; 400 on a schema-invalid body (incl. a password shorter than 8 chars); a mixed-case email is stored lowercased and the created row is returned (no 500 on the re-select).
  • POST /:id/role promotes/demotes and returns the updated row; 400 on an invalid role body; 404 on an unknown id; refuses self-demotion (400); allows demoting a peer admin while the caller stays admin (200).
  • activeAdminsExcluding (unit) counts only active (non-banned) admins and honours the excluded id.
  • POST /:id/deactivate bans the user, clears any live session rows, and returns status: 'inactive'; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a peer admin while always blocking self.
  • POST /:id/reactivate clears the ban and returns status: 'active'; 404 on an unknown id.
  • POST /:id/password sets a new password (new works, old fails); 400 on a too-short password; 404 on an unknown id; surfaces a 400 (not a 500) when better-auth's setUserPassword throws.
  • Lifecycle (test/integration/user-lifecycle.test.ts): admin creates a worker → worker signs in and the bearer authorizes /api/me → admin deactivates → the session rows are deleted so the pre-issued token no longer authorizes /api/me (401) and a fresh sign-in is blocked → admin reactivates → the ban is cleared and sign-in works again.

Auth (context/storage/login) — worker client

Src covered (worker apps/worker): src/auth/AuthContext.tsx, src/lib/auth-storage.ts, and src/screens/Login.tsx — all at 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • auth-storage: setToken persists the bearer, getToken reads it back, clearToken removes it (getToken returns null when nothing is stored) — all via localStorage.
  • AuthContext starts unauthenticated when no token is stored and restores the authenticated state on mount when a token is already present (getToken() !== null).
  • AuthContext.signIn delegates to the api signIn(email, password) and, on success, flips the context to authenticated (isAuthed → true).
  • AuthContext.signOut resets the router path to / (so the next authed mount lands on Stopwatch, not Account), clears the stored token, and flips the context to unauthenticated.
  • useAuth throws "useAuth must be used within an AuthProvider" when called outside the provider.
  • Login renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials to POST /api/auth/sign-in/email, storing the returned bearer token.
  • Login shows the "Inloggen mislukt" error (and stores no token) when sign-in fails, and disables the submit button while the request is pending, re-enabling it once it settles.
  • Lifecycle (src/screens/Login.test.tsx): from the unauthenticated app, a successful login stores the token and flips the context so the tabbed authenticated shell (Stopwatch + TabBar) renders.

Note: the design checklist mentions "clears on 401" for the worker auth context — this is not implemented in the current code (there is no global 401 interceptor; apiFetch throws ApiError and callers surface it via React Query). Left as-is (missing feature, not a defect); not tested and not filed as a bug.

History — worker client

Src covered (worker apps/worker): src/screens/History.tsx and src/lib/export.ts — both at 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • History renders the Dutch header (Geschiedenis) and the Exporteer CSV button.
  • Sessions render in the order the API returns them (GET /api/sessions is newest-first), one .session-card per session, showing the activity name and its formatted duration.
  • formatDuration renders the legacy format: Hh Mm when hours are present (3661 → 1h 1m), Mm Ss when only minutes (125 → 2m 5s), and just seconds under a minute (45 → 45s); a null duration_seconds renders 0s.
  • A card falls back to "Onbekende handeling" when activity_name is absent, omits the insole-type pill when insole_type is null, and uses the singular noun (inlegzool) only for a pair count of 1 (inlegzolen otherwise).
  • A Pauze <duration> line shows only when paused_seconds > 0 (absent when it is 0).
  • The empty state (Nog geen opgeslagen sessies.) shows only once loading has finished and there are no sessions; while the query is still loading, neither the empty state nor any cards render.
  • Clicking Exporteer CSV calls downloadExport; when it rejects, the card surfaces the Kan de export niet openen error message instead of throwing.
  • downloadExport (src/lib/export.ts): fetches GET /api/export with the bearer token when one is stored (no Authorization header when there is none), then triggers a client-side download via an object URL (createObjectURL → anchor click → revokeObjectURL); it throws "Kan de export niet openen" when the response is not ok.
  • Lifecycle (src/screens/History.test.tsx): the list renders a completed session showing both its worked duration and its Pauze line, then the export action invokes the download path.

Account — worker client

Src covered (worker apps/worker): src/screens/Account.tsx — 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • Account renders the Dutch header (Account) and, once GET /api/me resolves, shows the signed-in user's name and email from useMe().
  • The fallback message Kon accountgegevens niet laden. shows when the query resolves without a user (and the name/email are then absent).
  • Clicking Uitloggen calls signOut, which clears the stored bearer token.
  • Lifecycle (src/screens/Account.test.tsx): from the authenticated app on the Account screen, clicking Uitloggen flips the auth context to unauthenticated so the login form (E-mailadres) renders and the Uitloggen button is gone (token cleared).

lib/api + app/nav — worker client

Src covered (worker apps/worker): src/lib/api.ts, src/App.tsx, src/components/TabBar.tsx, src/api/sessions.ts, src/api/activities.ts, src/api/me.ts — all 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • apiFetch attaches Authorization: Bearer <token> when a token is stored and omits it otherwise.
  • apiFetch sets Content-Type: application/json when a request has a body and no explicit Content-Type, and does not override a Content-Type the caller supplied.
  • apiFetch parses a JSON response body, and returns undefined when the body is empty.
  • apiFetch throws ApiError (carrying the HTTP status) on any non-2xx response.
  • signIn POSTs to /api/auth/sign-in/email, stores the token from the set-auth-token header, and throws ApiError when the response is not ok or the header is missing.
  • App gates on auth state: no token renders Login (no tab bar); a token renders the authed shell with the Stopwatch screen mounted at / beside the TabBar.
  • TabBar renders the three tabs (Stopwatch → /, Geschiedenis → /history, Account → /account) as links, marks the current route's tab tab-active, and uses end matching so the Stopwatch tab is active only on the root path.
  • Each api/* hook hits the right path/method: useSessions → GET /api/sessions; useActiveSessions → GET /api/sessions/active; useStartSession → POST /api/sessions/start with the serialized input; useStop/Discard/Pause/ResumeSession → POST /api/sessions/:id/<action>; useActivities → GET /api/activities; useMe → GET /api/me. Every mutation invalidates the ['sessions'] query cache on success.
  • Lifecycle (src/App.test.tsx): unauthenticated renders Login with no navigation; authenticated renders the Stopwatch screen under the tab bar and no login form.

Auth (admin-only gate) — admin client

Src covered (admin apps/admin): src/auth/AuthContext.tsx and src/screens/Login.tsx — both at 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • AuthContext starts unauthenticated when no token is stored and restores the authenticated state on mount when a token is already present (getToken() !== null).
  • AuthContext.signIn delegates to the api signIn(email, password), then fetches /api/me: admin-only gate — an admin role flips the context to authenticated; a non-admin role clears the stored token and throws NotAdminError ("not-admin"), leaving the context unauthenticated.
  • AuthContext.signOut resets the router path to / (so the next authed mount lands on Live, not whatever tab the admin logged out from), clears the stored token, and flips to unauthenticated.
  • useAuth throws "useAuth must be used within an AuthProvider" when called outside the provider.
  • Login renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials to signIn.
  • Login distinguishes the two failure modes: a NotAdminError surfaces "Geen toegang — alleen beheerders." while any other failure surfaces "Inloggen mislukt"; a stale error is cleared on the next submit.
  • Login disables the submit button while the sign-in is pending, re-enabling it once it settles.
  • Lifecycle (src/screens/Login.test.tsx): from the unauthenticated app, worker credentials are rejected with the "Geen toegang — alleen beheerders." message and the shell never renders; admin credentials then flip the gate so the authed shell (Sidebar nav) renders.

Live view — admin client

Src covered (admin apps/admin): src/screens/Live.tsx and src/lib/elapsed.ts — both at 100% stmts/branch/funcs/lines. No c8 ignores needed.

Guaranteed behaviors:

  • Live renders one .live-card per active session under a Actief nu (<count>) header, each card showing the worker name, the activity, the pair count, and a running worked-time timer.
  • The per-card worked time is server-authoritative: worked = (base − start_time) − paused_seconds, where base is now while running (a 1s client tick counts it up between the 5s server polls) and the frozen paused_at moment while paused, floored at 0. formatTime (in elapsed.ts) renders it as zero-padded HH:MM:SS and never returns a negative time.
  • A running (not-paused) card's timer keeps counting up on the 1s tick; a paused card shows the Gepauzeerd badge and its worked timer stays frozen at the pause moment across ticks.
  • The Pauze <duration> total line shows only when paused_seconds > 0 (absent when it is 0).
  • Placeholder fallbacks: a missing user_name renders Onbekend, a missing activity_name renders Onbekende handeling, and a null insole_type omits the type pill.
  • Each card exposes Stop and Annuleer buttons that call POST /api/admin/sessions/:id/stop and .../discard for that session; both buttons are disabled while either mutation is in flight.
  • Loading state renders Laden…; the error state renders Kon gegevens niet laden. (and no header); the empty state renders Niemand is nu aan het werk.; a non-array payload is treated as zero active sessions (defensive Array.isArray fallback).
  • Lifecycle (src/screens/Live.test.tsx): active-sessions data renders a running card and a paused card side by side, with exactly the paused session showing the Gepauzeerd badge and its Pauze 00:01:30 total.

Sessions + SessionForm — admin client

Src covered (admin apps/admin): src/screens/Sessions.tsx and src/components/SessionForm.tsx — both at 100% stmts/branch/funcs/lines. One c8 ignore with a reason: localToIso's Number.isNaN guard is unreachable because a datetime-local input only ever yields a valid datetime string or ''.

Guaranteed behaviors:

  • Sessions renders one .sessions-row per session (worker, activity + insole-type chip, worked time, start date, status badge). Row worked-time is server-authoritative: completed/discarded rows use duration_seconds; an active row counts (base − start) − paused_seconds where base is the frozen paused_at moment while paused (else now), floored at 0.
  • The Status filter narrows the list to all/active/completed/discarded; the empty message is Nog geen sessies. when there are truly none and Geen sessies in dit filter. when the filter hides every row.
  • The query's error state renders Kon sessies niet laden. (no list); the loading state renders Laden…; a non-array payload is treated as zero sessions.
  • Row placeholders: a missing user_name renders Onbekend (and the edit aria-label uses onbekend), a missing activity_name renders Onbekende handeling, a null insole_type omits the type chip, and an unparseable start_time renders an empty date cell; the Pauze <time> line shows only when paused_seconds > 0.
  • Stop and Annuleer appear only on active rows and call POST /api/admin/sessions/:id/stop and .../discard; the ✎ edit button appears on every row and opens the shared form in edit mode.
  • + Nieuwe registratie opens the shared form in create mode; cancelling (Annuleren) closes it and returns to the list.
  • SessionForm create mode shows the medewerker picker (hidden in edit mode) and POSTs a CreateManualSessionInput; edit mode hides the picker, shows a Status select, prefills from the session, and PUTs an AdminUpdateSessionInput.
  • Create validation: an empty handeling/starttijd shows Vul handeling en starttijd in. and blocks the POST; a filled handeling+starttijd but missing medewerker/zooltype/eindtijd shows Vul medewerker, zooltype, start- en eindtijd in..
  • Serialization: the notitie is trimmed (empty → null); in edit mode an empty zooltype serializes to insole_type: null and status active serializes end_time: null; Aantal paren floors at 1 and Pauze (minuten) floors at 0 on invalid/empty input.
  • The gewerkt preview renders end − start − paused (— when the span is indeterminate); a prefilled unparseable date falls back to an empty field; a 400 from the API surfaces Controleer de ingevoerde gegevens … while any other failure surfaces Opslaan mislukt ….
  • Edit mode without a session is a defensive no-op on submit (no PUT is issued).
  • Lifecycle (src/screens/Sessions.test.tsx): open the create form → fill a valid manual session → submit → it appears in the list; then edit that row (change the handeling) → the change persists in the refreshed list.

Reports + date-range — admin client

Src covered (admin apps/admin): src/screens/Reports.tsx, src/api/reports.ts, and src/lib/date-range.ts — all three at 100% stmts/branch/funcs/lines. No c8 ignore needed.

Guaranteed behaviors:

  • Date-range helpers derive ISO instants in the admin's local timezone: dayStartISO → local 00:00:00.000 of the picked day, dayEndISO → local 23:59:59.999; toDateStr zero-pads month/day.
  • Presets map to the correct YYYY-MM-DD windows: thisWeek = Monday..today, thisMonth = 1st..today, allTime = fixed floor 2000-01-01..today.
  • filtersToQuery always serializes from/to and only emits user_id/insole_type/activity_id when set (empty optionals are omitted).
  • useReport calls GET /api/admin/report?<query>; clicking a preset (Deze week/Deze maand/ Alles), changing the Van/Tot date inputs, or picking a Medewerker/Type/Handeling filter re-issues the report query with the new params (verified against the emitted querystring).
  • The screen renders headline totals (worked/pairs/sessions/paused) and the three breakdown tables (Per medewerker/Per handeling/Per type) from the response rows.
  • Loading state renders Laden…; the query error state renders Kon rapport niet laden.; a report with no data falls back to zero totals and shows Geen gegevens. in every table (the ?? 0 / ?? [] defensive fallbacks).
  • Exporteer CSV calls downloadExport with the current filters; a rejected download surfaces Export mislukt. Probeer opnieuw..
  • downloadExport fetches /api/admin/export?<query> with a Bearer header when a token exists (and no Authorization header when it doesn't), throws on a non-ok response, names the file from the content-disposition header, and falls back to solelog-report.csv when that header is absent.
  • Lifecycle (src/screens/Reports.test.tsx): pick Deze maand + a Medewerker filter → the report query reflects both (from = this-month start, user_id set) and the returned rows still render.

Users — admin client

Src covered (admin apps/admin): src/screens/Users.tsx, src/components/UserForm.tsx, and src/api/users.ts — UserForm.tsx and users.ts at 100% stmts/branch/funcs/lines; Users.tsx at 100% stmts/branch/funcs/lines. Two c8 ignores with reasons in Users.tsx: the usersQuery.data ?? [] fallback (defensive — the isLoading/isError branches return above it, so data is always set) and the if (pw.length < 8) return; guard in onReset (defensive — the Opslaan button is disabled while the password is shorter than 8, so it never fires).

Guaranteed behaviors:

  • The screen lists one row per user with a role pill (Beheerder/Werker) and a status pill (Actief/Inactief), plus name, email, and a locale-formatted created date.
  • The signed-in admin's own row (matched by useMe().user.id) shows a jij badge and hides the role-toggle and deactivate/reactivate actions (a user can never demote or deactivate themselves via the UI); other rows show those actions.
  • The loading state renders Laden… and the query error state renders Kon gebruikers niet laden..
  • + Nieuwe gebruiker toggles the create form; submitting it POSTs a CreateUserInput ({ name, email, password, role }) to /api/admin/users; Annuleer closes the form without a POST.
  • Create errors surface inline as E-mailadres bestaat al of ongeldig. (the useCreateUser onError path), which renders in the UserForm error slot.
  • UserForm: name/email are trimmed into the payload and the selected Rol is carried through; a password shorter than 8 shows Minstens 8 tekens. and blocks submission (the password.length < 8 guard); the submit button is disabled while pending.
  • The role button promotes a worker to admin ({ role: 'admin' }) or demotes an admin to worker ({ role: 'worker' }) via POST /api/admin/users/:id/role.
  • Inline password reset: Reset wachtwoord reveals a password field with Opslaan/Annuleer; Opslaan stays disabled until the new password is at least 8 chars, then POSTs { password } to /api/admin/users/:id/password and closes the field on success; Annuleer closes it without a POST.
  • Deactiveer POSTs to /api/admin/users/:id/deactivate (shown on active peers) and Heractiveer POSTs to /api/admin/users/:id/reactivate (shown on inactive peers).
  • Every mutation (create/setRole/resetPassword/deactivate/reactivate) invalidates the ['admin','users'] query so the list refetches and reflects the change.
  • Lifecycle (src/screens/Users.test.tsx): create a worker via the form → it appears in the refreshed list as Werker → promote it via Maak admin → the pill flips to Beheerder after the refetch → reset its password inline (Opslaan) → the POST fires and the inline field closes.

Activities + app/nav — admin client

Src covered (admin apps/admin): src/screens/Activities.tsx, src/api/activities.ts, src/App.tsx, and src/components/Sidebar.tsx — all at 100% stmts/branch/funcs/lines. Three c8 ignores with reasons in Activities.tsx: the early-return guards in handleAdd, handleSave, and handleMove are unreachable via the UI because the button that triggers each is disabled in exactly that state (Stap toevoegen when !addEnabled, Opslaan when the name/types are empty, the ↑/↓ move buttons at the list bounds).

Guaranteed behaviors:

  • Activities renders the Dutch header (Handelingen / Beheer handelingen per zooltype), the add-handeling card, and the current-steps list with a live count (Huidige stappen (<n>)).
  • The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via aria-pressed; a new handeling defaults to all three types (Kurk, Berk, 3D).
  • Creating a handeling POSTs /api/activities with the trimmed name and the selected insole_types subset (a deselected type is dropped from the payload); Stap toevoegen is disabled while the name is blank or every type is deselected.
  • Editing a handeling opens an inline editor prefilled from the row and PUTs /api/activities/:id with the updated name + insole_types; an activity stored with no insole types pre-selects all three in the editor; Annuleren exits edit mode without issuing a PUT.
  • Deleting a handeling confirms first (window.confirm); DELETE /api/activities/:id fires only when confirmed, and is skipped when cancelled.
  • Reorder: ↑/↓ move a row and PUT /api/activities/reorder with the swapped id order; ↑ is disabled on the first row and ↓ on the last.
  • The list shows the loading placeholder (Laden...) while the query is pending and the empty state (Nog geen stappen. Voeg er een toe hierboven.) when there are none; a query error falls back to the same empty state (there is no dedicated error UI).
  • api/activities.ts hooks hit the right path/method — useActivities → GET /api/activities; useCreateActivity → POST; useUpdateActivity → PUT /:id; useDeleteActivity → DELETE /:id; useReorderActivities → PUT /reorder — and every mutation invalidates the ['activities'] query on success.
  • App/nav: App gates on auth (no token → Login; a token → the authed shell with Sidebar + routed screens). Sidebar renders the brand, the five nav links, and the signed-in email (hidden when useMe has no data), and Uitloggen calls signOut.
  • Sidebar highlights the active route via nav-link-active and, with end matching on /, keeps the Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves the highlight.
  • Lifecycle (src/screens/Activities.test.tsx): create a handeling → edit its insole types down to a single type → reorder it above another → delete it, with each step reflected in the refetched list; the Sidebar test (src/components/Sidebar.test.tsx) covers navigation switching screens.