# 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: Mappers** — `toWorkSession`: 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 migration** — `yarn 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 + typecheck** — `yarn workspace @solelog/api test` + `typecheck` green. (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 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: ```ts 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: ```ts 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: 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_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`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): ```ts 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 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`/`useResumeSession` → `apiFetch('/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: 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 > 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: 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_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 fix** — `AuthContext.signOut` gets `window.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" (↑) 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 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 oxlint` clean; `npx oxfmt` on 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`, 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: Commit** — `docs: 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.