# Phase 1 — Worker Timing Implementation Plan (Web Client) > **For agentic workers:** REQUIRED SUB-SKILL — use `superpowers:test-driven-development` for every > task and `superpowers:subagent-driven-development` (or `superpowers:executing-plans`) to drive the > plan task-by-task. Steps use checkbox (`- [ ]`) syntax. Strict TDD: write the test, watch it fail > for the right reason, then write the code to make it pass. **Never weaken, skip, or delete a test to > make it pass.** Run REAL commands; never fabricate output. > > **⚠ This plan REPLACES the earlier Expo/React-Native plan that previously lived at this path.** > The client in Phase 1 is a **Vite + React + TypeScript single-page web app (PWA)**. There is > **NO Expo, NO React Native, NO react-native-web, NO ngrok / tunnelling** anywhere in this phase. --- ## Goal Deliver "Worker timing" end-to-end as a backend plus a **web** client: - **Backend (`apps/api`)** gains domain tables (`activities`, `work_sessions`), a **user-scoped** REST surface to manage activities and to start / stop / discard **server-authoritative** work sessions, a history list, an "active session" recovery endpoint, and a CSV export — all behind the existing better-auth bearer session. Request/response shapes are zod schemas in `packages/shared`. - **Client (`apps/worker`, package `@solelog/worker`)** is a fresh, lean **Vite + React + TS SPA**, installable as a PWA, that logs in (email+password → bearer token in `localStorage`), attaches `Authorization: Bearer ` to every API call, and reproduces the three Dutch screens (Stopwatch / Geschiedenis / Instellingen) against this backend. **Done when:** a worker can pick an activity, start/stop a server-side session, see history, and export CSV; every backend endpoint is user-scoped and covered by vitest (401 without token, ownership scoping, start→stop lifecycle, discard, CSV); the worker SPA builds (`vite build`), typechecks (`tsc --noEmit`), passes its vitest suite, and runs at `http://localhost:5173` in **any** browser (desktop, or a phone on the same LAN via the PC's IP) with no tunnel. ## Architecture The backend is the single owner of auth + DB (roadmap Decision A). The worker SPA is a pure client: it holds no business logic beyond UI state and the live elapsed-timer display. **start / stop / discard are server calls**, so an open session survives a browser/phone restart and is recovered on load via `GET /api/sessions/active`. Request/response shapes are zod schemas in `packages/shared`, imported by both `apps/api` (validation) and `apps/worker` (typed client). All new domain routes resolve the user from the better-auth session using the exact `auth.api.getSession({ headers })` pattern already used by `apps/api/src/routes/me.ts`, and scope every query to that `user_id`; no valid token → `401`. ``` ┌──────────────────────────┐ │ apps/worker (Vite SPA) │ localhost:5173 — desktop or phone-on-LAN; PWA-installable │ React + React Router + │ │ React Query + zod (shared)│ └────────────┬─────────────┘ │ HTTP, Authorization: Bearer (token from /api/auth/sign-in/email) ▼ ┌──────────────────────────┐ │ apps/api (Hono) │ localhost:3000 — better-auth + Drizzle, CORS for :5173 │ better-auth (bearer) + │ │ domain routes (scoped) │ └────────────┬─────────────┘ ▼ ┌──────────┐ │ SQLite │ libsql file; activities + work_sessions + better-auth tables └──────────┘ ``` ## Tech Stack (installed versions are AUTHORITATIVE — do NOT bump) These were read from `node_modules` at planning time. If a code sample below disagrees with the installed API, **the installed library wins** — adapt the code and make the real test pass. | Package | Installed version | Notes | |---|---|---| | `hono` | 4.12.25 | router + `hono/cors` middleware (verified present) | | `@hono/node-server` | 1.x | server entry (already used by `apps/api/src/index.ts`) | | `better-auth` | 1.6.18 | bearer plugin; `set-auth-token` response header on sign-in | | `drizzle-orm` | 0.36.4 | **pinned — do NOT bump (SL-9)**; `text`, `integer`, `index`, `eq`, `and`, `desc` all verified | | `drizzle-kit` | 0.30.6 | **pinned — do NOT bump (SL-9)** | | `@libsql/client` | 0.14.0 | SQLite driver (no native build) | | `zod` | 3.25.76 | contracts (shared) | | `vitest` | 3.2.6 | backend + worker tests | Client deps to add (latest compatible at install time, pinned in `apps/worker/package.json`): `vite`, `@vitejs/plugin-react`, `react`, `react-dom`, `react-router-dom`, `@tanstack/react-query`, `typescript`, `@solelog/shared` (`workspace:*`); dev: `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom`, `@types/react`, `@types/react-dom`. A web app **manifest** for installability via a `public/manifest.webmanifest` plus two PNG icons referenced from `index.html` (NO `vite-plugin-pwa`, NO service worker — offline is out of scope). Keep deps minimal — no UI kitchen-sink libraries. ## Global Constraints - **Strict TDD.** Test first; never weaken/skip/delete a test to pass it. Real commands only. - **The client is a Vite + React PWA, NOT Expo.** No Expo / React Native / react-native-web / ngrok. - **Do NOT modify the better-auth config beyond using it** (`apps/api/src/auth.ts`). You MAY add `'http://localhost:5173'` to its `trustedOrigins` array (that is *using* it, and required for the cross-origin SPA) — but do not change plugins, hashing, or session config. - **Do NOT bump `drizzle-orm` / `drizzle-kit`** (pinned, tracked as SL-9). Use the installed API. - **Keep `apps/api` green** at every commit (`yarn workspace @solelog/api test` passes). - **SQLite array storage:** `insole_types` is stored via Drizzle `text('insole_types', { mode: 'json' })` (libsql stores it as a JSON string; Drizzle (de)serialises to/from `string[]`). The shared zod schema validates the subset of `'Kurk' | 'Berk' | '3D'`. - **Timestamps:** store `start_time` / `end_time` / `created_at` as `integer({ mode: 'timestamp_ms' })` (epoch-ms, same convention better-auth uses in `schema.ts`). Contracts serialise them as ISO-8601 strings at the HTTP boundary. - **Commands.** Run git as `git -C D:/Sven ...`. Run yarn from the repo root (Yarn 4.12.0 via corepack). Run a single workspace's tests with `yarn workspace @solelog/api test` / `yarn workspace @solelog/worker test`. Commit frequently — one commit per task minimum. - **Migrations:** generate with `yarn workspace @solelog/api db:generate` (drizzle-kit). Do NOT touch the existing better-auth migration (`drizzle/0000_stiff_captain_britain.sql`) — domain tables go in a NEW migration file. The test harness (`apps/api/test/setup.ts`) runs `runMigrations()` against a fresh `./.tmp/test.db` before each run, so a new migration is picked up automatically. ### Insole-type and seed reference (from `docs/reference/legacy-mobile-app.md` §3 and §6.2) - Valid insole types (verbatim, ordered): `['Kurk', 'Berk', '3D']`. Default selected: `'Kurk'`. - An activity with empty/missing `insole_types` defaults to all three. - Seed activities (realistic Dutch handeling names; `Leerrand` is the doc's example step): | name | insole_types | |---|---| | `Leerrand` | `['Kurk','Berk','3D']` | | `Frezen` | `['Kurk','Berk']` | | `Slijpen` | `['Kurk','Berk','3D']` | | `Bekleden` | `['Kurk','Berk','3D']` | | `Afwerken` | `['Kurk','Berk','3D']` | | `Printen` | `['3D']` | ### CSV contract (from `docs/reference/legacy-backend.md` §4) `GET /api/export` returns `text/csv; charset=utf-8`, `Content-Disposition: attachment; filename="insole-production-report.csv"`. The user's **completed** sessions, ordered `start_time ASC`. Columns (in order), every cell and header quoted with `"` (embedded `"` doubled), rows joined with `\n`: | # | Header | Source / formatting | |---|---|---| | 1 | `ID` | session id | | 2 | `Task` | activity name | | 3 | `Insole Type` | `insole_type ?? 'Kurk'` | | 4 | `No. of Insoles` | `pair_count ?? 2` | | 5 | `Date` | `start_time` → `toLocaleDateString('nl-BE', { day:'2-digit', month:'2-digit', year:'numeric' })` | | 6 | `Total Duration` | `duration_seconds` → `HH:MM:SS` (zero-padded; hours can exceed 99) | | 7 | `Start Time` | `start_time` → `toLocaleTimeString('nl-BE', { hour:'2-digit', minute:'2-digit', second:'2-digit' })` | | 8 | `End Time` | `end_time` → same time format, or `''` if null | Helpers (port verbatim): ```ts const quote = (value: unknown) => `"${String(value).replace(/"/g, '""')}"`; function formatDuration(totalSeconds: number): string { const s = totalSeconds || 0; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } ``` --- # Backend Task 1: Domain schema + migration + shared contracts (activities & work_sessions) **Outcome:** `activities` and `work_sessions` tables exist in a NEW migration; the zod contracts for both live in `packages/shared`; `apps/api` still green. No routes yet. ### Files - `apps/api/src/db/schema.ts` — APPEND domain tables (do not touch the better-auth tables above). - `packages/shared/src/index.ts` — APPEND contracts. - `apps/api/drizzle/0001_*.sql` + `apps/api/drizzle/meta/*` — generated, committed. - `apps/api/test/schema.test.ts` — NEW test. ### Schema (append to `apps/api/src/db/schema.ts`) ```ts // ---- SoleLog domain tables (Phase 1) ---- export const activities = sqliteTable('activities', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), // subset of 'Kurk' | 'Berk' | '3D' — stored as a JSON string by libsql. insoleTypes: text('insole_types', { mode: 'json' }) .$type() .notNull() .default(['Kurk', 'Berk', '3D']), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), }); export const workSessions = sqliteTable( 'work_sessions', { id: integer('id').primaryKey({ autoIncrement: true }), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), activityId: integer('activity_id') .notNull() .references(() => activities.id), insoleType: text('insole_type'), pairCount: integer('pair_count').notNull().default(2), startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(), endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active durationSeconds: integer('duration_seconds'), status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded' source: text('source').notNull().default('app'), // 'app' | 'manual' notes: text('notes'), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), }, (table) => ({ workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId), workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime), }) ); ``` > `sql` and `index` are already imported at the top of `schema.ts`. `user` is defined above in the > same file. If `sqliteTable`'s second-arg callback-returns-object form is deprecated in 0.36.4, > adapt to the array form the installed version expects (installed API wins). ### Contracts (append to `packages/shared/src/index.ts`) ```ts export const InsoleType = z.enum(['Kurk', 'Berk', '3D']); export type InsoleType = z.infer; export const Activity = z.object({ id: z.number().int(), name: z.string(), insole_types: z.array(InsoleType), created_at: z.string(), // ISO-8601 }); export type Activity = z.infer; export const CreateActivityInput = z.object({ name: z.string().trim().min(1), insole_types: z.array(InsoleType).default(['Kurk', 'Berk', '3D']), }); export type CreateActivityInput = z.infer; export const UpdateActivityInput = CreateActivityInput; export type UpdateActivityInput = z.infer; export const SessionStatus = z.enum(['active', 'completed', 'discarded']); export type SessionStatus = z.infer; export const WorkSession = z.object({ id: z.number().int(), user_id: z.string(), activity_id: z.number().int(), activity_name: z.string().optional(), // present on history/active joins insole_type: InsoleType.nullable(), pair_count: z.number().int(), start_time: z.string(), // ISO-8601 end_time: z.string().nullable(), duration_seconds: z.number().int().nullable(), status: SessionStatus, source: z.enum(['app', 'manual']), notes: z.string().nullable(), created_at: z.string(), }); export type WorkSession = z.infer; export const StartSessionInput = z.object({ activity_id: z.number().int(), insole_type: InsoleType, pair_count: z.number().int().min(1).default(2), }); export type StartSessionInput = z.infer; ``` ### Test — `apps/api/test/schema.test.ts` Describe **"domain schema"**: - `it('creates and reads back an activity with a json insole_types array')`: import `db` from `../src/db/client`, `activities` from `../src/db/schema`, `eq` from `drizzle-orm`. Insert `{ name: 'Frezen', insoleTypes: ['Kurk','Berk'] }`, select it back by id, assert `row.insoleTypes` deep-equals `['Kurk','Berk']` (proves the JSON round-trip) and `row.name === 'Frezen'`. - `it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time')`: needs a real `user.id`, so first sign a user up through the app (mirror `me.test.ts`: build the app, `POST /api/auth/sign-up/email`, then read the created user's id from the `user` table via `db.select().from(user)`), then insert a `work_sessions` row with only the required fields (`userId`, `activityId` from the activity above, `startTime: new Date()`), select it back, and assert `status === 'active'`, `source === 'app'`, `pairCount === 2`, `endTime === null`, `durationSeconds === null`. > This test exercises the live migration (the setup file migrates `./.tmp/test.db` before tests), > so it fails until the migration is generated. ### Steps - [ ] Append the two contracts blocks above to `packages/shared/src/index.ts`. - [ ] Write `apps/api/test/schema.test.ts`. Run `yarn workspace @solelog/api test schema` — watch it fail (no `activities` table). - [ ] Append the schema tables to `apps/api/src/db/schema.ts`. - [ ] Generate the migration: `yarn workspace @solelog/api db:generate`. Confirm a new `drizzle/0001_*.sql` appears creating only `activities` + `work_sessions` (NOT re-creating better-auth tables) and that `drizzle/meta/_journal.json` gained an entry. - [ ] Run `yarn workspace @solelog/api test schema` — green. Run the full suite — still green. Run `yarn workspace @solelog/api typecheck` — clean. - [ ] Commit: `feat(api): add activities + work_sessions domain schema and shared contracts`. --- # Backend Task 2: Auth helper + activities CRUD routes (user-scoped) **Outcome:** `GET/POST /api/activities`, `PUT/DELETE /api/activities/:id`, all behind the bearer session, with full vitest coverage. Activities are shared shop data (not per-user) but all routes require a valid session (401 without). ### Files - `apps/api/src/lib/require-user.ts` — NEW shared auth helper. - `apps/api/src/routes/activities.ts` — NEW route module. - `apps/api/src/app.ts` — mount the route (CORS added in Task 6; just mount here). - `apps/api/test/activities.test.ts` — NEW test. ### `apps/api/src/lib/require-user.ts` A helper that resolves the better-auth session the same way `me.ts` does and returns the user, or `null`. Routes turn `null` into a `401`. ```ts import type { Context } from 'hono'; import { auth } from '../auth'; export async function getSessionUser(c: Context): Promise<{ id: string } | null> { const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session) return null; return { id: session.user.id }; } ``` ### `apps/api/src/routes/activities.ts` A Hono sub-app. Behaviour (mirror legacy task rules from `legacy-backend.md` §3, scoped behind auth): - `GET /api/activities` — 401 if no user. Optional `?insole_type=Kurk|Berk|3D` filter: return activities whose `insoleTypes` array includes that value (filter in JS after the select, since the column is JSON text). Order by `name ASC`. Map rows → `Activity` shape (`created_at` to ISO). - `POST /api/activities` — 401 if no user. Parse body with `CreateActivityInput.safeParse`; on failure `400 { error: 'Invalid input' }`. Empty/missing `insole_types` defaults to all three (the zod `.default` handles this). Insert, return the created `Activity` with status `200` (match the legacy `Response.json` convention; the test asserts `200`). - `PUT /api/activities/:id` — 401 if no user. `id` parsed as int. Validate body as above. If no row updated → `404 { error: 'Activity not found' }`. Return updated `Activity`. - `DELETE /api/activities/:id` — 401 if no user. Delete the activity's `work_sessions` first, then the activity (reproduce legacy cascade *behaviour* explicitly). Return `{ success: true }`. (The FK has no cascade declared, so the explicit delete is required to avoid a constraint error when sessions reference it.) Use Drizzle query builder (`db.select().from(activities)`, `db.insert(...).values(...).returning()`, `db.update(...).set(...).where(eq(...)).returning()`, `db.delete(...)`). Serialise timestamps with `new Date(row.createdAt).toISOString()`. ### Test — `apps/api/test/activities.test.ts` Add a helper at the top to sign up + sign in and return a bearer token (copy the pattern from `me.test.ts`; factor a local `async function authToken(app, email)` returning the `set-auth-token`). Use a UNIQUE email per test to avoid cross-test collisions in the shared file DB. Describe **"activities routes"**: - `it('401s GET /api/activities without a token')` → status 401. - `it('401s POST /api/activities without a token')` → status 401. - `it('creates an activity and lists it')`: POST `{ name:'Frezen', insole_types:['Kurk','Berk'] }` with token → status 200, body matches `Activity` (`insole_types` deep-equals `['Kurk','Berk']`); then GET `/api/activities` → array contains it. - `it('defaults insole_types to all three when omitted')`: POST `{ name:'Slijpen' }` → `insole_types` deep-equals `['Kurk','Berk','3D']`. - `it('filters by ?insole_type')`: create one `['3D']`-only activity and one `['Kurk']`-only; GET `/api/activities?insole_type=3D` returns only the 3D one. - `it('400s POST with an empty name')` → status 400. - `it('updates an activity')`: PUT changes name + types; assert returned body reflects it. - `it('404s PUT for a missing id')` → status 404. - `it('deletes an activity and its sessions')`: create an activity, insert a `work_sessions` row against it directly via `db` with the test user's id, DELETE the activity, assert `{ success: true }` and that the `work_sessions` row is gone (`db.select()` empty). ### Steps - [ ] Write `apps/api/test/activities.test.ts`. Run it — fails (route not mounted). - [ ] Implement `require-user.ts` and `activities.ts`; mount `app.route('/', activities)` in `app.ts`. - [ ] Run the activities test — green. Full suite + typecheck — green. - [ ] Commit: `feat(api): user-scoped activities CRUD with shared auth helper`. --- # Backend Task 3: Session lifecycle routes — start / stop / discard (ownership-enforced) **Outcome:** `POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `POST /api/sessions/:id/discard`, all behind the bearer session and scoped to the owning user. Ownership is enforced: user B cannot stop/discard user A's session (treated as not-found → `404`). ### Files - `apps/api/src/routes/sessions.ts` — NEW route module (also holds the read endpoints in Task 4 and CSV in Task 5; create it here with the write endpoints). - `apps/api/src/app.ts` — mount it. - `apps/api/test/sessions.test.ts` — NEW test. ### Behaviour - `POST /api/sessions/start` — 401 if no user. Body via `StartSessionInput.safeParse`; `400` on fail. Verify the `activity_id` exists (else `404 { error: 'Activity not found' }`). Insert a `work_sessions` row: `userId` = session user, `activityId`, `insoleType`, `pairCount`, `startTime: new Date()`, `endTime: null`, `durationSeconds: null`, `status: 'active'`, `source: 'app'`. Return the created `WorkSession`. - `POST /api/sessions/:id/stop` — 401 if no user. Load the session by id **AND** `userId` (scope to owner). If not found (missing or not owned) → `404 { error: 'Session not found' }`. If its `status !== 'active'` (already closed) → `409 { error: 'Session already closed' }`. Otherwise set `endTime = new Date()`, `durationSeconds = Math.round((endTime - startTime)/1000)` (wall-clock delta — server-authoritative; roadmap prefers wall-clock over the legacy tick count), `status = 'completed'`. Return the updated `WorkSession`. - `POST /api/sessions/:id/discard` — 401 if no user. Same owner-scoped load → `404` if not found. Reject if already closed (`409`) the same way. Set `status = 'discarded'`, `endTime = new Date()`, leave `durationSeconds` null. Return the updated `WorkSession`. > Ownership rule: always filter the load by `and(eq(id), eq(userId))`. A row owned by someone else is > indistinguishable from a missing row → `404`. This is the security boundary the test below proves. ### Test — `apps/api/test/sessions.test.ts` Reuse the `authToken` helper pattern. Add a small helper to create an activity via the API and return its id. Describe **"session lifecycle"**: - `it('401s start/stop/discard without a token')` → three requests, each 401. - `it('starts an active session')`: with token, create activity, POST `/api/sessions/start` `{ activity_id, insole_type:'Kurk', pair_count:2 }` → body matches `WorkSession`, `status === 'active'`, `end_time === null`, `duration_seconds === null`. - `it('400s start with a bad body')` → POST start with `{}` → 400. - `it('404s start for a missing activity')` → `{ activity_id: 999999, insole_type:'Kurk' }` → 404. - `it('completes a session and computes duration')`: start a session; to make the duration deterministic, directly `db.update(workSessions).set({ startTime: new Date(Date.now() - 5000) })` for that session id, then POST `/api/sessions/:id/stop`. Assert `status === 'completed'`, `end_time` non-null, `duration_seconds === 5` (exact — never a fuzzy range). - `it('409s stopping an already-completed session')`: start, stop, stop again → 409. - `it('discards an active session')`: start, discard → `status === 'discarded'`, `duration_seconds === null`. - `it('does not let user B stop user A\'s session')`: token A starts a session; token B (different email) POSTs `/api/sessions/:id/stop` → **404**; then verify via `db` (or token A read) the session is still `active`. ### Steps - [ ] Write `apps/api/test/sessions.test.ts`. Run it — fails. - [ ] Implement the three write endpoints in `apps/api/src/routes/sessions.ts`; mount in `app.ts`. - [ ] Run the sessions test — green. Full suite + typecheck — green. - [ ] Commit: `feat(api): server-authoritative session start/stop/discard with ownership scoping`. --- # Backend Task 4: Session read routes — history & active recovery (joined, scoped) **Outcome:** `GET /api/sessions` (history, newest first, joined to activity name, includes active) and `GET /api/sessions/active` (the user's open session(s) for recovery), both user-scoped. ### Files - `apps/api/src/routes/sessions.ts` — ADD the two GET endpoints. - `apps/api/test/sessions.test.ts` — ADD a `describe('session reads')` block. ### Behaviour - `GET /api/sessions` — 401 if no user. Select all `work_sessions` where `userId = user.id`, LEFT JOIN `activities` on `activityId` to get `activity_name`, ordered `startTime DESC` (newest first). Map to `WorkSession[]` (`activity_name` set; timestamps to ISO; `end_time`/`duration_seconds` null-safe). Includes active, completed, and discarded sessions for this user. - `GET /api/sessions/active` — 401 if no user. Select `work_sessions` where `userId = user.id AND status = 'active'`, joined to activity name, ordered `startTime DESC`. Return `WorkSession[]` (usually 0 or 1; return an array so the client can pick the most recent). > Path note: `/api/sessions/active` and `/api/sessions` are distinct exact paths; there is no > `/api/sessions/:id` GET in this phase, so no route shadows another. Keep paths exact. ### Test additions — `describe('session reads')` - `it('401s GET /api/sessions and /api/sessions/active without a token')`. - `it('returns the user\'s sessions joined with activity name, newest first')`: token A starts two sessions against named activities (e.g. `Frezen`, `Slijpen`); to control ordering, `db.update` their `startTime`s so one is clearly newer; GET `/api/sessions` → length 2, `new Date(res[0].start_time) > new Date(res[1].start_time)`, and `res[0].activity_name` is the newer one. - `it('scopes history to the requesting user')`: token A has sessions; token B GETs `/api/sessions` → none of B's results carry A's session ids (B sees only its own). - `it('returns only active sessions from /api/sessions/active')`: token A starts one session and starts+stops another; `/api/sessions/active` → length 1, that one is `status === 'active'`. ### Steps - [ ] Add the read-route tests. Run — fail. - [ ] Implement the two GET endpoints in `sessions.ts`. - [ ] Run sessions test — green. Full suite + typecheck — green. - [ ] Commit: `feat(api): session history and active-session recovery endpoints`. --- # Backend Task 5: CSV export (completed sessions, scoped, legacy format) **Outcome:** `GET /api/export` returns the bearer user's **completed** sessions as CSV matching the legacy format (see Global Constraints → CSV contract). ### Files - `apps/api/src/routes/sessions.ts` — ADD `GET /api/export` (or a small `apps/api/src/routes/export.ts` mounted in `app.ts`; either is fine — keep it in one place and mount it). - `apps/api/src/lib/csv.ts` — NEW: the `quote` + `formatDuration` helpers (so they are unit-testable). - `apps/api/test/export.test.ts` — NEW test. ### Behaviour - 401 if no user. Select the user's `work_sessions` where `status = 'completed'`, joined to activity name, ordered `startTime ASC` (oldest first — note this is the OPPOSITE of history). Build the CSV exactly per the contract table. Format dates/times with `nl-BE` locale as specified. Response: `text/csv; charset=utf-8`, `Content-Disposition: attachment; filename="insole-production-report.csv"`, body = header row + data rows joined with `\n`. ### `apps/api/src/lib/csv.ts` Export `quote` and `formatDuration` exactly as in the Global Constraints section. ### Test — `apps/api/test/export.test.ts` - `it('401s without a token')` → GET `/api/export` no token → 401. - `it('exports completed sessions as CSV with the legacy header')`: token user creates an activity `Frezen`, starts a session, `db.update`s its `startTime` to exactly 90s before its `endTime`/stop so the duration is exactly 90, stops it. GET `/api/export` with token. Assert: - `res.headers.get('content-type')` includes `text/csv`. - `res.headers.get('content-disposition')` === `attachment; filename="insole-production-report.csv"`. - body first line === `"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time"`. - body has exactly 2 lines (header + 1 row); the data row contains `"Frezen"`, the insole type, and the `Total Duration` cell `"00:01:30"` (90s, computed exactly). - `it('excludes active and discarded sessions and scopes to the user')`: the same user also has an active and a discarded session; another user has a completed session. The CSV for the first user has only its own completed row(s) (still 2 lines). - `describe('csv helpers')`: `quote('a"b')` → `"a""b"`; `formatDuration(3661)` → `01:01:01`; `formatDuration(0)` → `00:00:00`. > Locale note: `nl-BE` `toLocaleString` depends on the platform's ICU. Assert the **Total Duration** > cell exactly (pure arithmetic, no locale) and the **header** + **structure** exactly. For the > Date/Start/End cells, assert they are non-empty quoted strings rather than a hard-coded locale > rendering (avoids a brittle ICU dependency while still proving the columns populate). ### Steps - [ ] Write `apps/api/test/export.test.ts`. Run — fail. - [ ] Implement `lib/csv.ts` and the `/api/export` route; mount it. - [ ] Run export test — green. Full suite + typecheck — green. - [ ] Commit: `feat(api): user-scoped CSV export matching legacy format`. --- # Backend Task 6: Seed script + CORS for the SPA origin **Outcome:** a seed script inserts the reference activities (idempotent); CORS allows the SPA at `http://localhost:5173` to call the API with a bearer token and read the `set-auth-token` response header; `apps/api/src/auth.ts` `trustedOrigins` includes `:5173`. ### Files - `apps/api/src/db/seed.ts` — NEW; `apps/api/package.json` `db:seed` script. - `apps/api/src/app.ts` — add `cors()` middleware. - `apps/api/src/auth.ts` — add `'http://localhost:5173'` to `trustedOrigins` (allowed: *using* auth). - `apps/api/test/cors.test.ts` — NEW test. - `apps/api/test/seed.test.ts` — NEW test. ### Seed — `apps/api/src/db/seed.ts` Idempotent: for each reference activity (table in Global Constraints), insert it only if no activity with that `name` exists (`db.select().from(activities).where(eq(activities.name, name))`). Export a `seed()` function and add the direct-run guard (copy the `pathToFileURL(process.argv[1])` pattern from `migrate.ts`). Add `"db:seed": "tsx src/db/seed.ts"` to `apps/api/package.json`. ### CORS — `apps/api/src/app.ts` Add BEFORE the routes: ```ts import { cors } from 'hono/cors'; // ... app.use( '/api/*', cors({ origin: ['http://localhost:5173'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in credentials: true, }) ); ``` > `exposeHeaders: ['set-auth-token']` is load-bearing: better-auth returns the bearer token in that > response header on sign-in, and a cross-origin browser fetch can only read it if it is exposed. ### Test — `apps/api/test/cors.test.ts` - `it('answers a CORS preflight for the SPA origin')`: `OPTIONS /api/activities` with headers `Origin: http://localhost:5173` and `Access-Control-Request-Method: GET` → `access-control-allow-origin` === `http://localhost:5173` and the response allows GET. - `it('exposes set-auth-token to the SPA origin')`: any `/api/*` request with `Origin: http://localhost:5173` → `access-control-expose-headers` contains `set-auth-token` (case-insensitive check). ### Test — `apps/api/test/seed.test.ts` - `it('seeds the reference activities idempotently')`: import `seed` from `../src/db/seed`; run it twice; the count of activities with the seeded names is unchanged after the second run (no duplicates); assert `Printen` exists with `insole_types` deep-equal `['3D']`. ### Steps - [ ] Write `cors.test.ts` and `seed.test.ts`. Run — fail. - [ ] Add CORS to `app.ts`; add `:5173` to `auth.ts` `trustedOrigins`; write `seed.ts` + `db:seed`. - [ ] Run both tests — green. Full suite + typecheck — green. - [ ] Run the seed once for real against the dev DB: `yarn workspace @solelog/api db:migrate && yarn workspace @solelog/api db:seed` and confirm it prints success (sanity, not a test). - [ ] Commit: `feat(api): seed reference activities and enable CORS for the worker SPA`. --- # Client Task 1: Scaffold the Vite + React + TS PWA workspace + API client + token storage **Outcome:** `apps/worker` exists as workspace `@solelog/worker`, builds, typechecks, has a vitest setup, a PWA manifest, a typed `apiFetch` wrapper that attaches the bearer token from `localStorage`, and tests for the token storage + fetch wrapper. No screens yet (a placeholder root). ### Files (new app skeleton) ``` apps/worker/ package.json tsconfig.json tsconfig.node.json vite.config.ts vitest.config.ts index.html public/manifest.webmanifest public/icon-192.png (a simple solid-colour PNG, 192x192) public/icon-512.png (512x512) src/main.tsx src/App.tsx (placeholder: renders "SoleLog") src/test/setup.ts (jest-dom) src/lib/auth-storage.ts src/lib/api.ts src/lib/auth-storage.test.ts src/lib/api.test.ts ``` ### `apps/worker/package.json` ```json { "name": "@solelog/worker", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "@solelog/shared": "workspace:*", "@tanstack/react-query": "^5.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "jsdom": "^25.0.0", "typescript": "^5.7.2", "vite": "^5.4.0", "vitest": "^3.0.0" } } ``` > Install resolves these to concrete versions; whatever Yarn picks is authoritative — adapt code to > the installed React 18/19 + Router 6/7 + RQ 5 API. (React 19's `createRoot` call/types are > identical; Router 7 still exports the `react-router-dom` symbols used here.) Keep the dep list to > exactly these — no extras. ### `apps/worker/vite.config.ts` ```ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { host: true, port: 5173 }, // host:true → reachable from a phone on the LAN }); ``` ### `apps/worker/vitest.config.ts` ```ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] }, }); ``` ### `apps/worker/tsconfig.json` Standard Vite React tsconfig: `target ES2020`, `lib ['ES2020','DOM','DOM.Iterable']`, `module 'ESNext'`, `moduleResolution 'Bundler'`, `jsx 'react-jsx'`, `strict true`, `noEmit true`, `types ['vitest/globals', '@testing-library/jest-dom']`, `skipLibCheck true`. Include `src`. `tsconfig.node.json` covers `vite.config.ts` (composite, `moduleResolution 'Bundler'`). ### `apps/worker/index.html` Minimal; in `` link the manifest and theme: ``, ``, ``, ``. Body: `
` + ``. Title `SoleLog`. ### `apps/worker/public/manifest.webmanifest` ```json { "name": "SoleLog", "short_name": "SoleLog", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#2563EB", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] } ``` Generate the two PNG icons as simple solid blue squares (a one-off node script writing minimal valid PNGs, or commit two tiny pre-made PNGs). They only need to be valid PNGs of the right dimensions for installability — no design work. ### `apps/worker/src/lib/auth-storage.ts` ```ts const TOKEN_KEY = 'solelog.token'; export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); } export function clearToken(): void { localStorage.removeItem(TOKEN_KEY); } ``` ### `apps/worker/src/lib/api.ts` A typed fetch wrapper feeding React Query. Base URL from `import.meta.env.VITE_API_URL` (default `http://localhost:3000`). Attaches `Authorization: Bearer ` when a token is stored. Sign-in reads the token from the `set-auth-token` response header and stores it. ```ts import { getToken, setToken } from './auth-storage'; export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; export class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } export async function apiFetch(path: string, init: RequestInit = {}): Promise { const token = getToken(); const headers = new Headers(init.headers); if (token) headers.set('Authorization', `Bearer ${token}`); if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); const res = await fetch(`${API_URL}${path}`, { ...init, headers }); if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`); const text = await res.text(); return (text ? JSON.parse(text) : undefined) as T; } // Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header. export async function signIn(email: string, password: string): Promise { const res = await fetch(`${API_URL}/api/auth/sign-in/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt'); const token = res.headers.get('set-auth-token'); if (!token) throw new ApiError(500, 'Geen token ontvangen'); setToken(token); } // Sign up affordance for testing: POST /api/auth/sign-up/email. export async function signUp(email: string, password: string): Promise { const res = await fetch(`${API_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }), }); if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt'); } ``` > The installed `apps/api` better-auth `/sign-up/email` requires `name` (no backfill hook in > `apps/api`), so the SPA supplies it from the email local-part. ### `apps/worker/src/main.tsx` `createRoot(document.getElementById('root')!).render()` wrapped in `` and a ``. (Router added in Client Task 2.) Import `./styles.css`. ### Tests `apps/worker/src/test/setup.ts`: `import '@testing-library/jest-dom';`. `apps/worker/src/lib/auth-storage.test.ts` (describe **"auth-storage"**): - `it('stores, reads, and clears the token')`: `setToken('abc')` → `getToken()==='abc'`; `clearToken()` → `getToken()===null`. (jsdom provides `localStorage`.) `apps/worker/src/lib/api.test.ts` (describe **"api client"**): - `it('attaches the bearer token to requests')`: stub `global.fetch` with `vi.fn` returning `new Response(JSON.stringify({ ok: true }), { status: 200 })`; `setToken('tok')`; call `apiFetch('/api/me')`; assert the `fetch` mock was called with the URL `http://localhost:3000/api/me` and a `Headers` containing `Authorization: Bearer tok`. - `it('throws ApiError on a non-2xx response')`: mock fetch → `status 401`; expect `apiFetch` rejects with an `ApiError` whose `.status === 401`. - `it('signIn stores the token from the set-auth-token header')`: mock fetch → `new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } })`; call `signIn('a@b.c','pw')`; assert `getToken() === 'xyz'`. - `it('signIn throws when the header is missing')`: mock fetch → 200, no header → rejects. ### Steps - [ ] Create the `apps/worker` skeleton files above (placeholder `App.tsx` rendering `SoleLog`). - [ ] From repo root: `yarn install` (registers the workspace + installs deps). - [ ] Write the two test files. Run `yarn workspace @solelog/worker test` — watch fail, then make pass by implementing `auth-storage.ts` and `api.ts`. - [ ] `yarn workspace @solelog/worker typecheck` — clean. `yarn workspace @solelog/worker build` — succeeds (headless build check; produces `dist/` with the manifest). - [ ] Commit: `feat(worker): scaffold Vite+React PWA with token storage and typed API client`. --- # Client Task 2: App shell — auth gate, login screen, router, tab layout **Outcome:** the app boots into a login screen when there is no token; after sign-in it shows a 3-tab shell (Stopwatch / Geschiedenis / Instellingen) wired to React Router routes (empty screen stubs for now). Dutch strings throughout. A component smoke test renders the login screen. ### Files - `apps/worker/src/App.tsx` — router + auth gate. - `apps/worker/src/auth/AuthContext.tsx` — token presence state + `signIn`/`signUp`/`signOut`. - `apps/worker/src/screens/Login.tsx` — email/password form + a sign-up toggle. - `apps/worker/src/components/TabBar.tsx` — bottom nav: `Stopwatch` / `Geschiedenis` / `Instellingen`. - `apps/worker/src/screens/Stopwatch.tsx`, `History.tsx`, `Settings.tsx` — stubs (each renders its Dutch title) — filled in Client Tasks 3–5. - `apps/worker/src/styles.css` — palette + mobile-first layout. - `apps/worker/src/App.test.tsx` — smoke test. ### Behaviour - `AuthContext` holds `isAuthed` derived from `getToken()`; `signIn`/`signUp` call `lib/api.ts`; `signOut` clears the token and flips state. (No network "is my token valid" check in Phase 1 — a `401` from any API call surfaces as an error and the user can re-login. Keep it minimal.) - `App.tsx`: if `!isAuthed` render ``. If authed, render `` with routes `'/'` → Stopwatch, `'/history'` → History, `'/settings'` → Settings, plus the ``. - `Login.tsx`: heading `SoleLog`; email input (label `E-mailadres`), password input (label `Wachtwoord`), a primary button. A toggle switches between sign-in (`Inloggen`) and a sign-up affordance (`Registreren`). On submit call `signIn` (or `signUp` then `signIn`); on error show a Dutch message. Mobile-first: full-width inputs, generous tap targets. - `TabBar.tsx`: three ``s with the exact Dutch tab titles from `legacy-mobile-app.md` §1: `Stopwatch`, `Geschiedenis`, `Instellingen`. Active link uses primary blue `#2563EB`; fixed to the bottom; mobile-first. ### Styling Plain CSS (`src/styles.css`) or inline styles — no UI library. Reuse the legacy palette tokens (`legacy-mobile-app.md` §2): primary `#2563EB`, light-blue `#EFF6FF`, text `#111827`/`#6B7280`, borders `#E5E7EB`, danger `#DC2626`, amber `#D97706`. Mobile-first, responsive, big tap targets. ### Test — `apps/worker/src/App.test.tsx` - `it('shows the login screen when there is no token')`: `clearToken()`; render `` (inside its providers); assert the `Inloggen` button and `E-mailadres` label are in the document. - `it('shows the tab bar when a token is present')`: `setToken('tok')`; render ``; assert the three Dutch tab titles `Stopwatch`, `Geschiedenis`, `Instellingen` are present. (Stub `apiFetch`/ network so the stub screens render without real requests.) ### Steps - [ ] Write `App.test.tsx`. Run — fail. - [ ] Implement `AuthContext`, `Login`, `TabBar`, `App` wiring, `styles.css`, the three stub screens. - [ ] Run worker tests — green. typecheck + build — clean. - [ ] Commit: `feat(worker): auth gate, Dutch login screen, router and 3-tab shell`. --- # Client Task 3: Instellingen (Settings) — activities CRUD per zooltype **Outcome:** the Instellingen screen lists activities, adds/edits/deletes them per zooltype, against `/api/activities`, via React Query. Built before Stopwatch because Stopwatch needs activities to exist (and this is the simplest data round-trip to prove the client↔API contract end-to-end). ### Files - `apps/worker/src/screens/Settings.tsx` — full implementation. - `apps/worker/src/api/activities.ts` — typed RQ hooks (`useActivities`, `useCreateActivity`, `useUpdateActivity`, `useDeleteActivity`) using `apiFetch` + the `Activity` zod type from shared. - `apps/worker/src/screens/Settings.test.tsx` — test. ### Behaviour (from `legacy-mobile-app.md` §6) - Header `Instellingen`; subtitle `Beheer handelingen per zooltype`. - "Add new handling" card: label `Nieuwe handeling toevoegen`; name input placeholder `Naam van de stap, bijv. Leerrand`; a `Van toepassing op` row with three toggle pills (`Kurk` / `Berk` / `3D`, default all three selected); add button `Stap toevoegen` (disabled unless trimmed name non-empty AND ≥1 type selected). On success clears the name, resets to all three. - List: `Huidige stappen ({n})`; empty state `Nog geen stappen. Voeg er een toe hierboven.`. Each row shows the name + type badges; an edit (pencil) and delete (trash) affordance. Edit mode shows a name input + the `Van toepassing op` toggles + `Opslaan` / `Annuleren`. - Delete confirmation (use `window.confirm` for Phase 1 minimalism): body text `"{name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`; on confirm call delete; on success the activities query refetches (and the sessions query is invalidated). - Use the per-type colours (`TYPE_COLORS` in §2) for the toggles/badges. ### `apps/worker/src/api/activities.ts` `useActivities()` → `useQuery({ queryKey: ['activities'], queryFn: () => apiFetch('/api/activities') })`. Mutations POST/PUT/DELETE to `/api/activities[/:id]` and `invalidateQueries(['activities'])` (delete also invalidates `['sessions']`). Validate responses with the shared `Activity` schema where cheap. ### Test — `apps/worker/src/screens/Settings.test.tsx` `vi.mock` the `lib/api` module so no real network. Render `` inside a `QueryClientProvider`. - `it('renders the heading and add form in Dutch')`: assert `Instellingen`, `Nieuwe handeling toevoegen`, placeholder `Naam van de stap, bijv. Leerrand`, and button `Stap toevoegen` present. - `it('lists activities returned by the API')`: mock `apiFetch` → `[{ id:1, name:'Frezen', insole_types:['Kurk','Berk'], created_at:'...' }]`; assert `Frezen` and its type badges render and the header shows `Huidige stappen (1)`. - `it('disables the add button until a name is entered')`: empty form → `Stap toevoegen` disabled; after typing a name (user-event) it is enabled. - `it('shows the empty state when there are no activities')`: mock `[]` → `Nog geen stappen. Voeg er een toe hierboven.` present. ### Steps - [ ] Write `Settings.test.tsx`. Run — fail. - [ ] Implement `api/activities.ts` and `Settings.tsx`. - [ ] Run worker tests — green. typecheck + build — clean. - [ ] Commit: `feat(worker): Instellingen screen — activities CRUD per zooltype`. --- # Client Task 4: Stopwatch ('/') — server-authoritative timing **Outcome:** the Stopwatch screen: pick `Type zool` → `Type handeling` (filtered by zool) → `Aantal zolen` (default 2); start/pause/stop&save/double-press-discard; live elapsed timer; server-authoritative (start/stop/discard are API calls); recovers an active session on load. ### Files - `apps/worker/src/screens/Stopwatch.tsx` — full implementation. - `apps/worker/src/api/sessions.ts` — RQ hooks: `useActiveSessions`, `useStartSession`, `useStopSession`, `useDiscardSession` (POST to the Backend Task 3/4 endpoints). - `apps/worker/src/lib/stopwatch.ts` — PURE timing helpers (unit-testable, no React). - `apps/worker/src/lib/stopwatch.test.ts` — timing-logic test. - `apps/worker/src/screens/Stopwatch.test.tsx` — component test. ### `apps/worker/src/lib/stopwatch.ts` (pure logic — server-authoritative elapsed) Elapsed is computed from the server `start_time` (wall-clock), not a tick counter (roadmap preference; survives backgrounding). Pause accumulates paused time client-side. ```ts export function formatTime(totalSeconds: number): string { const s = Math.max(0, Math.floor(totalSeconds)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } // elapsed seconds since startMs, excluding accumulated paused ms, evaluated at nowMs. export function elapsedSeconds(startMs: number, nowMs: number, pausedMs: number): number { return Math.max(0, Math.floor((nowMs - startMs - pausedMs) / 1000)); } ``` ### Behaviour (from `legacy-mobile-app.md` §4) - Section `Type zool`: three segmented buttons `Kurk`/`Berk`/`3D` (default `Kurk`). Disabled while running. Changing the zool resets the selected handling (`activeActivityId = null`). - Section `Type handeling`: a select listing activities **filtered** by the chosen zool (`insole_types.includes(insoleType)`); placeholder `Kies een handeling...`. Disabled while running. Empty state when none match: `Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen.`. - Section `Aantal zolen`: a stepper `− [n] +`, default 2, min 1, free-typed; sent as `pair_count`. - Stopwatch display: `HH:MM:SS` (large). Tap target with status pill: not-running+can-start → `Tik om te starten`; running → `Tik om te pauzeren`; paused → `Gepauzeerd — tik om te hervatten`. - Buttons: not running → `Start Stopwatch` (enabled only if a handling is chosen). Running → red `Stop & Opslaan` + the double-press discard (`Annuleren` → armed `Nogmaals tikken ter bevestiging`, 3s window). - **Server calls:** Start → `POST /api/sessions/start { activity_id, insole_type, pair_count }` → store the returned session id + its `start_time`. Pause/resume are **client-only** (accumulate paused ms) — the server stays open. Stop & Save → `POST /api/sessions/:id/stop`. Discard (2nd tap) → `POST /api/sessions/:id/discard`. After stop/discard, reset the timer (selections persist). - **Recovery on load:** `GET /api/sessions/active`; if one exists, adopt it (set running, set `activeActivityId`, `insoleType`, `pairCount`, and base the live timer on its `start_time`). This is the "phone died, resume from another device" path. - Live timer: a 1s `setInterval` re-render; the displayed value is `elapsedSeconds(startMs, Date.now(), pausedMs)`. ### Tests `apps/worker/src/lib/stopwatch.test.ts` (describe **"stopwatch logic"**): - `formatTime(0) === '00:00:00'`, `formatTime(65) === '00:01:05'`, `formatTime(3661) === '01:01:01'`. - `elapsedSeconds(1000, 6000, 0) === 5`; `elapsedSeconds(1000, 6000, 2000) === 3` (paused time excluded); `elapsedSeconds(5000, 1000, 0) === 0` (never negative). `apps/worker/src/screens/Stopwatch.test.tsx` (mock `lib/api`/the session+activity hooks): - `it('renders the three sections and Start button in Dutch')`: with activities mocked, assert `Type zool`, `Type handeling`, `Aantal zolen`, `Start Stopwatch`; the `Kurk`/`Berk`/`3D` buttons; default count `2`. - `it('disables Start until a handling is chosen')`: initially `Start Stopwatch` disabled; after selecting a handling it is enabled. - `it('filters handlings by the chosen zooltype')`: activities `[{name:'Printen',insole_types:['3D']}, {name:'Frezen',insole_types:['Kurk','Berk']}]`; with `Kurk` selected the handling options include `Frezen` but not `Printen`; selecting `3D` shows `Printen` and the inverse. - `it('calls start with the selected values')`: mock the start mutation; pick `Berk`, a handling, set count 3, click `Start Stopwatch`; assert the mutation was called with `{ activity_id, insole_type:'Berk', pair_count:3 }`. - `it('arms discard on first Annuleren tap and discards on the second')`: with a running session (mock post-start state), first `Annuleren` tap shows `Nogmaals tikken ter bevestiging`; second tap calls the discard mutation. (Use fake timers if you assert the 3s auto-disarm; at minimum assert the two-tap path.) ### Steps - [ ] Write `lib/stopwatch.test.ts`. Run — fail. Implement `lib/stopwatch.ts`. Green. - [ ] Write `Stopwatch.test.tsx`. Run — fail. Implement `api/sessions.ts` + `Stopwatch.tsx`. Green. - [ ] typecheck + build — clean. - [ ] Commit: `feat(worker): server-authoritative Stopwatch screen with active-session recovery`. --- # Client Task 5: Geschiedenis (History) + CSV export **Outcome:** the Geschiedenis screen lists the user's sessions (newest first) via `GET /api/sessions` and offers a CSV export action that downloads `GET /api/export` with the bearer token. ### Files - `apps/worker/src/screens/History.tsx` — full implementation. - `apps/worker/src/api/sessions.ts` — ADD `useSessions()` (`GET /api/sessions`). - `apps/worker/src/lib/export.ts` — `downloadExport()` helper (authenticated blob download). - `apps/worker/src/screens/History.test.tsx` — test. ### Behaviour (from `legacy-mobile-app.md` §5) - Header `Geschiedenis`; a pill button `Exporteer CSV`. - Body: list of session cards via `useSessions()`. Empty state (not loading, no sessions): `Nog geen opgeslagen sessies.`. - Each card: title = `activity_name`; a date/time line (`{date} • {time}` from `start_time`, device locale); badges: insole-type pill (verbatim `Kurk`/`Berk`/`3D`), a count pill `{pair_count} {pair_count === 1 ? 'inlegzool' : 'inlegzolen'}`, and a duration pill formatted like the legacy `formatDuration` (`1h 5m` / `3m 20s` / `45s`). - CSV export: because the download needs the bearer token, it cannot be a plain `` to the API. `downloadExport()` does an authenticated `fetch` of `/api/export`, reads the blob, and triggers a download via an object URL + a synthetic `` click. On error show a Dutch message (`Fout` / `Kan de export niet openen`). ### `apps/worker/src/lib/export.ts` ```ts import { API_URL } from './api'; import { getToken } from './auth-storage'; export async function downloadExport(): Promise { const token = getToken(); const res = await fetch(`${API_URL}/api/export`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error('Kan de export niet openen'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'insole-production-report.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } ``` ### Test — `apps/worker/src/screens/History.test.tsx` `vi.mock` `lib/api`'s `apiFetch` (for `useSessions`) and `lib/export`'s `downloadExport`. - `it('renders the header and export button in Dutch')`: assert `Geschiedenis` and `Exporteer CSV`. - `it('shows the empty state when there are no sessions')`: mock `[]` → `Nog geen opgeslagen sessies.` present. - `it('renders a session card with activity name, type, count and duration')`: mock one completed session `{ activity_name:'Frezen', insole_type:'Kurk', pair_count:2, duration_seconds:3661, ... }`; assert `Frezen`, `Kurk`, `2 inlegzolen`, and a duration like `1h 1m` render. - `it('uses the singular noun for a count of 1')`: `pair_count:1` → `1 inlegzool`. - `it('triggers the CSV download on Exporteer CSV')`: click the button; assert the mocked `downloadExport` was called. ### Steps - [ ] Write `History.test.tsx`. Run — fail. - [ ] Implement `useSessions`, `lib/export.ts`, `History.tsx`. - [ ] Run worker tests — green. typecheck + build — clean. - [ ] Commit: `feat(worker): Geschiedenis screen with session list and CSV export`. --- # Client Task 6: End-to-end manual smoke + README, final green check **Outcome:** the whole phase verified together, run instructions documented, everything green. ### Files - `apps/worker/README.md` — NEW: how to run (no Expo, no tunnel). - (no code changes expected; docs + verification) ### `apps/worker/README.md` Document: prerequisites (`yarn install` from repo root); run the API (`yarn workspace @solelog/api db:migrate && yarn workspace @solelog/api db:seed && yarn workspace @solelog/api start` on `:3000`); run the worker (`yarn workspace @solelog/worker dev` on `:5173`); open `http://localhost:5173` in any browser, or on a phone via `http://:5173` (Vite `server.host: true` exposes it on the LAN — no tunnel). When testing from a phone, set `VITE_API_URL` to the PC's LAN URL (`http://:3000`) so the SPA targets the API on the LAN, and add that origin to the API CORS `origin` list + better-auth `trustedOrigins`. Installability: use the browser's "Add to Home Screen" / "Install" to install the PWA (the manifest + icons enable it). ### Verification (run REAL commands; paste real output into the session log) - [ ] `yarn workspace @solelog/api test` — all backend tests green. - [ ] `yarn workspace @solelog/api typecheck` — clean. - [ ] `yarn workspace @solelog/worker test` — all worker tests green. - [ ] `yarn workspace @solelog/worker typecheck` — clean. - [ ] `yarn workspace @solelog/worker build` — succeeds; `dist/manifest.webmanifest` + icons present. - [ ] `npx oxlint` from repo root — no new errors. - [ ] Manual smoke (recommended): start API + worker, sign up, sign in, create an activity, run a session start→stop, see it in Geschiedenis, export CSV, install the PWA. - [ ] Commit: `docs(worker): run instructions and Phase 1 verification`. --- ## Self-review notes (writing-plans discipline) - **Zero-context check.** Every task names exact file paths, the contract types it produces/consumes (from `packages/shared`), complete code or precise specs, exact test names, and a commit. Installed library versions were read from `node_modules` and flagged authoritative over the samples (`cors` verified at `hono/cors`; `drizzle-orm` 0.36.4 `text`/`integer`/`index`/`eq`/`and`/`desc` verified). - **TDD honoured.** Each task writes its test(s) first and watches them fail before implementing. - **No drizzle bump.** Schema uses the installed 0.36.4 API; the migration is generated, not hand-written; the existing better-auth migration is untouched (new domain tables → `0001_*.sql`). - **CORS / token / array storage / manifest / VITE_API_URL all resolved concretely** in Global Constraints + the relevant tasks (`exposeHeaders: ['set-auth-token']`, `text({mode:'json'})`, `import.meta.env.VITE_API_URL` default, `public/manifest.webmanifest`). - **Resolved risk — better-auth `trustedOrigins`.** Adding `:5173` is "using" the auth config, not rewriting it; no plugin/hashing/session change. CORS `exposeHeaders` is what lets the browser read the bearer token cross-origin — both required for the SPA, both explicitly called out. - **Resolved risk — locale-sensitive CSV cells.** Tests assert the header, structure, and the arithmetic `Total Duration` cell exactly, but treat the `nl-BE` Date/time cells as non-empty (avoids a brittle ICU dependency while still proving the columns populate). - **Resolved risk — deterministic duration.** The stop/export tests control `start_time` via a direct `db.update`, so `duration_seconds` is asserted exactly, never as a fuzzy range. - **Ownership boundary tested.** A cross-user stop returns `404` and leaves the victim's session `active`; history and active-session reads are scoped to the requester. - **Web-only, no Expo/tunnel** is restated in the header, architecture, and run instructions; the worker app's dependency list contains no Expo/RN/ngrok packages.