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

16 KiB
Raw Permalink Blame History

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: Commitfeat(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/discardstatus==='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).
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: Commitfeat(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):
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: ScreenSessions.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: Commitfeat(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: Commitfeat(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: Commitfeat(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: Commitfeat(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/formatnpx oxlint clean; npx oxfmt on changed files only.
  • Step 2: Full greenyarn 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: Commitdocs: 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).