From 9d215760146eb95e75e9b9125413250bede64b3c Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 22:25:28 +0200 Subject: [PATCH] docs: spec + plan for phase 3b.1 manual session entry/edit + admin stop/fix --- .../2026-06-17-phase-3b1-manual-sessions.md | 295 ++++++++++++++++++ ...-06-17-phase-3b1-manual-sessions-design.md | 112 +++++++ 2 files changed, 407 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md create mode 100644 docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md diff --git a/docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md b/docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md new file mode 100644 index 0000000..01ba044 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md @@ -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 { + 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('/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 `} />`. +- [ ] **Step 5: Screen** — `Sessions.tsx`: title "Sessies", a status `` (create only), activity `` (edit only), notes `