Files
solelog/docs/reference/domain-test-checklist.md

31 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.