Files
solelog/docs/superpowers/plans/2026-06-17-pause-reorder-loginfix.md

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:generate to emit 0003 from the edited schema, then yarn 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.request and a temp DB — fine.
  • Status stays active|completed|discarded; a paused session is active + paused_at set.

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:

  • WorkSession gains paused_seconds: number, paused_at: string | null.

  • Activity gains sort_order: number.

  • ReorderActivitiesInput = z.object({ ids: z.array(z.number().int()).min(1) }).

  • toWorkSession returns the paused fields; toActivity returns sort_order.

  • Step 1: Failing unit test for toWorkSession: given a row with pausedSeconds: 120, pausedAt: null, the result has paused_seconds === 120 and paused_at === null; with a pausedAt Date, paused_at is its ISO string. (Build a row literal of typeof workSessions.$inferSelect shape.)

  • Step 2: Run — fail (paused_seconds undefined / type error).

  • Step 3: Shared contracts — add the two WorkSession fields (place after duration_seconds), Activity.sort_order (after created_at is fine), and ReorderActivitiesInput.

  • Step 4: Schema — in work_sessions add pausedSeconds: integer('paused_seconds').notNull().default(0) and pausedAt: integer('paused_at', { mode: 'timestamp_ms' }); in activities add sortOrder: integer('sort_order').notNull().default(0).

  • Step 5: MapperstoWorkSession: add paused_seconds: row.pausedSeconds ?? 0, paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null. toActivity (in activities.ts): add sort_order: row.sortOrder ?? 0.

  • Step 6: Generate + apply migrationyarn workspace @solelog/api db:generate (creates 0003_*.sql), then yarn workspace @solelog/api db:migrate. Confirm the SQL has three ALTER TABLE … ADD … columns.

  • Step 7: Run tests + typecheckyarn workspace @solelog/api test + typecheck green. (Existing tests must still pass; the new fields are additive.)

  • Step 8: Commitfeat(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 helpers createTestUser/ bearer/seedActivity, start a session, then drive pause/resume/stop via app.request):
    • pause sets paused_at non-null, status still active; pausing an already-paused → 409.
    • resume clears paused_at and increases paused_seconds; resuming a running → 409.
    • stop math: start, (simulate elapsed), pause then stop → duration_seconds excludes the paused span and equals worked; paused_seconds > 0. (To make timing deterministic, assert duration_seconds + paused_seconds ≈ wall-clock and paused_seconds > 0 rather than exact seconds, or stub times — keep it robust.)
    • In export.test.ts: the CSV header includes Paused Duration and a paused session's row carries the formatted paused value.
  • Step 2: Run — fail (routes 404 / header missing).
  • Step 3: Implement pause — mirror the stop handler'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); set pausedSeconds: row.pausedSeconds + Math.round((Date.now() - new Date(row.pausedAt).getTime())/1000) and pausedAt: 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') and formatDuration(session.pausedSeconds ?? 0) to each data row in the matching position.
  • Step 7: Run tests + typecheck — green.
  • Step 8: Commitfeat(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_order then name; PUT /api/activities/reorder with { ids: [b, a] } (admin token) sets their sort_order so a later GET returns them in that order; reorder as a worker → 403; unknown id → 400; new activity created via POST gets a sort_order greater 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 with sortOrder: max + 1 (import sql from 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: Commitfeat(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 calls POST /api/sessions/:id/pause; tapping while paused calls …/resume. Recovery: when the active session query returns a session with paused_at set, the UI mounts in the paused state (status pill shows the resume hint).
  • Step 2: Run — fail.
  • Step 3: Add hooks in api/sessions.ts mirroring useStopSession: usePauseSession/useResumeSessionapiFetch<WorkSession>('/api/sessions/${id}/pause'|'/resume', { method: 'POST' }), onSuccess invalidate ['sessions'].
  • Step 4: Wire handleTapDisplay — on pause call pauseSession.mutate(sessionId); on resume call resumeSession.mutate(sessionId). Keep the optimistic local isPaused/pausedMs bookkeeping 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, seed pausedMs from session.paused_seconds * 1000, and if paused_at set, seed pauseStartedMs from it so the frozen clock matches.
  • Step 6: Run worker tests + typecheck + build — green.
  • Step 7: Commitfeat(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 > 0 renders a "Pauze …" label on its History card (none when 0). Login-tab: after signOut, window.location.pathname is / (set pathname to /account first via history.replaceState).
  • Step 2: Run — fail.
  • Step 3: History — in SessionCard, when session.paused_seconds > 0, render an extra grey pill/line Pauze {formatDuration(session.paused_seconds)} (reuse the local formatDuration). The existing duration pill stays = worked time.
  • Step 4: Login-tab fix — in AuthContext.signOut, before clearToken()/setIsAuthed(false), add window.history.replaceState(null, '', '/').
  • Step 5: Run worker tests + typecheck + build — green.
  • Step 6: Commitfix(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_at set renders a "Gepauzeerd" badge and its timer is frozen (does not depend on the 1s tick); paused_seconds > 0 shows a paused total. After admin signOut, window.location.pathname is /.
  • 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); When paused_at is set, render a "Gepauzeerd" badge (amber) and show Pauze {formatTime(session.paused_seconds)}.
  • Step 4: Admin login-tab fixAuthContext.signOut gets window.history.replaceState(null, '', '/') before clearing auth.
  • Step 5: Run admin tests + typecheck + build — green.
  • Step 6: Commitfeat(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" (↑) calls PUT /api/activities/reorder with { 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 useReorderActivities in api/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: Commitfeat(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/formatnpx oxlint clean; npx oxfmt on changed files only.
  • Step 2: Full greenyarn 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, confirm duration_seconds excludes paused and paused_seconds > 0; as admin: PUT /api/activities/reorder and confirm GET order; 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: Commitdocs: pause-accounting + reorder session log.

Self-Review notes

  • reorder route registered before :id routes (Task 3) — else reorder parses 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/sessions via toWorkSession (Task 1) so 3b inherits them.