18 KiB
Pause Accounting + Reorderable Handelingen + Login-Tab Fix — Implementation Plan
For agentic workers: Implement task-by-task with TDD. Steps use checkbox (
- [ ]). Spec:docs/superpowers/specs/2026-06-17-pause-reorder-loginfix-design.md.
Goal: Make pause server-authoritative (admin sees "Gepauzeerd"; stop saves worked time + stores paused), let admins reorder handelingen with ↑/↓ arrows (worker picker follows), and fix the worker landing on Account instead of Stopwatch after re-login.
Architecture: SQLite columns + new user-scoped pause/resume endpoints + changed stop math;
activities.sort_order + an admin-gated reorder endpoint; worker Stopwatch switches to
server pause; small UI additions in worker History + admin Live; one-line router reset on
signOut in both clients.
Tech Stack: Hono + Drizzle + libsql (api), Vite+React+react-query (worker, admin),
@solelog/shared zod contracts, 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, no remote, no amend of earlier commits); stage only your task's files.
- oxlint + oxfmt on changed files only (never repo-wide). Style: 2-space, single quotes, semicolons, width 100, ES5 trailing commas — no trailing comma after the last function param / last call arg; DO use them in multiline arrays/objects.
- Dutch UI strings.
- Migrations are generated, not hand-written: run
yarn workspace @solelog/api db:generateto emit0003from the edited schema, thenyarn workspace @solelog/api db:migrate. Do not hand-author the SQL. - Windows libsql lock trap: if you start the API server, kill the process tree afterward
and free port 3000. Tests use in-process
app.requestand a temp DB — fine. - Status stays
active|completed|discarded; a paused session is active +paused_atset.
File Structure
packages/shared/src/index.ts MODIFY WorkSession +paused_seconds/+paused_at; Activity +sort_order; ReorderActivitiesInput
apps/api/src/db/schema.ts MODIFY work_sessions +paused_seconds/+paused_at; activities +sort_order
apps/api/drizzle/0003_*.sql CREATE (generated)
apps/api/src/lib/work-session.ts MODIFY map paused fields
apps/api/src/routes/sessions.ts MODIFY pause/resume endpoints; stop math; CSV paused column
apps/api/src/routes/activities.ts MODIFY order by (sort_order,name); reorder endpoint; append on create
apps/api/test/{sessions,activities,export}.test.ts MODIFY
apps/worker/src/api/sessions.ts MODIFY usePauseSession/useResumeSession
apps/worker/src/screens/Stopwatch.tsx MODIFY server pause + recovery
apps/worker/src/screens/History.tsx MODIFY paused line
apps/worker/src/auth/AuthContext.tsx MODIFY reset path to / on signOut
apps/admin/src/api/admin-sessions.ts MODIFY (consumes paused fields — type only)
apps/admin/src/screens/Live.tsx MODIFY Gepauzeerd badge + frozen timer + paused total
apps/admin/src/auth/AuthContext.tsx MODIFY reset path to / on signOut
apps/admin/src/api/activities.ts MODIFY useReorderActivities
apps/admin/src/screens/Activities.tsx MODIFY ↑/↓ arrows
Task 1: Contracts + schema + migration + mappers
Files: packages/shared/src/index.ts, apps/api/src/db/schema.ts,
apps/api/src/lib/work-session.ts, apps/api/src/routes/activities.ts (toActivity),
apps/api/drizzle/* (generated), test apps/api/test/work-session.test.ts (create) or extend
schema.test.ts.
Interfaces — Produces:
-
WorkSessiongainspaused_seconds: number,paused_at: string | null. -
Activitygainssort_order: number. -
ReorderActivitiesInput = z.object({ ids: z.array(z.number().int()).min(1) }). -
toWorkSessionreturns the paused fields;toActivityreturnssort_order. -
Step 1: Failing unit test for
toWorkSession: given a row withpausedSeconds: 120, pausedAt: null, the result haspaused_seconds === 120andpaused_at === null; with apausedAtDate,paused_atis its ISO string. (Build a row literal oftypeof workSessions.$inferSelectshape.) -
Step 2: Run — fail (
paused_secondsundefined / type error). -
Step 3: Shared contracts — add the two
WorkSessionfields (place afterduration_seconds),Activity.sort_order(aftercreated_atis fine), andReorderActivitiesInput. -
Step 4: Schema — in
work_sessionsaddpausedSeconds: integer('paused_seconds').notNull().default(0)andpausedAt: integer('paused_at', { mode: 'timestamp_ms' }); inactivitiesaddsortOrder: integer('sort_order').notNull().default(0). -
Step 5: Mappers —
toWorkSession: addpaused_seconds: row.pausedSeconds ?? 0,paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null.toActivity(inactivities.ts): addsort_order: row.sortOrder ?? 0. -
Step 6: Generate + apply migration —
yarn workspace @solelog/api db:generate(creates0003_*.sql), thenyarn workspace @solelog/api db:migrate. Confirm the SQL has threeALTER TABLE … ADD …columns. -
Step 7: Run tests + typecheck —
yarn workspace @solelog/api test+typecheckgreen. (Existing tests must still pass; the new fields are additive.) -
Step 8: Commit —
feat(shared,api): add pause + sort_order columns and contracts.
Task 2: Backend — pause/resume endpoints, stop math, CSV paused column
Files: apps/api/src/routes/sessions.ts; tests apps/api/test/sessions.test.ts,
apps/api/test/export.test.ts.
Interfaces — Consumes Task 1's columns/mappers. Produces POST /api/sessions/:id/pause,
POST /api/sessions/:id/resume; changed stop; /api/export Paused column.
- Step 1: Failing tests in
sessions.test.ts(use the helperscreateTestUser/bearer/seedActivity, start a session, then drive pause/resume/stop viaapp.request):- pause sets
paused_atnon-null, status stillactive; pausing an already-paused → 409. - resume clears
paused_atand increasespaused_seconds; resuming a running → 409. - stop math: start, (simulate elapsed), pause then stop →
duration_secondsexcludes the paused span and equals worked;paused_seconds > 0. (To make timing deterministic, assertduration_seconds + paused_seconds ≈ wall-clockandpaused_seconds > 0rather than exact seconds, or stub times — keep it robust.) - In
export.test.ts: the CSV header includesPaused Durationand a paused session's row carries the formatted paused value.
- pause sets
- Step 2: Run — fail (routes 404 / header missing).
- Step 3: Implement pause — mirror the
stophandler's ownership/lookup:
sessionsRoutes.post('/api/sessions/:id/pause', async (c) => {
const sessionUser = await getSessionUser(c);
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) return c.json({ error: 'Session not found' }, 404);
const [row] = await db
.select()
.from(workSessions)
.where(and(eq(workSessions.id, id), eq(workSessions.userId, sessionUser.id)));
if (!row) return c.json({ error: 'Session not found' }, 404);
if (row.status !== 'active') return c.json({ error: 'Session not active' }, 409);
if (row.pausedAt) return c.json({ error: 'Already paused' }, 409);
const [updated] = await db
.update(workSessions)
.set({ pausedAt: new Date() })
.where(eq(workSessions.id, id))
.returning();
return c.json(toWorkSession(updated));
});
- Step 4: Implement resume — same guards; require
row.pausedAt(else 409); setpausedSeconds: row.pausedSeconds + Math.round((Date.now() - new Date(row.pausedAt).getTime())/1000)andpausedAt: null. - Step 5: Change stop — after loading the active row, fold any open pause span:
const now = Date.now();
const extraPaused = row.pausedAt ? Math.round((now - new Date(row.pausedAt).getTime()) / 1000) : 0;
const pausedSeconds = (row.pausedSeconds ?? 0) + extraPaused;
const endTime = new Date(now);
const wall = Math.round((now - new Date(row.startTime).getTime()) / 1000);
const durationSeconds = Math.max(0, wall - pausedSeconds);
// .set({ endTime, durationSeconds, pausedSeconds, pausedAt: null, status: 'completed' })
- Step 6: CSV paused column — in
/api/export, add'Paused Duration'to the header (after'Total Duration') andformatDuration(session.pausedSeconds ?? 0)to each data row in the matching position. - Step 7: Run tests + typecheck — green.
- Step 8: Commit —
feat(api): server-authoritative pause/resume + worked-time stop + CSV paused.
Task 3: Backend — activities ordering + reorder + append-on-create
Files: apps/api/src/routes/activities.ts; test apps/api/test/activities.test.ts.
Interfaces — Produces PUT /api/activities/reorder (admin-gated); ordered GET;
POST appends.
- Step 1: Failing tests: GET returns activities ordered by
sort_orderthen name;PUT /api/activities/reorderwith{ ids: [b, a] }(admin token) sets theirsort_orderso a later GET returns them in that order; reorder as a worker → 403; unknown id → 400; new activity created via POST gets asort_ordergreater than existing ones. - Step 2: Run — fail.
- Step 3: Order GET — change
.orderBy(asc(activities.name))to.orderBy(asc(activities.sortOrder), asc(activities.name)). - Step 4: Append on create — in POST, compute next order:
const [{ max }] = await db.select({ max: sql<number>COALESCE(MAX(${activities.sortOrder}), -1)}).from(activities);then insert withsortOrder: max + 1(importsqlfrom drizzle-orm). - Step 5: Reorder endpoint (admin-gated like POST/PUT/DELETE):
activitiesRoutes.put('/api/activities/reorder', async (c) => {
const sessionUser = await getSessionUser(c);
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
const parsed = ReorderActivitiesInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const existing = await db.select({ id: activities.id }).from(activities);
const known = new Set(existing.map((r) => r.id));
if (parsed.data.ids.length !== known.size || parsed.data.ids.some((id) => !known.has(id)))
return c.json({ error: 'Invalid input' }, 400);
for (let i = 0; i < parsed.data.ids.length; i++) {
await db.update(activities).set({ sortOrder: i }).where(eq(activities.id, parsed.data.ids[i]));
}
const rows = await db.select().from(activities).orderBy(asc(activities.sortOrder), asc(activities.name));
return c.json(rows.map(toActivity));
});
Register it BEFORE /api/activities/:id routes so reorder isn't captured as an :id.
- Step 6: Run tests + typecheck — green.
- Step 7: Commit —
feat(api): orderable activities + admin reorder endpoint.
Task 4: Worker — Stopwatch uses server pause + recovery
Files: apps/worker/src/api/sessions.ts, apps/worker/src/screens/Stopwatch.tsx;
tests apps/worker/src/screens/Stopwatch.test.tsx.
Interfaces — Produces usePauseSession(), useResumeSession() (mutations invalidating
['sessions']).
- Step 1: Failing tests (mock
apiFetch): tapping the display while running callsPOST /api/sessions/:id/pause; tapping while paused calls…/resume. Recovery: when the active session query returns a session withpaused_atset, the UI mounts in the paused state (status pill shows the resume hint). - Step 2: Run — fail.
- Step 3: Add hooks in
api/sessions.tsmirroringuseStopSession:usePauseSession/useResumeSession→apiFetch<WorkSession>('/api/sessions/${id}/pause'|'/resume', { method: 'POST' }),onSuccessinvalidate['sessions']. - Step 4: Wire
handleTapDisplay— on pause callpauseSession.mutate(sessionId); on resume callresumeSession.mutate(sessionId). Keep the optimistic localisPaused/pausedMsbookkeeping for the live clock, but the mutation is the source of truth. - Step 5: Recovery — in the active-session recovery effect, set
isPaused = !!session.paused_at, seedpausedMsfromsession.paused_seconds * 1000, and ifpaused_atset, seedpauseStartedMsfrom it so the frozen clock matches. - Step 6: Run worker tests + typecheck + build — green.
- Step 7: Commit —
feat(worker): server-authoritative pause/resume on the stopwatch.
Task 5: Worker — History paused line + login-tab fix
Files: apps/worker/src/screens/History.tsx, apps/worker/src/auth/AuthContext.tsx;
tests apps/worker/src/screens/History.test.tsx, apps/worker/src/auth/* or
apps/worker/src/App.test.tsx.
- Step 1: Failing tests: a session with
paused_seconds > 0renders a "Pauze …" label on its History card (none when 0). Login-tab: aftersignOut,window.location.pathnameis/(set pathname to/accountfirst viahistory.replaceState). - Step 2: Run — fail.
- Step 3: History — in
SessionCard, whensession.paused_seconds > 0, render an extra grey pill/linePauze {formatDuration(session.paused_seconds)}(reuse the localformatDuration). The existing duration pill stays = worked time. - Step 4: Login-tab fix — in
AuthContext.signOut, beforeclearToken()/setIsAuthed(false), addwindow.history.replaceState(null, '', '/'). - Step 5: Run worker tests + typecheck + build — green.
- Step 6: Commit —
fix(worker): show paused time in history; reset to stopwatch on logout.
Task 6: Admin — Live paused state + login-tab fix
Files: apps/admin/src/screens/Live.tsx, apps/admin/src/auth/AuthContext.tsx;
tests apps/admin/src/screens/Live.test.tsx.
- Step 1: Failing tests: an active session with
paused_atset renders a "Gepauzeerd" badge and its timer is frozen (does not depend on the 1s tick);paused_seconds > 0shows a paused total. After adminsignOut,window.location.pathnameis/. - Step 2: Run — fail.
- Step 3: Live freeze + badge — compute elapsed as
const base = session.paused_at ? Date.parse(session.paused_at) : now;const worked = Math.max(0, Math.floor((base - Date.parse(session.start_time)) / 1000) - session.paused_seconds);Whenpaused_atis set, render a "Gepauzeerd" badge (amber) and showPauze {formatTime(session.paused_seconds)}. - Step 4: Admin login-tab fix —
AuthContext.signOutgetswindow.history.replaceState(null, '', '/')before clearing auth. - Step 5: Run admin tests + typecheck + build — green.
- Step 6: Commit —
feat(admin): show paused sessions in live view; reset to live on logout.
Task 7: Admin — Activities ↑/↓ reorder
Files: apps/admin/src/api/activities.ts, apps/admin/src/screens/Activities.tsx;
test apps/admin/src/screens/Activities.test.tsx.
Interfaces — Produces useReorderActivities() → PUT /api/activities/reorder
({ ids }), invalidates ['activities'].
- Step 1: Failing test (mock
apiFetch): given activities[A, B, C], clicking B's "omhoog" (↑) callsPUT /api/activities/reorderwith{ ids: [B, A, C] }; clicking the first row's ↑ is disabled (no call); last row's ↓ disabled. - Step 2: Run — fail.
- Step 3: Hook — add
useReorderActivitiesinapi/activities.ts:mutationFn: (ids: number[]) => apiFetch('/api/activities/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }), invalidate['activities']. - Step 4: Arrows UI — in the (non-editing) activity row, add ↑/↓ buttons
(aria-labels
Verplaats <naam> omhoog/omlaag), disabled at the ends. On click, build the reordered id array by swapping with the neighbour and call the mutation. Keep the existing edit/delete buttons. - Step 5: Run admin tests + typecheck + build — green.
- Step 6: Commit —
feat(admin): reorder handelingen with up/down arrows.
Task 8: Docs, lint, verification
Files: docs/roadmap.md (note pause accounting + reorder landed), apps/admin/README.md
/ apps/worker/README.md (if behaviour notes belong), docs/sessions/2026-06-17-pause-reorder-loginfix.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/worker typecheck && test;yarn workspace @solelog/admin typecheck && test && build. - Step 3: Live smoke (preferred) — start API, seed; as a worker: start a session,
POST …/pause,…/resume,…/stop, confirmduration_secondsexcludes paused andpaused_seconds > 0; as admin:PUT /api/activities/reorderand confirmGETorder; then kill the server tree + free port 3000. - Step 4: Docs — session log (goal/work/verification/outcome), and a one-line roadmap
note. Keep the main note untouched (no Obsidian here — SoleLog uses
docs/+ Plane). - Step 5: Commit —
docs: pause-accounting + reorder session log.
Self-Review notes
reorderroute registered before:idroutes (Task 3) — elsereorderparses as an id.- Stop math folds an open pause span before subtracting (Task 2) — covers stop-while-paused.
- Worker keeps its local clock but the server now owns pause truth (Task 4); recovery seeds
from
paused_at/paused_seconds. - Admin "session views" today = the Live screen only (the all-sessions list is Phase 3b); the
paused fields already flow through
/api/admin/sessionsviatoWorkSession(Task 1) so 3b inherits them.