Fill the Stopwatch domain to 100% coverage (Stopwatch.tsx + stopwatch.ts, both now 100% stmts/branch/funcs/lines) and add the reconcile lifecycle test. New tests cover: idle display-tap start (and no-op without a handling), pair-count stepper floor + free-typed entry (non-positive ignored), clearing the handling via the empty option, stop/discard onSuccess reset, discard arm-window expiry, clearing a pending discard timer on reconcile and on unmount, and the reconcile lifecycle — running locally, then a poll without the local session resets the timer and shows the admin-stopped notice. No behaviour changes. Five defensive/unreachable guards in Stopwatch.tsx are annotated /* c8 ignore next -- <reason> */ (comment-only): the paused_seconds ?? 0 coalesce (contract types it non-null) and the early-return guards in handleSelectZool/handleStart/handleStop/handleDiscard (each triggered by a control disabled or unrendered in that state). No bugs found.
40 KiB
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 /healthreturns{ status: 'ok' }.env.tsapplies 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 (PORTcoerced to a number).env.WEB_ORIGINSparsesCORS_ORIGINSinto a list, trimming whitespace and dropping empty entries; falls back to the default localhost origins whenCORS_ORIGINSis 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 whenNODE_ENV=production(reference activities still seeded).- The
migrate.ts/seed.tsCLI direct-run blocks arec8 ignored — unreachable under the test runner (exercised only viatsx 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-tokenheader); sign-in with a wrong password fails (status ≥ 400). - Public sign-up is rejected (
disableSignUp— admin creates users). GET /api/mereturns 401 without a token, and{ user: { id, email, name, role } }with a valid bearer; role reflects the user (worker/admin).GET /api/mefalls back to role'worker'when the session user has no role set.getSessionUsermapsid,role, andbannedfrom the session; defaultsroleto'worker'andbannedtofalsewhen unset; returnsnullwhen there is no session.isAdminis true only for theadminrole (false forworkerand fornull).adminGuardreturns 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 inadmin-users.test.ts.)- CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the
access-control-allow-originheader, OPTIONS preflight is answered with the allowed methods, andset-auth-tokenis 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/activitiesrequires a session (401 without a token); any authenticated user — worker or admin — may read.- The list is ordered by
sort_orderthenname, and?insole_type=<type>filters to activities whoseinsole_typesinclude that type. POST/PUT/DELETE/PUT /reorderare admin-only: 401 without a token, 403 for a worker.- Create validates a non-empty (trimmed)
name(400 otherwise) and defaultsinsole_typesto all three (Kurk,Berk,3D) when omitted. - Create appends the new activity with a
sort_orderone higher than the current maximum. insole_typesround-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) setssort_orderfrom theidsarray position; it 400s on an empty array (zodmin(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,discardall require a session (401 without a token), as do the readsGET /api/sessionsandGET /api/sessions/active.POST /api/sessions/start: 200 creates anactive,source: 'app'session (nullend_time/duration_seconds); 400 on an invalid body and on a malformed-JSON body; 404 when theactivity_iddoes not exist.POST /:id/pause: 200 setspaused_aton 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 clearspaused_atand accumulatespaused_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 intopaused_seconds, and computesduration_seconds = wall-clock − paused_seconds(never negative); a never-paused stop keepspaused_seconds = 0andduration = wall-clock; 404 for a non-numeric/unknown id; 409 when already closed.POST /:id/discard: 200 marks the sessiondiscardedwith a nullduration_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/sessionsreturns the caller's sessions only, newest-first, joined to the activityname;GET /api/sessions/activereturns only that user'sactivesessions. toWorkSessionmaps every field to the API contract (incl.paused_at,paused_seconds,notes,created_at), threadsactivity_name/user_name/user_emailopts through (undefined when omitted), maps a nullinsoleType/durationSeconds/notesto null, and coalesces a nullishpaused_secondsto 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/exportreturns 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,includeWorkerdefaults to false) emits the exact legacy headerID,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 aWorkercolumn carryinguserName.- Every field is quoted;
quoteescapes 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/userNamerenders'', nullinsoleTyperendersKurk, nullpairCountrenders2, nulldurationSeconds/pausedSecondsrender00:00:00, and a nullendTimerenders an empty End Time cell. formatDurationrenders zero-paddedHH: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'scompletedsessions (active/discarded and other users' rows excluded); rows ordered by start time ascending; sets thetext/csvcontent-type and theattachment; filename="insole-production-report.csv"disposition header.- Lifecycle: covered by the worker-session anchor (
test/integration/worker-session-lifecycle.test.ts), whose final step assertsGET /api/exportreturns 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/sessionsreturns all users' sessions newest-first, each joined toactivity_name,user_name, anduser_email;GET /api/admin/sessions/activeis the same but filtered toactivesessions only.POST /api/admin/sessions(manual create): creates acompleted,source: 'manual'session withduration_seconds = span − paused_secondsand nullpaused_at; 400 on a schema-invalid body, onend_time < start_time, and whenpaused_secondsexceeds the worked span; 404 for an unknownuser_idand for an unknownactivity_id.PUT /api/admin/sessions/:id(edit): recomputesduration_secondswhenend_timeis present and sets it null whenend_timeis null (re-activating); 404 for a non-numeric or unknown id; 400 on a schema-invalid body, on an unparseablestart_time, onend_time < start_time, and whenpaused_secondsexceeds the span; 404 for an unknownactivity_id.POST /api/admin/sessions/:id/stop: completes another user's active session, folds an open pause span intopaused_seconds, clearspaused_at, and computesduration_secondsexcluding 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 sessiondiscardedwith anend_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 inGET /api/admin/report(totals,by_worker,by_activity,by_typeall matching the stored row) and inGET /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/reportandGET /api/admin/exportare gated: 401 without a token, 403 for a worker. - Query parsing (
parseReportQuery, shared by report + export): 400 whenfrom/tois missing, unparseable, or inverted (to < from); 400 on aninsole_typeoutsideKurk/Berk/3D; 400 on a non-numericactivity_id; the export route rejects the same bad queries with 400. - Report scope: only
completedsessions inside[from, to](bystart_time) are counted — active/discarded rows are excluded. - Filters narrow the result set:
user_id,insole_type, andactivity_ideach restrict the counted sessions (individually and combined). - Totals sum
worked_seconds,paused_seconds,pairs, andsessionsacross every matched row, and equal the sum of each breakdown. - Breakdowns
by_worker,by_activity, andby_typegroup correctly and are each sorted byworked_secondsdescending. - A completed session with a null
insole_typeis bucketed under"Onbekend"inby_type(worker and activity buckets stay real); a completed session with a nullduration_secondscontributes0worked seconds. - An empty range yields zero totals and empty
by_worker/by_activity/by_typearrays. - Admin export: emits an all-users CSV with the leading
Workercolumn; thetext/csvcontent-type and aContent-Dispositionfilename ofsolelog-report_<from>_<to>.csv(dates sliced toYYYY-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 everyby_worker,by_activity,by_typerow (values and descending order) → apply auser_idfilter → 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 (adminGuarddefense-in-depth). GET /api/admin/usersreturns every user ordered by name, each mapped bytoListItemto{ id, email, name, role, status, created_at }; a null DBrolefalls back toworkerand abannedrow surfaces asstatus: 'inactive'.POST /api/admin/userscreates 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/rolepromotes/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/deactivatebans the user, clears any live session rows, and returnsstatus: 'inactive'; 404 on an unknown id; refuses self-deactivation (400); allows deactivating a peer admin while always blocking self.POST /:id/reactivateclears the ban and returnsstatus: 'active'; 404 on an unknown id.POST /:id/passwordsets 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'ssetUserPasswordthrows.- 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:setTokenpersists the bearer,getTokenreads it back,clearTokenremoves it (getTokenreturnsnullwhen nothing is stored) — all vialocalStorage.AuthContextstarts unauthenticated when no token is stored and restores the authenticated state on mount when a token is already present (getToken() !== null).AuthContext.signIndelegates to the apisignIn(email, password)and, on success, flips the context to authenticated (isAuthed→ true).AuthContext.signOutresets the router path to/(so the next authed mount lands on Stopwatch, not Account), clears the stored token, and flips the context to unauthenticated.useAuththrows"useAuth must be used within an AuthProvider"when called outside the provider.Loginrenders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials toPOST /api/auth/sign-in/email, storing the returned bearer token.Loginshows 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;
apiFetchthrowsApiErrorand 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 theExporteer CSVbutton. - Sessions render in the order the API returns them (
GET /api/sessionsis newest-first), one.session-cardper session, showing the activity name and its formatted duration. formatDurationrenders the legacy format:Hh Mmwhen hours are present (3661 → 1h 1m),Mm Sswhen only minutes (125 → 2m 5s), and just seconds under a minute (45 → 45s); a nullduration_secondsrenders0s.- A card falls back to
"Onbekende handeling"whenactivity_nameis absent, omits the insole-type pill wheninsole_typeis null, and uses the singular noun (inlegzool) only for a pair count of 1 (inlegzolenotherwise). - A
Pauze <duration>line shows only whenpaused_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 CSVcallsdownloadExport; when it rejects, the card surfaces theKan de export niet openenerror message instead of throwing. downloadExport(src/lib/export.ts): fetchesGET /api/exportwith the bearer token when one is stored (noAuthorizationheader when there is none), then triggers a client-side download via an object URL (createObjectURL→ anchorclick→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 itsPauzeline, 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, onceGET /api/meresolves, shows the signed-in user's name and email fromuseMe(). - The fallback message
Kon accountgegevens niet laden.shows when the query resolves without auser(and the name/email are then absent). - Clicking
UitloggencallssignOut, which clears the stored bearer token. - Lifecycle (
src/screens/Account.test.tsx): from the authenticated app on the Account screen, clickingUitloggenflips the auth context to unauthenticated so the login form (E-mailadres) renders and theUitloggenbutton 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:
apiFetchattachesAuthorization: Bearer <token>when a token is stored and omits it otherwise.apiFetchsetsContent-Type: application/jsonwhen a request has a body and no explicit Content-Type, and does not override a Content-Type the caller supplied.apiFetchparses a JSON response body, and returnsundefinedwhen the body is empty.apiFetchthrowsApiError(carrying the HTTP status) on any non-2xx response.signInPOSTs to/api/auth/sign-in/email, stores the token from theset-auth-tokenheader, and throwsApiErrorwhen the response is not ok or the header is missing.Appgates on auth state: no token rendersLogin(no tab bar); a token renders the authed shell with the Stopwatch screen mounted at/beside theTabBar.TabBarrenders the three tabs (Stopwatch→/,Geschiedenis→/history,Account→/account) as links, marks the current route's tabtab-active, and usesendmatching 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/startwith 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 rendersLoginwith 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:
AuthContextstarts unauthenticated when no token is stored and restores the authenticated state on mount when a token is already present (getToken() !== null).AuthContext.signIndelegates to the apisignIn(email, password), then fetches/api/me: admin-only gate — anadminrole flips the context to authenticated; a non-admin role clears the stored token and throwsNotAdminError("not-admin"), leaving the context unauthenticated.AuthContext.signOutresets 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.useAuththrows"useAuth must be used within an AuthProvider"when called outside the provider.Loginrenders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed credentials tosignIn.Logindistinguishes the two failure modes: aNotAdminErrorsurfaces"Geen toegang — alleen beheerders."while any other failure surfaces"Inloggen mislukt"; a stale error is cleared on the next submit.Logindisables 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-cardper active session under aActief 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, wherebaseisnowwhile running (a 1s client tick counts it up between the 5s server polls) and the frozenpaused_atmoment while paused, floored at 0.formatTime(inelapsed.ts) renders it as zero-paddedHH:MM:SSand never returns a negative time. - A running (not-paused) card's timer keeps counting up on the 1s tick; a paused card shows the
Gepauzeerdbadge and its worked timer stays frozen at the pause moment across ticks. - The
Pauze <duration>total line shows only whenpaused_seconds > 0(absent when it is 0). - Placeholder fallbacks: a missing
user_namerendersOnbekend, a missingactivity_namerendersOnbekende handeling, and a nullinsole_typeomits the type pill. - Each card exposes
StopandAnnuleerbuttons that callPOST /api/admin/sessions/:id/stopand.../discardfor that session; both buttons are disabled while either mutation is in flight. - Loading state renders
Laden…; the error state rendersKon gegevens niet laden.(and no header); the empty state rendersNiemand is nu aan het werk.; a non-array payload is treated as zero active sessions (defensiveArray.isArrayfallback). - 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 theGepauzeerdbadge and itsPauze 00:01:30total.
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-rowper session (worker, activity + insole-type chip, worked time, start date, status badge). Row worked-time is server-authoritative: completed/discarded rows useduration_seconds; an active row counts(base − start) − paused_secondswherebaseis the frozenpaused_atmoment while paused (elsenow), floored at 0. - The
Statusfilter narrows the list toall/active/completed/discarded; the empty message isNog geen sessies.when there are truly none andGeen 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 rendersLaden…; a non-array payload is treated as zero sessions. - Row placeholders: a missing
user_namerendersOnbekend(and the edit aria-label usesonbekend), a missingactivity_namerendersOnbekende handeling, a nullinsole_typeomits the type chip, and an unparseablestart_timerenders an empty date cell; thePauze <time>line shows only whenpaused_seconds > 0. StopandAnnuleerappear only onactiverows and callPOST /api/admin/sessions/:id/stopand.../discard; the✎edit button appears on every row and opens the shared form in edit mode.+ Nieuwe registratieopens 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 aStatusselect, prefills from the session, and PUTs anAdminUpdateSessionInput. - 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 showsVul medewerker, zooltype, start- en eindtijd in.. - Serialization: the notitie is trimmed (empty →
null); in edit mode an empty zooltype serializes toinsole_type: nulland statusactiveserializesend_time: null;Aantal parenfloors at 1 andPauze (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 surfacesControleer de ingevoerde gegevens …while any other failure surfacesOpslaan 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;toDateStrzero-pads month/day. - Presets map to the correct
YYYY-MM-DDwindows:thisWeek= Monday..today,thisMonth= 1st..today,allTime= fixed floor2000-01-01..today. filtersToQueryalways serializesfrom/toand only emitsuser_id/insole_type/activity_idwhen set (empty optionals are omitted).useReportcallsGET /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 rendersKon rapport niet laden.; a report with no data falls back to zero totals and showsGeen gegevens.in every table (the?? 0/?? []defensive fallbacks). Exporteer CSVcallsdownloadExportwith the current filters; a rejected download surfacesExport mislukt. Probeer opnieuw..downloadExportfetches/api/admin/export?<query>with aBearerheader when a token exists (and noAuthorizationheader when it doesn't), throws on a non-ok response, names the file from thecontent-dispositionheader, and falls back tosolelog-report.csvwhen that header is absent.- Lifecycle (
src/screens/Reports.test.tsx): pickDeze maand+ a Medewerker filter → the report query reflects both (from= this-month start,user_idset) 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 ajijbadge 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 rendersKon gebruikers niet laden.. + Nieuwe gebruikertoggles the create form; submitting it POSTs aCreateUserInput({ name, email, password, role }) to/api/admin/users;Annuleercloses the form without a POST.- Create errors surface inline as
E-mailadres bestaat al of ongeldig.(theuseCreateUseronError path), which renders in theUserFormerror slot. UserForm: name/email are trimmed into the payload and the selectedRolis carried through; a password shorter than 8 showsMinstens 8 tekens.and blocks submission (thepassword.length < 8guard); the submit button is disabled whilepending.- The role button promotes a worker to admin (
{ role: 'admin' }) or demotes an admin to worker ({ role: 'worker' }) viaPOST /api/admin/users/:id/role. - Inline password reset:
Reset wachtwoordreveals a password field withOpslaan/Annuleer;Opslaanstays disabled until the new password is at least 8 chars, then POSTs{ password }to/api/admin/users/:id/passwordand closes the field on success;Annuleercloses it without a POST. DeactiveerPOSTs to/api/admin/users/:id/deactivate(shown on active peers) andHeractiveerPOSTs 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 asWerker→ promote it viaMaak admin→ the pill flips toBeheerderafter 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/activitieswith the trimmed name and the selectedinsole_typessubset (a deselected type is dropped from the payload);Stap toevoegenis 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/:idwith the updated name +insole_types; an activity stored with no insole types pre-selects all three in the editor;Annulerenexits edit mode without issuing a PUT. - Deleting a handeling confirms first (
window.confirm); DELETE/api/activities/:idfires only when confirmed, and is skipped when cancelled. - Reorder: ↑/↓ move a row and PUT
/api/activities/reorderwith 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.tshooks 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:
Appgates on auth (no token →Login; a token → the authed shell withSidebar+ routed screens).Sidebarrenders the brand, the five nav links, and the signed-in email (hidden whenuseMehas no data), andUitloggencallssignOut. Sidebarhighlights the active route vianav-link-activeand, withendmatching 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.
Stopwatch + reconcile — worker client
Src covered (worker apps/worker): src/screens/Stopwatch.tsx and src/lib/stopwatch.ts — both at
100% stmts/branch/funcs/lines. Five c8 ignores with reasons in Stopwatch.tsx: the
paused_seconds ?? 0 coalesce in the reconcile adopt (the contract types paused_seconds as
z.number().int(), never null) and the four early-return guards in handleSelectZool, handleStart,
handleStop, and handleDiscard — each unreachable because the button that triggers it is
disabled or not rendered in exactly that state (and the handleStart guard also narrows the type).
Guaranteed behaviors:
formatTimerenders zero-paddedHH:MM:SS, floors a negative total at00:00:00, and lets hours exceed two digits;elapsedSecondsreturns(now − start − paused)floored at 0 (paused time excluded).- The three sections (
Type zool/Type handeling/Aantal zolen) and the Dutch Start button render; the zool segmented control offersKurk/Berk/3DwithKurkselected by default. - Choosing a zooltype filters the handeling
<select>to activities whoseinsole_typesinclude that type and clears any previously chosen handeling; picking the empty option (Kies een handeling...) also clears the chosen handeling. - Start is disabled until a handeling is chosen; starting fires
useStartSessionwith{ activity_id, insole_type, pair_count }from the current selection. - Pair count: the stepper
+/−buttons adjust the count with a floor of 1 (the−button is disabled at 1); the free-typed field mirrors the raw text but only adopts a parsed value greater than 0 (a non-positive or non-numeric entry keeps the last valid count). - The stopwatch display is tappable: while idle with a handeling chosen it starts the session (idle
tap with no handeling is a no-op); while running it pauses via
usePauseSession; while paused it resumes viauseResumeSession(folding the just-finished pause span into the local accumulator). - On a successful start the screen adopts the returned session's running state (id, start time, cleared
pause); a paused server session is recovered into the paused UI (
Gepauzeerd — tik om te hervatten). - Stop (
Stop & Opslaan) and discard both reset the timer to idle on success; a409(already closed server-side) on either resets locally instead of leaving the timer stuck. - Discard is a double-tap: the first
Annulerentap arms it (Nogmaals tikken ter bevestiging) and the second confirms and firesuseDiscardSession; the arm window lapses after 3s back toAnnuleren, and a pending arm timer is cleared both on reconcile-reset and on unmount. - Reconcile against the active-sessions poll: while idle with a server-active session, the screen adopts it (phone-died / resume-elsewhere); the running-vs-server check keys on session id.
- Lifecycle (
src/screens/Stopwatch.test.tsx): running locally after a successful start, when the active-sessions poll returns a list without the local session (only another worker's), the timer resets to idle and theDeze sessie is door de beheerder gestopt.notice shows.