docs: spec + plan for pause accounting, reorder, login-tab fix

This commit is contained in:
Bas van Rossem
2026-06-17 20:43:19 +02:00
parent 660cbe50c8
commit cbfcb4a414
2 changed files with 436 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
# 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<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):
```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<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: 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 <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 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.

View File

@@ -0,0 +1,116 @@
# Pause Accounting + Reorderable Handelingen + Login-Tab Fix — Design
- **Created:** 2026-06-17
- **Status:** Approved (brainstorming) — ready for implementation plan
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
- **Touches:** `packages/shared`, `apps/api`, `apps/worker`, `apps/admin`
## Goal
Four maintainer-reported items, grouped because three of them share one root change
(server-authoritative pause):
1. **Admin shows paused sessions as running.** Pause is client-only today, so the admin
live view (filters `status='active'`) can't tell paused from running.
2. **Reorderable handelingen.** Admin sets the order; the worker picker follows it.
3. **Saved duration ignores pause.** Stop stores wall-clock `(end start)`, but the
stopwatch displays *worked* time. Save worked time, and store paused time too
("gewerkt 1:00 · pauze 0:20").
4. **Wrong default tab after re-login.** Logout happens on the Account tab, leaving the URL
at `/account`; the next login re-mounts there instead of Stopwatch.
## Root-cause findings (from current code)
- `apps/worker/src/screens/Stopwatch.tsx`: pause is **purely client-side** (`pausedMs`
accumulator); the server session stays `status='active'`, `paused` unknown server-side.
- `apps/api/src/routes/sessions.ts` stop: `durationSeconds = round((end start)/1000)`
wall-clock, includes paused time. The worker's displayed elapsed already excludes pause,
hence the mismatch (#3).
- `apps/api/src/db/schema.ts`: `work_sessions` has no pause columns; `activities` has no
`sort_order`.
- Worker logout sits on the Account tab → `BrowserRouter` re-mounts at `/account` (#4).
## A. Server-authoritative pause (#1 + #3)
**Data model** (migration `0003`, via `db:generate`):
- `work_sessions.paused_seconds``integer NOT NULL DEFAULT 0` (accumulated paused secs).
- `work_sessions.paused_at``integer timestamp_ms NULL` (set while paused; null = running).
- Status stays `active | completed | discarded`; a paused session is still **active** with
`paused_at` set, so no existing status filter changes.
**Shared contract** `WorkSession`: add `paused_seconds: number` and
`paused_at: string | null` (ISO). `toWorkSession` maps both.
**Endpoints** (`sessions.ts`, user-scoped exactly like stop/discard):
- `POST /api/sessions/:id/pause` — active + not already paused → set `paused_at = now`;
else 409. Returns the updated `WorkSession`.
- `POST /api/sessions/:id/resume` — paused → `paused_seconds += round((now paused_at)/1000)`,
clear `paused_at`; else 409.
- `POST /api/sessions/:id/stop`**changed**: if `paused_at` set, fold the open span into
`paused_seconds` first; then `duration_seconds = round((end start)/1000) paused_seconds`
(clamp ≥ 0); set `paused_at = null`, status `completed`. Stores worked + paused.
**Worker `Stopwatch.tsx`:** pause/resume call the new endpoints (`usePauseSession` /
`useResumeSession`); keep the local clock for snappy feel, server is source of truth.
Recovery-on-load restores `paused_at`/`paused_seconds` (today it forces running). Displayed
elapsed remains worked time; `isPaused` derives from `paused_at`.
## B. Paused-time display (#3 display)
- **Worker History card:** "Gewerkt H:MM:SS" + (if `paused_seconds > 0`) a grey
"Pauze H:MM:SS".
- **Admin Live + admin sessions views:** a **"Gepauzeerd"** badge when `paused_at` set; the
elapsed timer **freezes** while paused (worked = `(paused_at start) paused_seconds`),
and paused total shown.
- **CSV export** (`/api/export`): new "Paused Duration" column (`formatDuration(paused_seconds)`);
the existing "Total Duration" stays = worked.
## C. Reorderable handelingen (#2) — arrow buttons
**Data model** (same `0003`): `activities.sort_order``integer NOT NULL DEFAULT 0`.
Existing rows get 0; `GET` orders by `(sort_order ASC, name ASC)` so current alphabetical
order is preserved until an admin reorders. `Activity` contract gains `sort_order: number`.
**Endpoints** (`activities.ts`):
- `GET /api/activities` orders by `(sort_order, name)`.
- `PUT /api/activities/reorder` (admin-gated): body `{ ids: number[] }` (the full ordered
id list) → assigns `sort_order = index`. Validates the ids; returns the reordered list.
- `POST /api/activities` sets new rows to `sort_order = max(sort_order)+1` (append).
**Admin Activities screen:** each row gets ↑/↓ buttons (disabled at the ends) that swap with
the neighbour and fire the reorder mutation (invalidate `['activities']`). No new dependency.
**Worker picker:** inherits the order from `GET` — no worker UI change.
## D. Login-tab fix (#4)
Worker `AuthContext.signOut` resets the path to `/` (`window.history.replaceState(null, '', '/')`)
before clearing auth, so the next authed mount starts on Stopwatch. The **admin**
`AuthContext.signOut` gets the same one-liner (lands on Live) for consistency.
## Error handling
- pause/resume/stop on a non-owned or wrong-state session → 404/409 as today; client surfaces
nothing intrusive (the active-session query reconciles).
- reorder with unknown/missing ids → 400.
- Clamp negative worked durations to 0 (guards clock skew / odd pause data).
## Testing
- **API:** pause sets `paused_at`; resume accumulates; stop excludes paused and folds an open
pause span; reorder assigns `sort_order` by index and `GET` returns ordered; create appends.
- **Worker:** Stopwatch calls pause/resume; recovery restores paused state; History renders the
pauze line when `paused_seconds > 0`; after `signOut` the path is `/` so login shows Stopwatch.
- **Admin:** Live shows "Gepauzeerd" + frozen timer; Activities ↑/↓ fire the reorder mutation
with the swapped order; paused total shown in session views.
## Out of scope
- Admin pausing/resuming/stopping *another worker's* session (that's the Phase 3b
manual-entry/admin-control work). Pause remains a worker action here.
- Drag-and-drop reordering (arrows chosen; DnD would add dnd-kit against the dependency-light
goal).
## Build approach
One spec → `writing-plans`**one Workflow** (per the maintainer's standing preference),
~8 TDD tasks, commit per task, final verify pass. Tracked as a Plane epic.