Files
solelog/docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md

296 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).