docs: spec + plan for phase 3b.1 manual session entry/edit + admin stop/fix
This commit is contained in:
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`).
|
||||
> Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`.
|
||||
|
||||
**Goal:** Admin can list/filter all sessions, manually create a completed session for a worker,
|
||||
edit any session (server recomputes duration), and stop/cancel a worker's active session — and the
|
||||
worker's stopwatch converges to admin changes within ~15s (no stuck state).
|
||||
|
||||
**Architecture:** New admin-gated write endpoints + a users-roster endpoint in `routes/admin.ts`;
|
||||
a new admin **Sessies** screen + shared create/edit form; Stop/Annuleer actions on Live; worker
|
||||
polls + reconciles. **No DB migration** (reuses existing `work_sessions` columns).
|
||||
|
||||
**Tech Stack:** Hono + Drizzle + libsql (api), Vite+React+react-query (admin, worker),
|
||||
`@solelog/shared` zod, vitest. Yarn 4 monorepo.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **TDD**: failing test → see it fail → minimal implementation → green → commit.
|
||||
- **Commit per task**, conventional-commit message; commit **locally only** (no push/remote/amend);
|
||||
stage only your task's files.
|
||||
- **oxlint + oxfmt on changed files only.** `.oxfmtrc.json` now uses `trailingComma: "all"`
|
||||
(prettier-style) — keep trailing commas on multiline params/args/arrays/objects; `docs/**` and
|
||||
`**/drizzle/**` are ignored by the formatter. 2-space, single quotes, semicolons, width 100.
|
||||
- **Dutch UI strings.**
|
||||
- **No DB migration** this cycle. **Do not start the API server** (tests use in-process
|
||||
`app.request`); if you must, kill the tree + free port 3000 afterward (Windows lock trap).
|
||||
- Reuse `toWorkSession` (`apps/api/src/lib/work-session.ts`) for session responses. New admin
|
||||
routes live in `apps/api/src/routes/admin.ts`, already behind the `/api/admin/*` admin guard.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/shared/src/index.ts MODIFY CreateManualSessionInput, AdminUpdateSessionInput
|
||||
apps/api/src/routes/admin.ts MODIFY GET /api/admin/users; POST/PUT/stop/discard sessions
|
||||
apps/api/test/admin.test.ts MODIFY
|
||||
apps/admin/src/api/admin-sessions.ts MODIFY add list/users/create/update/stop/discard hooks
|
||||
apps/admin/src/screens/Sessions.tsx CREATE list + status filter + row actions
|
||||
apps/admin/src/components/SessionForm.tsx CREATE create/edit form
|
||||
apps/admin/src/components/Sidebar.tsx MODIFY add 'Sessies' nav; drop 'Handmatig' from soon
|
||||
apps/admin/src/App.tsx MODIFY add /sessies route
|
||||
apps/admin/src/screens/Live.tsx MODIFY Stop/Annuleer on LiveCard
|
||||
apps/admin/src/styles.css MODIFY table/form/action styles
|
||||
apps/admin/src/screens/Sessions.test.tsx, components/SessionForm.test.tsx, screens/Live.test.tsx TEST
|
||||
apps/worker/src/api/sessions.ts MODIFY refetchInterval 15000
|
||||
apps/worker/src/screens/Stopwatch.tsx MODIFY reconcile + 409 handling
|
||||
apps/worker/src/screens/Stopwatch.test.tsx TEST
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared contracts
|
||||
|
||||
**Files:** `packages/shared/src/index.ts`; test `packages/shared` (or assert via api tests — add a
|
||||
tiny parse test if shared has a test setup, else cover via Task 2's api tests).
|
||||
|
||||
**Interfaces — Produces:**
|
||||
- `CreateManualSessionInput = z.object({ user_id: z.string(), activity_id: z.number().int(),
|
||||
insole_type: InsoleType, pair_count: z.number().int().min(1), start_time: z.string(),
|
||||
end_time: z.string(), paused_seconds: z.number().int().min(0).default(0),
|
||||
notes: z.string().nullable().optional() })`.
|
||||
- `AdminUpdateSessionInput = z.object({ activity_id: z.number().int(), insole_type:
|
||||
InsoleType.nullable(), pair_count: z.number().int().min(1), start_time: z.string(),
|
||||
end_time: z.string().nullable(), paused_seconds: z.number().int().min(0),
|
||||
notes: z.string().nullable(), status: SessionStatus })`.
|
||||
|
||||
- [ ] **Step 1:** If `packages/shared` has no test runner, skip a standalone test here and rely on
|
||||
Task 2's API tests to exercise the schemas (note this in the commit). Otherwise add a parse
|
||||
test (valid input parses; `pair_count: 0` fails).
|
||||
- [ ] **Step 2:** Add both schemas + inferred types after `StartSessionInput`/`AdminUser`.
|
||||
- [ ] **Step 3:** `yarn workspace @solelog/api typecheck` (shared is consumed there) — green.
|
||||
- [ ] **Step 4: Commit** — `feat(shared): manual-session create/update contracts`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — admin session write endpoints + users roster
|
||||
|
||||
**Files:** `apps/api/src/routes/admin.ts`; test `apps/api/test/admin.test.ts`.
|
||||
|
||||
**Interfaces — Produces** `GET /api/admin/users`, `POST /api/admin/sessions`,
|
||||
`PUT /api/admin/sessions/:id`, `POST /api/admin/sessions/:id/stop`,
|
||||
`POST /api/admin/sessions/:id/discard`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (helpers: `createTestUser`/`bearer`/`seedActivity`; create an admin
|
||||
with `authToken(app, email, 'admin')` per the existing pattern):
|
||||
- `GET /api/admin/users` (admin) returns objects with `id/name/email`; 403 for a worker.
|
||||
- `POST /api/admin/sessions` (admin) with a worker's id + activity + `start_time`/`end_time` 1h
|
||||
apart + `paused_seconds: 600` → 201/200 with `source==='manual'`, `status==='completed'`,
|
||||
`duration_seconds === 3000` (3600−600).
|
||||
- `POST` with `end < start` → 400; unknown `user_id`/`activity_id` → 404/400.
|
||||
- `PUT /api/admin/sessions/:id` edits a session and recomputes duration; `end<start` → 400.
|
||||
- `POST …/:id/stop` on another user's **active** session → completed with computed duration;
|
||||
`…/:id/discard` → `status==='discarded'`. Worker token on any of these → 403.
|
||||
- [ ] **Step 2: Run — fail** (routes 404).
|
||||
- [ ] **Step 3: Implement** in `admin.ts` (imports: `and, eq, asc, desc` from drizzle-orm,
|
||||
`CreateManualSessionInput`, `AdminUpdateSessionInput` from `@solelog/shared`, `activities`,
|
||||
`user`, `workSessions` from schema, `toWorkSession`). All routes sit after the existing
|
||||
`/api/admin/*` guard (already admin-gated).
|
||||
|
||||
```ts
|
||||
adminRoutes.get('/api/admin/users', async (c) => {
|
||||
const rows = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.orderBy(asc(user.name));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
function computeDuration(startMs: number, endMs: number, paused: number) {
|
||||
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
|
||||
}
|
||||
|
||||
adminRoutes.post('/api/admin/sessions', async (c) => {
|
||||
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||
const d = parsed.data;
|
||||
const start = new Date(d.start_time);
|
||||
const end = new Date(d.end_time);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
|
||||
return c.json({ error: 'Invalid input' }, 400);
|
||||
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
|
||||
if (!u) return c.json({ error: 'User not found' }, 404);
|
||||
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
|
||||
if (!act) return c.json({ error: 'Activity not found' }, 404);
|
||||
if (d.paused_seconds > Math.round((end.getTime() - start.getTime()) / 1000))
|
||||
return c.json({ error: 'Invalid input' }, 400);
|
||||
const [row] = await db
|
||||
.insert(workSessions)
|
||||
.values({
|
||||
userId: d.user_id,
|
||||
activityId: d.activity_id,
|
||||
insoleType: d.insole_type,
|
||||
pairCount: d.pair_count,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
|
||||
pausedSeconds: d.paused_seconds,
|
||||
pausedAt: null,
|
||||
status: 'completed',
|
||||
source: 'manual',
|
||||
notes: d.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
return c.json(toWorkSession(row, { activityName: act.name }));
|
||||
});
|
||||
```
|
||||
|
||||
- **PUT `:id`** — load the row (any user); 404 if missing; validate activity; if `end_time`
|
||||
present validate `end ≥ start` and `paused ≤ span`, set `durationSeconds` via `computeDuration`,
|
||||
else `durationSeconds = null` and (if `status==='active'`) keep it open. Set the editable
|
||||
fields; return `toWorkSession`.
|
||||
- **stop `:id`** — load active row; if `pausedAt`, fold the open span into `pausedSeconds`;
|
||||
`end=now`; `durationSeconds=computeDuration(...)`; `status='completed'`, `pausedAt=null`.
|
||||
- **discard `:id`** — load active row; `status='discarded'`, `end=now`.
|
||||
- Register `stop`/`discard` (literal subpaths) and the bare `:id` PUT so they don't collide
|
||||
(Hono matches `/api/admin/sessions/:id/stop` distinctly from `/:id`).
|
||||
- [ ] **Step 4: Run tests + typecheck — green.**
|
||||
- [ ] **Step 5: Commit** — `feat(api): admin manual-session create/edit/stop/discard + users roster`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Admin — api hooks + Sessions screen (list + filter + nav/route)
|
||||
|
||||
**Files:** `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/screens/Sessions.tsx` (create),
|
||||
`apps/admin/src/components/Sidebar.tsx`, `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`;
|
||||
test `apps/admin/src/screens/Sessions.test.tsx`.
|
||||
|
||||
**Interfaces — Produces** `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession`.
|
||||
|
||||
- [ ] **Step 1: Failing test** (mock `apiFetch`): `Sessions` renders a row per session (worker +
|
||||
activity + worked); the status filter narrows the list (e.g. selecting "actief" shows only
|
||||
active); `Stop`/`Annuleer` appear only on active rows and call the right endpoints; ✎ present on
|
||||
all rows.
|
||||
- [ ] **Step 2: Run — fail.**
|
||||
- [ ] **Step 3: Hooks** in `api/admin-sessions.ts` (keep `useActiveSessions`):
|
||||
|
||||
```ts
|
||||
export function useAllSessions() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'sessions', 'all'],
|
||||
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
|
||||
});
|
||||
}
|
||||
export function useAdminUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
|
||||
});
|
||||
}
|
||||
// useCreateManualSession / useUpdateSession / useAdminStopSession / useAdminDiscardSession:
|
||||
// useMutation hitting POST /api/admin/sessions, PUT /api/admin/sessions/:id,
|
||||
// POST /api/admin/sessions/:id/stop|/discard; each onSuccess invalidates ['admin','sessions'].
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Sidebar + route** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems` in
|
||||
`Sidebar.tsx`; remove `'Handmatig'` from `soonItems` (leaves `['Rapporten', 'Gebruikers']`). In
|
||||
`App.tsx` import `Sessions` and add `<Route path="/sessies" element={<Sessions />} />`.
|
||||
- [ ] **Step 5: Screen** — `Sessions.tsx`: title "Sessies", a status `<select>` filter
|
||||
(alle/actief/voltooid/geannuleerd), `+ Nieuwe registratie` button (opens the form from Task 4 —
|
||||
for now a stub/`onCreate` prop or local state placeholder), a table/list of rows with worked
|
||||
time (reuse `formatTime` from `lib/elapsed`; show `Pauze …` when `paused_seconds>0`), ✎ edit and
|
||||
(active only) `Stop`/`Annuleer` buttons wired to the hooks. Loading/error/empty states in Dutch.
|
||||
- [ ] **Step 6: Styles** — add `.sessions-*`/table/action-button CSS to `styles.css`.
|
||||
- [ ] **Step 7: Run admin tests + typecheck + build — green.**
|
||||
- [ ] **Step 8: Commit** — `feat(admin): sessions management screen (list + filter + actions)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Admin — create/edit session form
|
||||
|
||||
**Files:** `apps/admin/src/components/SessionForm.tsx` (create), wire into
|
||||
`apps/admin/src/screens/Sessions.tsx`, `apps/admin/src/styles.css`; test
|
||||
`apps/admin/src/components/SessionForm.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (mock `apiFetch`/hooks): in **create** mode the form shows a worker
|
||||
picker (from `useAdminUsers`) and submitting posts `CreateManualSessionInput` with the entered
|
||||
values; in **edit** mode it prefills from the session, hides the worker picker, and submitting
|
||||
PUTs the changed fields. The "gewerkt" preview = `end − start − paused`.
|
||||
- [ ] **Step 2: Run — fail.**
|
||||
- [ ] **Step 3: Implement `SessionForm.tsx`** — props `{ mode: 'create' | 'edit', session?,
|
||||
onClose }`. Fields: worker `<select>` (create only), activity `<select>` (from `useActivities`),
|
||||
insole-type toggles, pair-count stepper, start/end `datetime-local`, paused (minutes or H:MM),
|
||||
status `<select>` (edit only), notes `<textarea>`, and a derived "gewerkt" line. Build the ISO
|
||||
`start_time`/`end_time` from the datetime-local values. Submit via `useCreateManualSession` /
|
||||
`useUpdateSession`; close on success. Inline error on 400.
|
||||
- [ ] **Step 4: Wire into Sessions** — `+ Nieuwe registratie` opens it in create mode; ✎ opens it
|
||||
in edit mode with the row's session. Modal or inline panel — keep it simple.
|
||||
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
|
||||
- [ ] **Step 6: Commit** — `feat(admin): manual session create/edit form`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Admin — Stop/Annuleer on the Live view
|
||||
|
||||
**Files:** `apps/admin/src/screens/Live.tsx`, `apps/admin/src/styles.css`; test
|
||||
`apps/admin/src/screens/Live.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: Failing test:** an active LiveCard shows `Stop` and `Annuleer`; clicking `Stop`
|
||||
calls `POST /api/admin/sessions/:id/stop`, `Annuleer` calls `…/discard`.
|
||||
- [ ] **Step 2: Run — fail.**
|
||||
- [ ] **Step 3: Implement** — add the two buttons to `LiveCard`, wired to `useAdminStopSession` /
|
||||
`useAdminDiscardSession`; on success the active query invalidates and the card drops off.
|
||||
- [ ] **Step 4: Run admin tests + typecheck + build — green.**
|
||||
- [ ] **Step 5: Commit** — `feat(admin): stop/cancel an active session from the live view`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Worker — poll + reconcile to server truth
|
||||
|
||||
**Files:** `apps/worker/src/api/sessions.ts`, `apps/worker/src/screens/Stopwatch.tsx`; test
|
||||
`apps/worker/src/screens/Stopwatch.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (mock `apiFetch`): with a session running locally, when the
|
||||
active-sessions query resolves to a list **without** that session, the stopwatch resets to the
|
||||
idle/start state and shows **"Deze sessie is door de beheerder gestopt."**; a 409 from the stop
|
||||
mutation resets locally (no error surfaced).
|
||||
- [ ] **Step 2: Run — fail.**
|
||||
- [ ] **Step 3: Poll** — in `api/sessions.ts`, add `refetchInterval: 15000` to `useActiveSessions`.
|
||||
- [ ] **Step 4: Reconcile** — in `Stopwatch.tsx`, extend the active-session effect: when
|
||||
`activeSessionsQuery.data` is present and the worker has a local `sessionId` that is **not** in
|
||||
the returned active list, call `resetTimer()` and set a transient `stoppedByAdmin` notice
|
||||
(cleared on next start). Keep the existing "adopt an active session when idle" recovery.
|
||||
- [ ] **Step 5: 409 handling** — give `useStopSession`/`useDiscardSession` (or the `handleStop`/
|
||||
`handleDiscard` callers) an error path: if the error is `ApiError` with `status === 409`, call
|
||||
`resetTimer()` (it's already closed) instead of leaving the timer stuck.
|
||||
- [ ] **Step 6: Run worker tests + typecheck + build — green.**
|
||||
- [ ] **Step 7: Commit** — `feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs, lint, verification
|
||||
|
||||
**Files:** `docs/roadmap.md`, `docs/sessions/2026-06-17-phase-3b1-manual-sessions.md` (create).
|
||||
|
||||
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` on changed files only.
|
||||
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck && test`;
|
||||
`yarn workspace @solelog/admin typecheck && test && build`;
|
||||
`yarn workspace @solelog/worker typecheck && test && build`.
|
||||
- [ ] **Step 3: Live smoke (preferred)** — start API, seed; as admin: `GET /api/admin/users`,
|
||||
`POST /api/admin/sessions` (manual, confirm duration excludes paused + `source=manual`),
|
||||
`PUT` edit, `stop`/`discard` on a worker's active session; then **kill the server tree + free
|
||||
port 3000**.
|
||||
- [ ] **Step 4: Docs** — session log (goal/work/verification/outcome) + a roadmap note (Phase 3b·1
|
||||
done; reports/export and user management remain).
|
||||
- [ ] **Step 5: Commit** — `docs: phase 3b·1 manual-sessions session log + roadmap note`.
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- Duration is always derived server-side (`computeDuration`) — never accepted from the client.
|
||||
- `stop`/`discard` literal subpaths vs the bare `:id` PUT don't collide in Hono.
|
||||
- Worker reconciliation keys off "my local session id is absent from the server's active list" —
|
||||
covers admin stop, admin discard, and admin edit-to-completed alike.
|
||||
- No migration: all fields already exist on `work_sessions` (incl. pause fields from the prior
|
||||
cycle).
|
||||
@@ -0,0 +1,112 @@
|
||||
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Design
|
||||
|
||||
- **Created:** 2026-06-17
|
||||
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||
- **Cycle:** First of three Phase 3b cycles (then reports/export, then user management)
|
||||
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`, `apps/worker`
|
||||
|
||||
## Goal
|
||||
|
||||
An admin can find any work session, **create** a manual one for a worker, **edit/correct** any
|
||||
session, and **stop or cancel** a worker's stuck active session — the "manual fallback wherever
|
||||
something fails" from the vision. Because the backend is the source of truth, the **worker
|
||||
converges** to admin changes (no stuck stopwatch).
|
||||
|
||||
_Done when:_ an admin can list/filter all sessions, hand-create a completed session for a worker,
|
||||
edit any session's fields (server recomputes duration), and stop/cancel a worker's active
|
||||
session — and within ~15s the worker's stopwatch reflects a stop/cancel done by the admin.
|
||||
|
||||
## Scope decisions (confirmed during brainstorming, 2026-06-17)
|
||||
|
||||
1. **Slicing:** three sequenced 3b cycles; **this one first** (manual entry/edit + admin
|
||||
stop/fix), then reports/export, then user management.
|
||||
2. **Sessions UI:** a full **"Sessies"** admin screen (the all-sessions list deferred from 3a
|
||||
lands here; the reports cycle reuses it).
|
||||
3. **Editable fields:** start/end time, activity, insole type, pair count, paused seconds,
|
||||
notes, status. The worker is chosen **only on create** — no reassignment on edit. Duration is
|
||||
always **derived** server-side (`end − start − paused`), never typed.
|
||||
4. **Worker convergence:** folded into this cycle — the worker polls and reconciles to server
|
||||
truth (an admin stop/cancel reflects on the phone within ~15s; no stuck state).
|
||||
|
||||
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
|
||||
|
||||
- `GET /api/admin/users` — `{ id, name, email }[]` from the `user` table (ordered by name), to
|
||||
populate the create form's worker picker. Direct DB query — no better-auth client dependency.
|
||||
- `POST /api/admin/sessions` — manual create. Body `CreateManualSessionInput`:
|
||||
`user_id, activity_id, insole_type, pair_count, start_time, end_time, paused_seconds?, notes?`.
|
||||
Produces a **completed** session, `source='manual'`, `paused_at=null`,
|
||||
`duration_seconds = max(0, round((end−start)/1000) − paused_seconds)`.
|
||||
- `PUT /api/admin/sessions/:id` — edit any session. Body `AdminUpdateSessionInput`:
|
||||
`activity_id, insole_type, pair_count, start_time, end_time(nullable), paused_seconds, notes,
|
||||
status`. Recomputes `duration_seconds` from times − paused when `end_time` is present; when
|
||||
`status='active'`/`end_time` null, `duration_seconds=null`. No user reassignment.
|
||||
- `POST /api/admin/sessions/:id/stop` — quick "stop now": fold any open pause, `end=now`,
|
||||
`status='completed'`, recompute duration.
|
||||
- `POST /api/admin/sessions/:id/discard` — `status='discarded'`, `end=now`.
|
||||
|
||||
New `@solelog/shared` contracts: `CreateManualSessionInput`, `AdminUpdateSessionInput`. **No DB
|
||||
migration** — reuses existing `work_sessions` columns (incl. the pause fields). Responses use the
|
||||
existing `toWorkSession` mapper (so they carry `user_name`/`activity_name` where joined).
|
||||
|
||||
### Validation
|
||||
`end ≥ start`; `pair_count ≥ 1`; `paused_seconds ≥ 0` and `≤ (end−start)`; activity must exist;
|
||||
user must exist (create); `insole_type` a valid `InsoleType`. Invalid → 400; missing
|
||||
session/user → 404. No hard delete — cancellation is `status='discarded'` (already excluded from
|
||||
exports).
|
||||
|
||||
## B. Admin UI
|
||||
|
||||
- **`components/Sidebar.tsx`** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems`; drop
|
||||
`'Handmatig'` from the muted `soonItems` (now built → leaves `['Rapporten', 'Gebruikers']`).
|
||||
- **`App.tsx`** — add `<Route path="/sessies" element={<Sessions />} />`.
|
||||
- **`screens/Sessions.tsx`** — lists all sessions via `useAllSessions` (`GET /api/admin/sessions`,
|
||||
newest first), a status filter (alle / actief / voltooid / geannuleerd), and a
|
||||
`+ Nieuwe registratie` button. Each row: worker · activity · type · worked (+ pauze) · date.
|
||||
**Active** rows show `[Stop]` `[Annuleer]`; **all** rows show ✎ edit.
|
||||
- **`components/SessionForm.tsx`** — shared create/edit form: worker picker (create only, from
|
||||
`useAdminUsers`), activity dropdown, insole-type, pair count, start/end datetime-local, paused,
|
||||
status (edit only), notes, and a live **"gewerkt"** preview. Submits create or update.
|
||||
- **`api/admin-sessions.ts`** — add `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession` (all invalidate the
|
||||
`['admin','sessions']` query family; keep the existing `useActiveSessions`).
|
||||
- **`screens/Live.tsx`** — add `[Stop]` `[Annuleer]` to each `LiveCard`, wired to the admin
|
||||
stop/discard hooks (invalidate the active query).
|
||||
|
||||
## C. Worker convergence (`apps/worker`)
|
||||
|
||||
- **`api/sessions.ts`** — give `useActiveSessions` a `refetchInterval: 15000` (poll), plus the
|
||||
default refetch-on-window-focus.
|
||||
- **`screens/Stopwatch.tsx`** — extend the active-session effect to **reconcile**: if a session is
|
||||
running locally (`sessionId` set) but the latest `activeSessionsQuery.data` no longer contains a
|
||||
matching **active** session for it, the session was stopped/cancelled elsewhere → reset the
|
||||
timer and surface a brief notice **"Deze sessie is door de beheerder gestopt."** Also: treat a
|
||||
**409** from the worker's own stop/discard as already-closed → reset locally instead of erroring.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Worker reconciliation notice is transient (dismissible / auto-clears on next start).
|
||||
- Admin form: inline validation errors mirror the API 400s (end before start, count < 1).
|
||||
- Stop/discard on a non-active session → 409; admin UI refetches and the row reflects truth.
|
||||
|
||||
## Testing
|
||||
|
||||
- **API** (`admin.test.ts`): create computes duration + `source='manual'`; edit recomputes and
|
||||
rejects `end<start`; stop/discard act on **another** user's session; all 401/403 gated;
|
||||
`GET /api/admin/users` returns the roster.
|
||||
- **Admin:** Sessions list renders + status filter; create form posts the right body; edit
|
||||
prefills + PUTs; Live `Stop`/`Annuleer` fire the right mutations.
|
||||
- **Worker:** when the active query returns without a locally-running session, the stopwatch
|
||||
resets + shows the notice; a 409 on stop resets instead of erroring.
|
||||
|
||||
## Out of scope (later 3b cycles)
|
||||
|
||||
- Aggregated/on-screen **reporting** + all-users filtered CSV (reports cycle).
|
||||
- Full **user management** UI — create/role/deactivate via `/api/auth/admin/*` (user-mgmt cycle);
|
||||
this cycle only *reads* the roster (`GET /api/admin/users`) for the picker.
|
||||
- Real-time push (SSE) — polling is sufficient at this scale.
|
||||
|
||||
## Build approach
|
||||
|
||||
spec → `writing-plans` → one **Workflow** (~7 TDD tasks), commit per task, final verify. Tracked
|
||||
as a Plane epic.
|
||||
Reference in New Issue
Block a user