docs: spec + plan for phase 3b.1 manual session entry/edit + admin stop/fix

This commit is contained in:
Bas van Rossem
2026-06-17 22:25:28 +02:00
parent 70ac27ec8e
commit 9d21576014
2 changed files with 407 additions and 0 deletions

View 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` (3600600).
- `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).