# 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 `