16 KiB
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.jsonnow usestrailingComma: "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 inapps/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/sharedhas 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: 0fails). -
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 withauthToken(app, email, 'admin')per the existing pattern):GET /api/admin/users(admin) returns objects withid/name/email; 403 for a worker.POST /api/admin/sessions(admin) with a worker's id + activity +start_time/end_time1h apart +paused_seconds: 600→ 201/200 withsource==='manual',status==='completed',duration_seconds === 3000(3600−600).POSTwithend < start→ 400; unknownuser_id/activity_id→ 404/400.PUT /api/admin/sessions/:idedits a session and recomputes duration;end<start→ 400.POST …/:id/stopon 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, descfrom drizzle-orm,CreateManualSessionInput,AdminUpdateSessionInputfrom@solelog/shared,activities,user,workSessionsfrom 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; ifend_timepresent validateend ≥ startandpaused ≤ span, setdurationSecondsviacomputeDuration, elsedurationSeconds = nulland (ifstatus==='active') keep it open. Set the editable fields; returntoWorkSession. - stop
:id— load active row; ifpausedAt, fold the open span intopausedSeconds;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:idPUT so they don't collide (Hono matches/api/admin/sessions/:id/stopdistinctly 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):Sessionsrenders a row per session (worker + activity + worked); the status filter narrows the list (e.g. selecting "actief" shows only active);Stop/Annuleerappear 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(keepuseActiveSessions):
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' }tonavItemsinSidebar.tsx; remove'Handmatig'fromsoonItems(leaves['Rapporten', 'Gebruikers']). InApp.tsximportSessionsand add<Route path="/sessies" element={<Sessions />} />. - Step 5: Screen —
Sessions.tsx: title "Sessies", a status<select>filter (alle/actief/voltooid/geannuleerd),+ Nieuwe registratiebutton (opens the form from Task 4 — for now a stub/onCreateprop or local state placeholder), a table/list of rows with worked time (reuseformatTimefromlib/elapsed; showPauze …whenpaused_seconds>0), ✎ edit and (active only)Stop/Annuleerbuttons wired to the hooks. Loading/error/empty states in Dutch. - Step 6: Styles — add
.sessions-*/table/action-button CSS tostyles.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 (fromuseAdminUsers) and submitting postsCreateManualSessionInputwith 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>(fromuseActivities), insole-type toggles, pair-count stepper, start/enddatetime-local, paused (minutes or H:MM), status<select>(edit only), notes<textarea>, and a derived "gewerkt" line. Build the ISOstart_time/end_timefrom the datetime-local values. Submit viauseCreateManualSession/useUpdateSession; close on success. Inline error on 400. - Step 4: Wire into Sessions —
+ Nieuwe registratieopens 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
StopandAnnuleer; clickingStopcallsPOST /api/admin/sessions/:id/stop,Annuleercalls…/discard. - Step 2: Run — fail.
- Step 3: Implement — add the two buttons to
LiveCard, wired touseAdminStopSession/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, addrefetchInterval: 15000touseActiveSessions. - Step 4: Reconcile — in
Stopwatch.tsx, extend the active-session effect: whenactiveSessionsQuery.datais present and the worker has a localsessionIdthat is not in the returned active list, callresetTimer()and set a transientstoppedByAdminnotice (cleared on next start). Keep the existing "adopt an active session when idle" recovery. - Step 5: 409 handling — give
useStopSession/useDiscardSession(or thehandleStop/handleDiscardcallers) an error path: if the error isApiErrorwithstatus === 409, callresetTimer()(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 oxlintclean;npx oxfmton 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),PUTedit,stop/discardon 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/discardliteral subpaths vs the bare:idPUT 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).