From 27ae67449b7d005b8efe1b57c1f65faf17872eb1 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 15:07:43 +0200 Subject: [PATCH] docs(plan): Phase 1 worker-timing implementation plan --- docs/plans/phase-1-worker-timing.md | 1080 +++++++++++++++++++++++++++ 1 file changed, 1080 insertions(+) create mode 100644 docs/plans/phase-1-worker-timing.md diff --git a/docs/plans/phase-1-worker-timing.md b/docs/plans/phase-1-worker-timing.md new file mode 100644 index 0000000..c014ca0 --- /dev/null +++ b/docs/plans/phase-1-worker-timing.md @@ -0,0 +1,1080 @@ +# Phase 1 — Worker Timing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` +> (recommended) or `superpowers:executing-plans` to implement this plan task-by-task, and +> `superpowers:test-driven-development` for every task. Steps use checkbox (`- [ ]`) syntax for tracking. +> Implement 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. + +**Goal:** Deliver "Worker timing" end-to-end. The backend (`apps/api`) gains domain tables (activities, +work-sessions), a user-scoped REST surface for managing activities and starting/stopping/discarding +server-authoritative work sessions, a history list, an "active session" recovery endpoint, and a CSV +export — all behind the existing better-auth bearer session. A fresh, lean Expo Router app +(`apps/mobile`, package `@solelog/mobile`) is added that logs in, attaches the bearer token to every +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; all backend endpoints are user-scoped and covered by vitest; the mobile app runs on Expo +web and on a device via Expo Go, with jest-expo unit tests and a clean `tsc --noEmit`. + +**Architecture:** The backend remains the single owner of auth + DB (Decision A from the roadmap). The +mobile app 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 phone restart and can be +recovered. Request/response shapes are zod schemas in `packages/shared`, imported by both `apps/api` +(validation) and `apps/mobile` (typed client). All new domain routes resolve the user from the +better-auth session (the exact `auth.api.getSession({ headers })` pattern already used by +`src/routes/me.ts`) and scope every query to that `user_id`; no token → `401`. + +**Tech Stack (already installed — these versions are authoritative; do not bump):** + +| Package | Installed version | Notes | +|---|---|---| +| `hono` | 4.12.25 | router + `hono/cors` middleware | +| `@hono/node-server` | 1.19.14 | server entry | +| `better-auth` | 1.6.18 | bearer plugin; `set-auth-token` response header on sign-in | +| `drizzle-orm` | 0.36.4 | **pinned — do NOT bump (tracked as SL-9)** | +| `drizzle-kit` | 0.30.6 | **pinned — do NOT bump (tracked as SL-9)** | +| `@libsql/client` | 0.14.0 | SQLite driver (no native build) | +| `zod` | 3.25.76 | contracts | +| `vitest` | 3.2.6 | backend tests | +| `typescript` | 5.9.3 | `tsc --noEmit` | +| `tsx` | 4.22.4 | run/seed scripts | + +Mobile (to be added, latest within each major at install time, pinned exact after install): +`expo` (SDK 54 line), `expo-router`, `react`, `react-native`, `react-dom`, `react-native-web`, +`expo-secure-store`, `expo-status-bar`, `@tanstack/react-query`, `@solelog/shared` (workspace:*), and +dev deps `jest-expo`, `jest`, `@testing-library/react-native`, `react-test-renderer`, `typescript`, +`@types/react`, `@types/jest`. + +## Global Constraints + +- **Package manager:** Yarn 4.12.0 (Berry), `nodeLinker: node-modules`. Run from the repo root via + corepack: `corepack yarn install`, `corepack yarn …`. Workspaces are `apps/*` + `packages/*` + (already configured in root `package.json`). The mobile app simply lives at `apps/mobile`, so it is + picked up automatically. +- **Run commands** — backend from `apps/api` (`corepack yarn test`, `corepack yarn typecheck`), + mobile from `apps/mobile`. Git always as `git -C D:/Sven …`. +- **Do NOT modify the better-auth files** (`src/auth.ts`, the `user`/`session`/`account`/`verification` + tables in `src/db/schema.ts`) beyond *adding* new domain tables/relations to `schema.ts` and *reusing* + `auth`/`auth.api.getSession`. Do NOT regenerate the auth tables. +- **Do NOT bump `drizzle-orm` / `drizzle-kit`** (pinned; SL-9). Use the installed API only. +- **Keep `apps/api` green:** the existing 4 test files / 5 tests must stay passing. Run the full suite + after every backend task. +- **Strict TDD, no test weakening.** If installed-library behaviour differs from any sample code in this + plan, the **installed library is authoritative** — adapt the code and make the *real* test pass; never + loosen an assertion to paper over a real bug. +- **Mobile is greenfield and minimal.** Do NOT restore the deleted Create/Anything export, its + `__create` plumbing, the web-sandbox iframe layer, analytics/Sentry, patched deps, or any unused + library (ads/IAP/maps/3D/audio/sensors/lucide/NativeWind). Add a dependency only when a task needs it. +- **Out of scope (do NOT build):** workbenches / QR scanning (Phase 4), the admin web panel (Phase 3), + admin user-management / roles beyond per-user scoping (Phase 2), offline-first, push notifications. +- **Commit style:** Conventional Commits, one commit per task (or per step where a task says so). + Commit only after that task's tests are green. + +## SQLite storage & cross-cutting decisions (resolved here, referenced by tasks) + +1. **`insole_types` array storage.** SQLite has no array type. Store it with Drizzle + `text('insole_types', { mode: 'json' }).$type()`. drizzle-orm 0.36.4 serialises the JS + array to a JSON string on write and `JSON.parse`s on read, so route code sees a real `string[]`. The + zod contract validates it as `z.array(InsoleType)`. **Never** filter inside SQL on this column; the + `?insole_type=` filter on `GET /api/activities` is applied **in JS** after fetching the user-visible + rows (the dataset is tiny — a handful of activities). Document this in a code comment. +2. **Timestamps.** Reuse the better-auth convention already in `schema.ts`: + `integer({ mode: 'timestamp_ms' })` (epoch-ms; Drizzle maps to/from `Date`). `start_time` is set at + `start`; `end_time` is `null` while active. The API contract serialises timestamps as **ISO-8601 + strings** (`Date.toISOString()`), so the mobile client and CSV are timezone-explicit (UTC). The + wire/JSON shape is always ISO strings; the DB stores epoch-ms. +3. **`duration_seconds`.** Server-authoritative and **computed on stop** as + `Math.round((end_time - start_time) / 1000)` (whole seconds). This differs deliberately from the + legacy client-tick count (see `docs/reference/legacy-lessons-and-gotchas.md` §6 — tick counting + under-counts when backgrounded). The mobile timer is display-only; the server number is the source of + truth. `notes` and a future pause model are out of scope for Phase 1 (pause does not change the + server session; it only freezes the on-screen display). +4. **CSV format.** Match `docs/reference/legacy-backend.md` §4 as closely as the new (user-scoped, + completed-only) data allows: `text/csv; charset=utf-8`, + `Content-Disposition: attachment; filename="insole-production-report.csv"`, every cell quoted with + `"` doubling, rows joined with `\n`, ordered `start_time ASC`. Columns are reduced to the set this + ticket specifies (activity name, insole type, pair count, start, end, duration_seconds) — see Backend + Task 7 for the exact header row. Format dates/times explicitly in UTC ISO to avoid the legacy + server-timezone fragility. +5. **CORS.** Add `hono/cors` so Expo web (a browser origin, e.g. `http://localhost:8081`) can call the + API cross-origin with `Authorization: Bearer …`. Because auth is **bearer-token only** for the + mobile/web client (no cookies), CORS does not need `credentials: true`; allow the `Authorization` and + `Content-Type` request headers and expose `set-auth-token`. The allow-list is env-driven and kept + **consistent with better-auth `trustedOrigins`** by reading the same env var (see Backend Task 8). +6. **`EXPO_PUBLIC_BASE_URL`.** The mobile client's typed API client reads + `process.env.EXPO_PUBLIC_BASE_URL` and defaults to `http://localhost:3000`. For device testing over + LAN, set it to the PC's LAN IP (e.g. `http://192.168.1.50:3000`) in `apps/mobile/.env`. The + `EXPO_PUBLIC_` prefix makes Expo inline it into the bundle. Document both values in `.env.example`. + +## Domain data model (added to `apps/api/src/db/schema.ts`) + +``` +activities + id integer PK autoincrement + name text NOT NULL + insole_types text(json) NOT NULL -- InsoleType[] subset of 'Kurk'|'Berk'|'3D' + created_at integer timestamp_ms DEFAULT now NOT NULL + +work_sessions + id integer PK autoincrement + user_id text NOT NULL FK -> user.id ON DELETE CASCADE + activity_id integer NOT NULL FK -> activities.id + insole_type text NOT NULL -- 'Kurk'|'Berk'|'3D' + pair_count integer NOT NULL DEFAULT 2 + start_time integer timestamp_ms NOT NULL + end_time integer timestamp_ms NULL -- null = active + duration_seconds integer NULL -- null until stopped + status text NOT NULL DEFAULT 'active' -- 'active'|'completed'|'discarded' + source text NOT NULL DEFAULT 'app' -- 'app'|'manual' + notes text NULL + created_at integer timestamp_ms DEFAULT now NOT NULL +``` + +Indices: `work_sessions(user_id)`, `work_sessions(activity_id)`, `work_sessions(user_id, status)`. + +--- + +# Backend Task 1: Domain contracts in `packages/shared` + +**Files:** +- Modify: `packages/shared/src/index.ts` +- Create: `packages/shared/test/contracts.test.ts` +- Modify: `packages/shared/package.json` (add `vitest` devDep + `test` script) and create + `packages/shared/vitest.config.ts` + +**Interfaces produced** (all exported as `const` zod schema + inferred `type` of the same name, the +existing convention in this file): + +```ts +// enums +InsoleType = z.enum(['Kurk', 'Berk', '3D']) +SessionStatus = z.enum(['active', 'completed', 'discarded']) +SessionSource = z.enum(['app', 'manual']) + +// Activity (response shape; timestamps are ISO strings on the wire) +Activity = z.object({ + id: z.number().int(), + name: z.string(), + insole_types: z.array(InsoleType), + created_at: z.string(), // ISO +}) + +// Activity write payloads +CreateActivityInput = z.object({ + name: z.string().trim().min(1), + insole_types: z.array(InsoleType).min(1), +}) +UpdateActivityInput = CreateActivityInput // same shape + +// WorkSession (response shape) +WorkSession = z.object({ + id: z.number().int(), + user_id: z.string(), + activity_id: z.number().int(), + activity_name: z.string(), // joined from activities.name + insole_type: InsoleType, + pair_count: z.number().int(), + start_time: z.string(), // ISO + end_time: z.string().nullable(),// ISO or null + duration_seconds: z.number().int().nullable(), + status: SessionStatus, + source: SessionSource, + notes: z.string().nullable(), + created_at: z.string(), +}) + +// Session write payloads +StartSessionInput = z.object({ + activity_id: z.number().int(), + insole_type: InsoleType, + pair_count: z.number().int().positive().default(2), +}) + +// list responses +ActivityList = z.array(Activity) +WorkSessionList = z.array(WorkSession) +``` + +Keep the existing `HealthResponse`, `PublicUser`, `MeResponse` exports untouched. + +- [ ] **Step 1 (test first):** Create `packages/shared/vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'node' } }); +``` + +Add to `packages/shared/package.json`: `"scripts": { "test": "vitest run" }` and +`"devDependencies": { "vitest": "^3.0.0" }`. Run `corepack yarn install` from the repo root. + +- [ ] **Step 2 (test first):** Write `packages/shared/test/contracts.test.ts` asserting: + - `InsoleType.parse('Kurk')` succeeds; `InsoleType.safeParse('Leer').success === false`. + - `CreateActivityInput.parse({ name: ' Leerrand ', insole_types: ['Kurk'] })` returns + `name: 'Leerrand'` (trim) and the types array. + - `CreateActivityInput.safeParse({ name: '', insole_types: ['Kurk'] }).success === false` (empty name). + - `CreateActivityInput.safeParse({ name: 'X', insole_types: [] }).success === false` (≥1 type). + - `StartSessionInput.parse({ activity_id: 1, insole_type: 'Berk' }).pair_count === 2` (default applied). + - `StartSessionInput.safeParse({ activity_id: 1, insole_type: 'Berk', pair_count: 0 }).success === false`. + - `WorkSession.parse()` + succeeds and `WorkSession.safeParse().success === false`. + + Run `corepack yarn workspace @solelog/shared test` — it must fail (schemas don't exist yet). + +- [ ] **Step 3:** Implement the schemas/types in `packages/shared/src/index.ts`. Re-run the test until + green. Run the existing `apps/api` suite (`corepack yarn workspace @solelog/api test`) to confirm the + new exports didn't break the imports in `health.ts`/`me.ts` (they shouldn't — only additions). + +- [ ] **Step 4:** `corepack yarn workspace @solelog/api typecheck` (must stay clean), then commit: + `git -C D:/Sven add packages/shared && git -C D:/Sven commit -m "feat(shared): Phase 1 activity & work-session zod contracts"` + +--- + +# Backend Task 2: Domain tables + migration + seed data + +**Files:** +- Modify: `apps/api/src/db/schema.ts` (ADD `activities`, `work_sessions` tables + relations; do NOT touch + the auth tables above them) +- Create: `apps/api/src/db/seed.ts` +- Modify: `apps/api/package.json` (add `"db:seed": "tsx src/db/seed.ts"` script) +- Generated: `apps/api/drizzle/0001_*.sql` (+ updated `drizzle/meta/*`) via `drizzle-kit generate` +- Create: `apps/api/test/schema.test.ts` + +**Interfaces produced:** `activities`, `workSessions` Drizzle tables (+ `activitiesRelations`, +`workSessionsRelations`) exported from `db/schema.ts`, consumed by all route tasks and the seed. + +- [ ] **Step 1:** Append to `apps/api/src/db/schema.ts` (after the existing auth tables/relations): + +```ts +import type { InsoleType } from '@solelog/shared'; +// (add `sqliteTable, text, integer, index` are already imported at the top) + +export const activities = sqliteTable('activities', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + // SQLite has no array type: store InsoleType[] as a JSON string. Drizzle + // (mode:'json') serialises on write and JSON.parses on read. NEVER filter on + // this column in SQL — the ?insole_type= filter is applied in JS (tiny dataset). + insoleTypes: text('insole_types', { mode: 'json' }).$type().notNull(), + 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').$type().notNull(), + pairCount: integer('pair_count').default(2).notNull(), + startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(), + endTime: integer('end_time', { mode: 'timestamp_ms' }), + durationSeconds: integer('duration_seconds'), + status: text('status').$type<'active' | 'completed' | 'discarded'>().default('active').notNull(), + source: text('source').$type<'app' | 'manual'>().default('app').notNull(), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => ({ + workSessionsUserIdIdx: index('work_sessions_user_id_idx').on(table.userId), + workSessionsActivityIdIdx: index('work_sessions_activity_id_idx').on(table.activityId), + workSessionsUserStatusIdx: index('work_sessions_user_status_idx').on(table.userId, table.status), + }) +); + +export const activitiesRelations = relations(activities, ({ many }) => ({ + sessions: many(workSessions), +})); +export const workSessionsRelations = relations(workSessions, ({ one }) => ({ + user: one(user, { fields: [workSessions.userId], references: [user.id] }), + activity: one(activities, { fields: [workSessions.activityId], references: [activities.id] }), +})); +``` + +> If `verbatimModuleSyntax`/`import type` for `InsoleType` causes a value/type clash, keep it as a +> `type` import (it is only used in `$type<>()`). If the installed drizzle-kit emits the JSON column or +> the autoincrement PK differently from this sample, **the generated SQL is authoritative** — keep the +> schema, regenerate, and adjust the schema only if generation errors. + +- [ ] **Step 2:** Generate the migration: from `apps/api`, `corepack yarn db:generate` + (`drizzle-kit generate`). This must produce a NEW `drizzle/0001_*.sql` containing `CREATE TABLE + activities` and `CREATE TABLE work_sessions` (and indices) and a `0001` journal entry — it must NOT + rewrite `0000`. Inspect the generated SQL and confirm it does not alter the auth tables. + +- [ ] **Step 3 (test first):** Write `apps/api/test/schema.test.ts`: + - imports `db` and `{ activities, workSessions }` from schema; + - inserts an activity with `insoleTypes: ['Kurk', 'Berk']`, reads it back, and asserts the value is a + real array `['Kurk','Berk']` (proves JSON round-trips), and `createdAt instanceof Date`; + - inserts a `work_sessions` row referencing a created user + the activity with `endTime: null`, + reads it back, asserts `status === 'active'`, `pairCount === 2` (default), `endTime === null`, + `durationSeconds === null`. + - To have a `user.id` to reference, create a user first via the better-auth sign-up route through + `createApp()` (as the auth test does), then `db.select().from(user)` to grab the id — OR insert a + user row directly with `db.insert(user).values({...})`. Prefer the sign-up route (closer to reality). + + Run `corepack yarn workspace @solelog/api test` — `schema.test.ts` fails (tables not migrated in the + test DB). NOTE: `test/setup.ts` runs `runMigrations()` which applies `./drizzle`, so once `0001` exists + it is applied automatically to the fresh `.tmp/test.db`. The failure before generating is the FK/table + missing; after Step 2 + implementing the schema it goes green. + +- [ ] **Step 4:** Create the seed script `apps/api/src/db/seed.ts` — idempotent (insert only if the + `activities` table is empty), realistic activities per `docs/reference/legacy-mobile-app.md` + (`Leerrand` example + plausible insole-production steps). Use this exact seed set: + +```ts +// apps/api/src/db/seed.ts +import { db } from './client'; +import { activities } from './schema'; + +const SEED_ACTIVITIES: { name: string; insole_types: ('Kurk' | 'Berk' | '3D')[] }[] = [ + { name: 'Uitsnijden', insole_types: ['Kurk', 'Berk', '3D'] }, + { name: 'Leerrand', insole_types: ['Kurk', 'Berk'] }, + { name: 'Slijpen', insole_types: ['Kurk', 'Berk', '3D'] }, + { name: 'Lijmen', insole_types: ['Kurk', 'Berk'] }, + { name: 'Bekleden', insole_types: ['Kurk', 'Berk', '3D'] }, + { name: 'Frezen', insole_types: ['3D'] }, + { name: 'Afwerken', insole_types: ['Kurk', 'Berk', '3D'] }, +]; + +export async function seed(): Promise { + const existing = await db.select().from(activities).limit(1); + if (existing.length > 0) { + console.log('activities already seeded — skipping'); + return; + } + await db.insert(activities).values(SEED_ACTIVITIES.map((a) => ({ name: a.name, insoleTypes: a.insole_types }))); + console.log(`seeded ${SEED_ACTIVITIES.length} activities`); +} + +import { pathToFileURL } from 'node:url'; +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + seed().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); }); +} +``` + +Add `"db:seed": "tsx src/db/seed.ts"` to `apps/api/package.json` scripts. (Export `seed` so route +tests can also seed programmatically.) + +- [ ] **Step 5 (test first):** add a `seed` test to `schema.test.ts` (or a new `seed.test.ts`): + import `{ seed }`, call it, assert `db.select().from(activities)` returns 7 rows with array + `insole_types`; call `seed()` again and assert it is still 7 (idempotent). Make it green. + +- [ ] **Step 6:** Full suite green (`corepack yarn workspace @solelog/api test` — now 6 files), typecheck + clean, then commit: + `git -C D:/Sven add apps/api packages && git -C D:/Sven commit -m "feat(api): activities & work_sessions tables, migration, seed"` + +--- + +# Backend Task 3: Auth helper + Activities routes (GET/POST) + +**Files:** +- Create: `apps/api/src/routes/_auth.ts` (a tiny shared helper) +- Create: `apps/api/src/routes/activities.ts` +- Modify: `apps/api/src/app.ts` (mount `activities`) +- Create: `apps/api/test/activities.test.ts` + +**Interfaces produced:** `requireUser(c)` helper returning the authenticated user or throwing a `401` +JSON response (used by every domain route); a Hono `activities` router exposing `GET /api/activities` +and `POST /api/activities`. Consumes `CreateActivityInput`, `Activity`, `ActivityList` from +`@solelog/shared` and the `activities` table. + +- [ ] **Step 1:** Create `apps/api/src/routes/_auth.ts`: + +```ts +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { auth } from '../auth'; + +export type AuthedUser = { id: string; email: string; name: string }; + +// Resolves the better-auth session from the request (bearer token or cookie), +// exactly like src/routes/me.ts. Throws a 401 JSON HTTPException when absent — +// callers wrap their body in try/catch or let Hono's onError surface it. +export async function requireUser(c: Context): Promise { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (!session) { + throw new HTTPException(401, { res: c.json({ error: 'Unauthorized' }, 401) }); + } + return { id: session.user.id, email: session.user.email, name: session.user.name }; +} +``` + +> Verify `hono/http-exception` and the `{ res }` option exist in hono 4.12.25. If the installed API +> differs, fall back to returning a `401` directly from each route (the `me.ts` pattern) instead of +> throwing — the **installed library is authoritative**. Whichever form is used, the observable contract +> is: no/invalid token → HTTP 401 `{ "error": "Unauthorized" }`. + +- [ ] **Step 2 (helpers):** Add a serialiser in `activities.ts` mapping a DB row → the `Activity` wire + shape (`created_at: row.createdAt.toISOString()`, `insole_types: row.insoleTypes`). Activities are a + **shared catalogue** (not per-user) in Phase 1 — they are managed in Instellingen and used by all + workers — so `GET/POST/PUT/DELETE /api/activities` require a valid session (401 without) but are NOT + filtered by `user_id`. (Per-activity ownership is not in the data model; only `work_sessions` carry + `user_id`.) The 401-without-token requirement still applies to every activities route. + +- [ ] **Step 3 (test first):** Write `apps/api/test/activities.test.ts`. Add a shared test helper inline + (or in `test/helpers.ts`, see Task 5) that signs a user up + in and returns the bearer token: + +```ts +async function tokenFor(app, email) { + const json = { 'content-type': 'application/json' }; + await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json, + body: JSON.stringify({ email, password: 'sterk-wachtwoord-123', name: email.split('@')[0] }) }); + const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json, + body: JSON.stringify({ email, password: 'sterk-wachtwoord-123' }) }); + return signin.headers.get('set-auth-token'); +} +const authHeaders = (t) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' }); +``` + + Tests: + - `GET /api/activities` **without** a token → `401`. + - `POST /api/activities` without a token → `401`. + - With a token, `POST /api/activities` `{ name: 'Leerrand', insole_types: ['Kurk','Berk'] }` → `201` + (or `200`), body validates against `Activity`, `insole_types` is `['Kurk','Berk']`. + - `POST` with `{ name: '', insole_types: ['Kurk'] }` → `400`. + - `POST` with `{ name: 'X', insole_types: [] }` → `400`. + - `GET /api/activities` with a token returns an array including the created activity, validates against + `ActivityList`. + - `GET /api/activities?insole_type=3D` returns only activities whose `insole_types` include `3D` + (create one `['3D']` and one `['Kurk']`, assert filtering happens in JS). + + Run — fails (route not mounted). + +- [ ] **Step 4:** Implement `activities.ts`: + - `GET /api/activities`: `requireUser(c)`; read optional `c.req.query('insole_type')`, validate it with + `InsoleType.safeParse` (ignore if invalid/absent); `db.select().from(activities).orderBy(activities.name)`; + if filter present, `rows.filter(r => r.insole_types.includes(filter))` **in JS**; map to `Activity[]`; + `c.json(list)`. + - `POST /api/activities`: `requireUser(c)`; parse body with `CreateActivityInput.safeParse`; on failure + `c.json({ error: 'Invalid input' }, 400)`; insert `{ name, insoleTypes: insole_types }` with + `.returning()`; map and `c.json(activity, 201)`. + + Mount in `app.ts`: `app.route('/', activities);` (after `me`). Re-run until green. + +- [ ] **Step 5:** Full suite + typecheck green, commit: + `git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET/POST /api/activities (user-authed)"` + +--- + +# Backend Task 4: Activities routes (PUT/DELETE) + +**Files:** +- Modify: `apps/api/src/routes/activities.ts` (add `PUT`/`DELETE /api/activities/:id`) +- Modify: `apps/api/test/activities.test.ts` + +**Interfaces:** `PUT /api/activities/:id` (consumes `UpdateActivityInput`, returns `Activity`), +`DELETE /api/activities/:id` (returns `{ success: true }`). + +- [ ] **Step 1 (test first):** Add tests: + - `PUT` without token → `401`; `DELETE` without token → `401`. + - `PUT /api/activities/:id` with `{ name: 'Slijpen', insole_types: ['3D'] }` updates and returns the + row (validates `Activity`, name + types changed). + - `PUT` a non-existent id (e.g. `999999`) → `404 { error: 'Activity not found' }`. + - `PUT` with empty name → `400`. + - `DELETE /api/activities/:id` → `200 { success: true }`; a subsequent `GET` no longer lists it. + - Decide and TEST the cascade choice: deleting an activity that has `work_sessions` — Phase 1 keeps it + simple and **blocks** deletion when sessions reference it: return `409 { error: 'Activity in use' }` + if any `work_sessions.activity_id = :id` exists. Add a test: start a session against an activity, + then `DELETE` it → `409`; the activity still lists. (This avoids destroying a worker's history, + unlike the legacy cascade. Document the divergence in a comment.) + +- [ ] **Step 2:** Implement: + - `PUT`: `requireUser`; `UpdateActivityInput.safeParse` → `400`; `db.update(activities).set({ name, + insoleTypes }).where(eq(activities.id, id)).returning()`; empty result → `404`; else map + `c.json`. + Parse `id` with `Number(c.req.param('id'))`; non-numeric → `404`. + - `DELETE`: `requireUser`; check `db.select().from(workSessions).where(eq(workSessions.activityId, + id)).limit(1)`; if found → `409`; else `db.delete(activities).where(eq(activities.id, id))`; + `c.json({ success: true })`. + +- [ ] **Step 3:** Green + typecheck, commit: + `git -C D:/Sven commit -am "feat(api): PUT/DELETE /api/activities with in-use guard"` + +--- + +# Backend Task 5: Sessions lifecycle — start / stop / discard + +**Files:** +- Create: `apps/api/test/helpers.ts` (extract `tokenFor`, `authHeaders` for reuse) +- Create: `apps/api/src/routes/sessions.ts` +- Modify: `apps/api/src/app.ts` (mount `sessions`) +- Create: `apps/api/test/sessions.test.ts` + +**Interfaces produced:** a `sessions` Hono router with +`POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `POST /api/sessions/:id/discard`. +Consumes `StartSessionInput`, returns `WorkSession`. A `toWorkSession(row, activityName)` serialiser +(ISO timestamps, `null`-safe `end_time`/`duration_seconds`). + +- [ ] **Step 1:** Create `apps/api/test/helpers.ts` exporting `tokenFor(app, email)` and + `authHeaders(token)` (move them out of `activities.test.ts` and import them there too, keeping that + suite green). + +- [ ] **Step 2 (test first):** Write `apps/api/test/sessions.test.ts`. Seed an activity (call `seed()` or + POST one). Cases: + - `POST /api/sessions/start` without token → `401`. + - `POST /api/sessions/start` `{ activity_id, insole_type:'Kurk', pair_count:3 }` → `201`, validates + `WorkSession`: `status:'active'`, `end_time:null`, `duration_seconds:null`, `source:'app'`, + `pair_count:3`, `activity_name` set, `user_id` = caller. + - `POST /api/sessions/start` with `pair_count` omitted defaults to `2`. + - `POST /api/sessions/start` with an `activity_id` that does not exist → `404 + { error: 'Activity not found' }`. + - `POST /api/sessions/start` with `insole_type:'Kurk'` but the activity does NOT support `'Kurk'` → + `400 { error: 'Insole type not valid for activity' }` (create an activity `['3D']`, start with + `'Kurk'`). Add a test. + - **Stop lifecycle:** start a session, then `POST /api/sessions/:id/stop` → `200`, + `status:'completed'`, `end_time` is a non-null ISO string, `duration_seconds` is an integer `>= 0`. + Because start/stop happen within the same test tick, assert `duration_seconds >= 0` (not strictly + positive) and that `end_time >= start_time`. + - **Stop is owner-scoped:** user A starts a session; user B (`tokenFor(app,'b@…')`) calls + `POST /api/sessions/:idOfA/stop` → `404` (B must not learn A's session exists; treat + not-owned == not-found). The session stays `active` for A. + - **Double-stop rejected:** stop a session, stop it again → `409 { error: 'Session already closed' }`. + - **Discard:** start a session, `POST /api/sessions/:id/discard` → `200`, `status:'discarded'`. + Discarding a non-owned session → `404`. Discarding an already-closed session → `409`. + - `:id` non-numeric or unknown → `404`. + +- [ ] **Step 3:** Implement `sessions.ts`: + - `start`: `requireUser`; `StartSessionInput.safeParse(body)` → `400`; load the activity by id, `404` + if missing; if `!activity.insole_types.includes(input.insole_type)` → `400`; insert + `{ userId, activityId, insoleType, pairCount, startTime: new Date(), status:'active', + source:'app' }` with `.returning()`; `c.json(toWorkSession(row, activity.name), 201)`. + - `stop`: `requireUser`; parse id; load the row `where(and(eq(id), eq(userId, user.id)))`; missing → + `404`; if `row.status !== 'active' || row.endTime !== null` → `409`; compute `end = new Date()`, + `duration = Math.max(0, Math.round((end.getTime() - row.startTime.getTime()) / 1000))`; + `db.update(workSessions).set({ endTime: end, durationSeconds: duration, status: 'completed' }) + .where(eq(workSessions.id, id)).returning()`; join the activity name; `c.json(...)`. + - `discard`: `requireUser`; same ownership load; `409` if not `active`; set `status:'discarded'` + (leave `end_time`/`duration` null — it was thrown away); return the row. + - Use `import { and, eq } from 'drizzle-orm'`. For the activity name, either a join or a second select + on `activities` by `activityId`. Mount `app.route('/', sessions)`. + +- [ ] **Step 4:** Green + typecheck, commit: + `git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): work-session start/stop/discard (owner-scoped, server-authoritative duration)"` + +--- + +# Backend Task 6: Sessions reads — history + active recovery + +**Files:** +- Modify: `apps/api/src/routes/sessions.ts` (add `GET /api/sessions`, `GET /api/sessions/active`) +- Modify: `apps/api/test/sessions.test.ts` + +> **Routing order matters:** register `GET /api/sessions/active` BEFORE any `/:id`-style route so +> `active` is not captured as an id. (The lifecycle routes are all under `/api/sessions/:id/`, so +> there is no direct conflict, but keep `active` explicit and first among GETs.) + +**Interfaces:** `GET /api/sessions` → `WorkSessionList` (caller's sessions, all statuses, newest first by +`start_time`, each with `activity_name`). `GET /api/sessions/active` → `WorkSessionList` (the caller's +`status:'active'` sessions only, newest first) for crash/recovery on app launch. + +- [ ] **Step 1 (test first):** Add tests: + - `GET /api/sessions` without token → `401`. `GET /api/sessions/active` without token → `401`. + - **Ownership scoping:** A starts+stops one session and starts a second (active); B starts one. `GET + /api/sessions` as A returns exactly A's 2 sessions (and none of B's); validates `WorkSessionList`; + ordered newest-first by `start_time` (assert the active/newer one is `[0]`). + - `GET /api/sessions/active` as A returns exactly the 1 active session; after stopping it, returns `[]`. + - Each returned item has `activity_name` populated (join correctness). + +- [ ] **Step 2:** Implement: + - `GET /api/sessions`: `requireUser`; select `work_sessions` joined to `activities` + `where(eq(workSessions.userId, user.id)).orderBy(desc(workSessions.startTime))`; map → `WorkSession[]`. + - `GET /api/sessions/active`: same but `and(eq(userId), eq(status, 'active'))`. + - Use `import { desc } from 'drizzle-orm'`. Prefer a Drizzle `innerJoin(activities, eq(...))` selecting + explicit columns + `activities.name` so the serialiser has the name without a second query. + +- [ ] **Step 3:** Green + typecheck, commit: + `git -C D:/Sven commit -am "feat(api): GET /api/sessions history + /api/sessions/active recovery"` + +--- + +# Backend Task 7: CSV export + +**Files:** +- Create: `apps/api/src/routes/export.ts` +- Modify: `apps/api/src/app.ts` (mount `export`) +- Create: `apps/api/test/export.test.ts` + +**Interfaces:** `GET /api/export` → `text/csv` of the **caller's completed** sessions, ordered +`start_time ASC`. Header row (exact): +`"Activity","Insole Type","Pair Count","Start","End","Duration (s)"`. Each data cell quoted with `"`, +embedded `"` doubled; rows joined with `\n`. `Start`/`End` are ISO-8601 UTC strings; `Duration (s)` is +`duration_seconds`. Response headers: `Content-Type: text/csv; charset=utf-8`, +`Content-Disposition: attachment; filename="insole-production-report.csv"`. + +- [ ] **Step 1 (test first):** Write `apps/api/test/export.test.ts`: + - `GET /api/export` without token → `401`. + - As user A: start+stop two sessions on a seeded activity (and start one active session that must NOT + appear). `GET /api/export` → `200`; `Content-Type` includes `text/csv`; `Content-Disposition` + contains `insole-production-report.csv`. Parse the body: first line equals the exact header above; + there are exactly 2 data rows (active one excluded); each row has 6 quoted fields; the `Activity` + cell equals the activity name; `Duration (s)` is the integer string. Ordered ascending by start. + - **Ownership:** B's completed sessions never appear in A's export (start+stop one as B; A's export + still has only its 2 rows). + - A cell-quoting test: create an activity whose name contains a `"` (e.g. `He"llo`), stop a session on + it, assert the CSV contains `"He""llo"`. + +- [ ] **Step 2:** Implement `export.ts`: + - `requireUser`; select completed sessions joined to activity name + `where(and(eq(userId, user.id), eq(status, 'completed'))).orderBy(asc(workSessions.startTime))`. + - `const quote = (v: unknown) => '"' + String(v ?? '').replace(/"/g, '""') + '"';` + - Header: `['Activity','Insole Type','Pair Count','Start','End','Duration (s)'].map(quote).join(',')`. + - Each row: `[activityName, insoleType, pairCount, startTime.toISOString(), + endTime?.toISOString() ?? '', durationSeconds ?? ''].map(quote).join(',')`. + - Body = `[header, ...rows].join('\n')`. Return via `c.body(csv, 200, { 'Content-Type': 'text/csv; + charset=utf-8', 'Content-Disposition': 'attachment; filename="insole-production-report.csv"' })` + (verify the `c.body(...)` header-object signature against hono 4.12.25; otherwise build a `Response` + with `new Response(csv, { headers })`). + +- [ ] **Step 3:** Green + typecheck, commit: + `git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET /api/export CSV of completed sessions (owner-scoped)"` + +--- + +# Backend Task 8: CORS + trusted-origins consistency + +**Files:** +- Modify: `apps/api/src/env.ts` (add `CORS_ORIGINS`) +- Modify: `apps/api/src/app.ts` (apply `hono/cors`) +- Modify: `apps/api/src/auth.ts` (derive `trustedOrigins` from the SAME env) — *adding to the + trustedOrigins array is allowed; do NOT change any other auth option* +- Modify: `apps/api/.env.example` and `docker-compose.yml` (document `CORS_ORIGINS`) +- Create: `apps/api/test/cors.test.ts` + +**Interfaces:** `env.CORS_ORIGINS: string[]` (parsed from a comma-separated env var, defaulting to the +local dev origins). CORS middleware allows those origins; better-auth `trustedOrigins` includes the +same list so a browser client passes CSRF/origin checks. + +- [ ] **Step 1:** In `env.ts` add: + +```ts +CORS_ORIGINS: (process.env.CORS_ORIGINS ?? 'http://localhost:8081,http://localhost:19006,http://localhost:3000') + .split(',').map((s) => s.trim()).filter(Boolean), +``` + +(`8081` = Expo web/Metro default; `19006` = legacy Expo web; `3000` = the API's own origin.) + +- [ ] **Step 2:** In `auth.ts`, change `trustedOrigins` to merge the existing values with + `env.CORS_ORIGINS` (dedup). Do NOT alter `secret`, `database`, `emailAndPassword`, or `plugins`. + Example: `trustedOrigins: Array.from(new Set([env.BETTER_AUTH_URL, 'http://localhost:3000', + ...env.CORS_ORIGINS]))`. + +- [ ] **Step 3 (test first):** Write `apps/api/test/cors.test.ts`: + - A browser **preflight**: `OPTIONS /api/activities` with headers `Origin: http://localhost:8081`, + `Access-Control-Request-Method: GET`, `Access-Control-Request-Headers: authorization` → + response has `Access-Control-Allow-Origin: http://localhost:8081` and the allow-headers list + includes `authorization` (case-insensitive check). + - A real `GET /api/activities` (no token) with `Origin: http://localhost:8081` still returns `401` + AND carries `Access-Control-Allow-Origin` (CORS headers present even on error responses). + - An origin NOT in the list (`http://evil.example`) does not get an `Access-Control-Allow-Origin: + http://evil.example` echo. (Assert the header is absent or not equal to the evil origin, per the + installed `hono/cors` behaviour — adapt the assertion to what the middleware actually does.) + +- [ ] **Step 4:** In `app.ts`, apply CORS **before** the routes: + +```ts +import { cors } from 'hono/cors'; +// inside createApp(), first: +app.use('*', cors({ + origin: env.CORS_ORIGINS, + allowHeaders: ['Authorization', 'Content-Type'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + exposeHeaders: ['set-auth-token'], +})); +``` + +> Bearer-only auth means `credentials: true` is NOT required. Verify the `origin` option accepts a +> string array in hono 4.12.25 (it does; otherwise pass a function that returns the origin when it is in +> `env.CORS_ORIGINS`). The **installed middleware is authoritative** — shape the config + the test +> assertions to its real behaviour. + +- [ ] **Step 5:** Update `.env.example` (add `CORS_ORIGINS=http://localhost:8081,http://localhost:3000`) + and add the same env to `docker-compose.yml`. Full suite green, typecheck clean, commit: + `git -C D:/Sven add apps/api docker-compose.yml && git -C D:/Sven commit -m "feat(api): CORS for browser clients, trustedOrigins consistency"` + +--- + +# Backend Task 9: Backend wrap-up — full green + manual smoke + +**Files:** none (verification only) — optionally a short `apps/api/README.md` note. + +- [ ] **Step 1:** From `apps/api`: `corepack yarn test` (ALL suites: the original 4 files + the new + `schema`, `seed`, `activities`, `sessions`, `export`, `cors`), `corepack yarn typecheck`, + `npx oxlint` (root config). All must pass. Record the real counts. +- [ ] **Step 2 (manual smoke, real commands):** `rm -rf apps/api/data` then from `apps/api`: + `corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start`. With `curl` (or + PowerShell `Invoke-RestMethod`): sign up, sign in (capture `set-auth-token`), then `GET + /api/activities`, `POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `GET /api/sessions`, + `GET /api/export` — confirm each works with the bearer header and `401` without. Do not commit the + local `data/` DB (it is gitignored). +- [ ] **Step 3:** Commit any doc note only: + `git -C D:/Sven commit -am "docs(api): Phase 1 backend smoke notes"` (skip if nothing changed). + +--- + +# Mobile Task 1: Scaffold the fresh Expo app + workspace wiring + +**Files (create):** +- `apps/mobile/package.json`, `apps/mobile/app.json`, `apps/mobile/tsconfig.json`, + `apps/mobile/babel.config.js`, `apps/mobile/.env.example`, `apps/mobile/.gitignore`, + `apps/mobile/index.ts` (Expo Router entry), `apps/mobile/src/app/_layout.tsx` (placeholder), + `apps/mobile/metro.config.js` (default, monorepo-aware), `apps/mobile/jest.config.js`, + `apps/mobile/jest.setup.ts`. + +**Interfaces produced:** the `@solelog/mobile` workspace, runnable with `npx expo start` / +`--web`, typechecking with `tsc --noEmit`, and a jest-expo harness that runs. + +- [ ] **Step 1:** Create `apps/mobile/package.json`. Use Expo Router's standard entry. Minimal deps ONLY: + +```json +{ + "name": "@solelog/mobile", + "version": "0.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "web": "expo start --web", + "android": "expo start --android", + "ios": "expo start --ios", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "dependencies": { + "@solelog/shared": "workspace:*", + "@tanstack/react-query": "^5.0.0", + "expo": "*", + "expo-router": "*", + "expo-secure-store": "*", + "expo-status-bar": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*" + }, + "devDependencies": { + "@testing-library/react-native": "*", + "@types/jest": "*", + "@types/react": "*", + "jest": "*", + "jest-expo": "*", + "react-test-renderer": "*", + "typescript": "*" + } +} +``` + +> **Install correctly with Expo's resolver** so versions match the SDK: from `apps/mobile`, prefer +> `npx create-expo-app@latest . --template blank-typescript` into a temp dir to learn the exact pinned +> versions, OR run `npx expo install expo-router react-native-web react-dom expo-secure-store +> expo-status-bar` and `npx expo install --dev jest-expo`, which writes SDK-correct versions. Then PIN +> the resolved exact versions (no `^`/`*`) per the repo convention, add `@solelog/shared` and +> `@tanstack/react-query`, and run `corepack yarn install` from the repo root so the workspace links. +> The `*` above are placeholders to be replaced by the resolved exact versions — **do not ship `*`**. + +- [ ] **Step 2:** `apps/mobile/app.json` — minimal Expo config with the router plugin and web bundler: + +```json +{ + "expo": { + "name": "SoleLog", + "slug": "solelog", + "scheme": "solelog", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "web": { "bundler": "metro", "output": "single" }, + "plugins": ["expo-router"] + } +} +``` + +- [ ] **Step 3:** `apps/mobile/tsconfig.json` extends `expo/tsconfig.base`, `strict: true`, and a + `@/*` → `src/*` path alias: + +```json +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} +``` + +- [ ] **Step 4:** `apps/mobile/babel.config.js`: `module.exports = (api) => { api.cache(true); return { + presets: ['babel-preset-expo'] }; };`. `metro.config.js`: default Expo config made monorepo-aware + (watch the repo root, resolve `node_modules` from both app and root) so `@solelog/shared` resolves: + +```js +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '../..'); +const config = getDefaultConfig(projectRoot); +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; +module.exports = config; +``` + +- [ ] **Step 5:** `apps/mobile/jest.config.js` (`preset: 'jest-expo'`, + `setupFilesAfterEnv: ['/jest.setup.ts']`, a `transformIgnorePatterns` that lets + `@solelog/shared`, expo, react-native, `@tanstack` transform). `jest.setup.ts` imports + `@testing-library/react-native` matchers if needed. Add a trivial smoke test + `apps/mobile/src/__tests__/smoke.test.ts` (`expect(1 + 1).toBe(2)`) and run `corepack yarn workspace + @solelog/mobile test` to prove the harness runs. + +- [ ] **Step 6:** Placeholder `src/app/_layout.tsx` rendering a `` (expo-router) wrapped in a + React Query provider (defined in Mobile Task 2) — for now a bare `` so `expo start` boots. + `.env.example`: `EXPO_PUBLIC_BASE_URL=http://localhost:3000` plus a commented LAN example + `# EXPO_PUBLIC_BASE_URL=http://192.168.1.50:3000`. `.gitignore`: `.expo/`, `node_modules/`, `dist/`, + `*.log`, `.env`. + +- [ ] **Step 7:** Verify: from `apps/mobile`, `corepack yarn typecheck` clean and `corepack yarn test` + green. (Do not block the plan on launching a simulator; `expo start --web` is exercised in Mobile + Task 6.) Commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): scaffold fresh @solelog/mobile Expo Router app + workspace wiring"` + +--- + +# Mobile Task 2: Token storage + typed API client + React Query provider + +**Files (create):** +- `apps/mobile/src/lib/tokenStore.ts`, `apps/mobile/src/lib/api.ts`, + `apps/mobile/src/lib/queryClient.tsx` +- `apps/mobile/src/lib/__tests__/api.test.ts`, `apps/mobile/src/lib/__tests__/tokenStore.test.ts` + +**Interfaces produced:** +- `tokenStore`: `getToken(): Promise`, `setToken(t: string): Promise`, + `clearToken(): Promise` — backed by `expo-secure-store` (`SecureStore.getItemAsync` etc.) on + native; on web SecureStore falls back to localStorage via Expo's own web implementation in SDK 54 + (no custom shim needed — verify; if SecureStore is unavailable on web in the installed SDK, guard with + `SecureStore.isAvailableAsync()` and fall back to `localStorage` inside `tokenStore`). +- `api`: a typed client. `baseUrl = process.env.EXPO_PUBLIC_BASE_URL ?? 'http://localhost:3000'`. + Methods (each attaches `Authorization: Bearer ` when a token is stored, sets + `Content-Type: application/json` for bodies, parses JSON, throws `ApiError { status, message }` on + non-2xx): + - `signUp(email, password)`, `signIn(email, password)` → on success reads the **`set-auth-token`** + response header and `setToken`s it; returns the user. + - `getActivities(insoleType?)`, `createActivity(input)`, `updateActivity(id, input)`, + `deleteActivity(id)`. + - `startSession(input)`, `stopSession(id)`, `discardSession(id)`, `getSessions()`, + `getActiveSessions()`. + - `exportUrl()` → returns the `${baseUrl}/api/export` string (the screen opens/shares it). + - Return types use `@solelog/shared` (`Activity`, `WorkSession`, etc.); validate responses with the + zod schemas where cheap (at least `WorkSession.parse` on session mutations) so contract drift fails + loudly in dev. + +- [ ] **Step 1 (test first):** `tokenStore.test.ts` — mock `expo-secure-store` (jest + `jest.mock('expo-secure-store')`) and assert `setToken`/`getToken`/`clearToken` call the right + SecureStore methods with a stable key (`'solelog-auth-token'`). Implement `tokenStore.ts`. Green. + +- [ ] **Step 2 (test first):** `api.test.ts` — mock `global.fetch`. Assert: + - `signIn` POSTs to `${baseUrl}/api/auth/sign-in/email` with the email/password body, reads the + `set-auth-token` header from the mocked response, and calls `tokenStore.setToken` with it + (mock `tokenStore`). + - `getActivities()` GETs `${baseUrl}/api/activities` with `Authorization: Bearer ` when a + token is present (mock `getToken` → `'tok'`), and WITHOUT the header when no token. + - `getActivities('3D')` appends `?insole_type=3D`. + - `startSession({activity_id:1,insole_type:'Kurk',pair_count:2})` POSTs to `/api/sessions/start` and + returns a parsed `WorkSession` (feed a valid fixture from the mock). + - A non-2xx response throws `ApiError` with the right `status`. + Implement `api.ts`. Green. + +- [ ] **Step 3:** `queryClient.tsx` exports a `QueryClient` (defaults: `staleTime` 5 min, `retry` 1, + `refetchOnWindowFocus: false` — matching the legacy app) and an `` wrapping + `QueryClientProvider`. Wire it into `src/app/_layout.tsx`. (No new test needed; covered by the screen + render smoke test in Mobile Task 6.) + +- [ ] **Step 4:** Typecheck + test green, commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): secure token store + typed API client + query provider"` + +--- + +# Mobile Task 3: Auth gate + login/sign-up screen + +**Files (create):** +- `apps/mobile/src/lib/auth.tsx` (an `AuthProvider` + `useAuth()` hook: `token`, `isReady`, `signIn`, + `signUp`, `signOut`) +- `apps/mobile/src/app/login.tsx` (the login/sign-up screen, Dutch) +- Modify: `apps/mobile/src/app/_layout.tsx` (gate: while `!isReady` render `null`/splash; if no token + redirect to `/login`; else render the tabs) +- `apps/mobile/src/app/(tabs)/_layout.tsx` (3-tab navigator placeholder so routing exists) +- `apps/mobile/src/lib/__tests__/auth.test.tsx` + +**Interfaces produced:** `useAuth()` context. On mount it calls `tokenStore.getToken()` to restore the +session (with a timeout escape hatch so a stuck read can't freeze launch — lesson from +`docs/reference/legacy-lessons-and-gotchas.md` §1), sets `isReady`. `signIn`/`signUp` delegate to +`api`, store the token, set `token` in state; `signOut` clears it. + +- [ ] **Step 1 (test first):** `auth.test.tsx` — render a component using `useAuth()` inside + `` with `api`/`tokenStore` mocked. Assert: starts `isReady:false`, becomes `isReady:true` + with `token` from `getToken`; calling `signIn` sets `token`; `signOut` clears it. Implement + `auth.tsx`. Green. + +- [ ] **Step 2:** Build `login.tsx` (RN `StyleSheet`, no extra libs): email `TextInput` + (`keyboardType:'email-address'`, `autoCapitalize:'none'`), password `TextInput` (`secureTextEntry`), + a primary button **`Inloggen`** ("Sign in") calling `signIn`, and a small text affordance + **`Account aanmaken`** ("Create account") that toggles to sign-up (button becomes **`Registreren`**). + On error show the message (Dutch fallback **`Inloggen mislukt`** / **`Registreren mislukt`**). On + success the auth state flips and the gate routes into the tabs. Use the blue palette from + `docs/reference/legacy-mobile-app.md` §2 for consistency. + +- [ ] **Step 3:** `_layout.tsx` gate + `(tabs)/_layout.tsx` (expo-router `Tabs` with three screens in + order — `index` titled **`Stopwatch`**, `history` titled **`Geschiedenis`**, `tasks` titled + **`Instellingen`**; tab tint `#2563EB`/`#6B7280`). Tabs may use simple text labels (NO `lucide`/icon + libraries — keep deps minimal; an emoji or omitted icon is fine). Add temporary stub screens for the + three tabs so navigation compiles. + +- [ ] **Step 4:** Typecheck + test green, commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): auth gate + Dutch login/sign-up screen + 3-tab shell"` + +--- + +# Mobile Task 4: Stopwatch screen (`(tabs)/index.tsx`) + +**Files (create/modify):** +- `apps/mobile/src/lib/timer.ts` (pure timing helpers — unit-testable, no React) +- `apps/mobile/src/app/(tabs)/index.tsx` (the Stopwatch screen) +- `apps/mobile/src/lib/__tests__/timer.test.ts` + +**Behaviour (from `docs/reference/legacy-mobile-app.md` §4), adapted to server-authoritative sessions:** +- `Type zool` segmented selector (`Kurk`/`Berk`/`3D`, default `Kurk`); changing it clears the chosen + activity. `Type handeling` dropdown listing activities filtered to the chosen zool + (`activity.insole_types.includes(insoleType)`); placeholder **`Kies een handeling...`**; empty-state + **`Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen.`** `Aantal zolen` + stepper (default `2`, min `1`). All three selectors lock while a session is active. +- Activities fetched via React Query `['activities']` → `api.getActivities()`. +- **Server-authoritative lifecycle:** + - Start (enabled only when an activity is chosen): `api.startSession({ activity_id, insole_type, + pair_count })`; store the returned `WorkSession` (its `id`, `start_time`) as the active session. + - The on-screen timer is **display-only**, computed by wall-clock delta from the server `start_time` + (`elapsed = now - new Date(session.start_time)`), updated every second via `setInterval`. Pause/resume + only freezes the *display* (do NOT call the server; Phase 1 has no server pause). Use + `timer.formatHMS(seconds)`. + - **Stop & Opslaan**: `api.stopSession(activeSession.id)`; on success invalidate `['sessions']` + + `['activeSessions']`, reset the timer (keep the selections, like legacy). + - **Annuleren** double-press discard (3 s arm window, **`Nogmaals tikken ter bevestiging`** when + armed): second tap calls `api.discardSession(activeSession.id)` and resets. +- **Recovery on launch:** `useQuery(['activeSessions'], api.getActiveSessions)`; if it returns a session, + adopt it as the active session and resume the display timer from its `start_time` (so a phone restart + doesn't lose an open session). Dutch strings exactly per the reference §7 inventory. + +- [ ] **Step 1 (test first):** `timer.test.ts` for pure helpers in `timer.ts`: + - `formatHMS(0) === '00:00:00'`, `formatHMS(65) === '00:01:05'`, `formatHMS(3661) === '01:01:01'`, + `formatHMS(360000) === '100:00:00'` (hours can exceed 99). + - `elapsedSeconds(startISO, nowMs)` returns whole seconds between an ISO start and a `now` epoch-ms, + floored, never negative (clamp to 0 if `now < start`). Test a 65 000 ms gap → `65`; a negative gap + → `0`. Implement `timer.ts`. Green. + +- [ ] **Step 2:** Build `index.tsx` using `timer.ts`, the API client, and React Query. State machine and + Dutch strings per §4/§4.9 of the reference, but every start/stop/discard is a server call as above. No + extra libraries (RN `Modal` for the picker sheet is fine; no animation lib required — a simple modal + list is acceptable for Phase 1). + +- [ ] **Step 3:** Typecheck + `timer.test.ts` green, commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): server-authoritative Stopwatch screen + timing helpers"` + +--- + +# Mobile Task 5: Geschiedenis (history) + Instellingen (settings) screens + +**Files (create/modify):** +- `apps/mobile/src/app/(tabs)/history.tsx` +- `apps/mobile/src/app/(tabs)/tasks.tsx` +- `apps/mobile/src/lib/format.ts` (`formatDuration`, `formatDate`, `formatTime`, `pluralInsoles`) + + `apps/mobile/src/lib/__tests__/format.test.ts` + +**History (`docs/reference/legacy-mobile-app.md` §5):** header **`Geschiedenis`** + an **`Exporteer +CSV`** action; list via React Query `['sessions']` → `api.getSessions()`. Each card: `activity_name`, +date/time line, badges for `insole_type`, pair count (**`inlegzool`**/**`inlegzolen`** singular/plural), +and `formatDuration(duration_seconds)` (`Xh Ym` / `Ym Zs` / `Zs`). Empty-state **`Nog geen opgeslagen +sessies.`** The CSV action opens `api.exportUrl()` — on web, open in a new tab / trigger download +(`window.open` / an ``); on native, use `Linking.openURL` (RN core `Linking`, no extra dep). +Failure alert title **`Fout`**, body **`Kan de export-URL niet openen`**. **Auth note:** the legacy +`/api/export` open used a plain URL with no header; the new export is bearer-protected, so on **native** +prefer fetching with the token and sharing the text, or append the token — simplest Phase 1 path: fetch +the CSV via `api` (with the bearer header) and write/share it; on **web**, fetch with the header and +trigger a Blob download. Document this divergence in a code comment (a bare `openURL` to a protected +endpoint would 401). + +**Settings (`docs/reference/legacy-mobile-app.md` §6):** header **`Instellingen`** + subtitle **`Beheer +handelingen per zooltype`**. "Add new handling" card (**`Nieuwe handeling toevoegen`**, name placeholder +**`Naam van de stap, bijv. Leerrand`**, **`Van toepassing op`** three type toggles default all three, +**`Stap toevoegen`** button). List **`Huidige stappen ({n})`** with edit (**`Opslaan`**/**`Annuleren`**) +and delete (confirm alert title **`Taak verwijderen`**, body per §6.5, buttons **`Annuleren`** / +**`Verwijderen`**). Wired to `api.createActivity`/`updateActivity`/`deleteActivity`, React Query key +`['activities']`. **Delete divergence:** the backend returns `409` when the activity is in use (Backend +Task 4) — surface that as an alert (Dutch **`Kan niet verwijderen: handeling is in gebruik.`**) instead +of assuming a cascade. Keep this screen minimal. + +- [ ] **Step 1 (test first):** `format.test.ts`: + - `formatDuration(45) === '45s'`, `formatDuration(200) === '3m 20s'`, `formatDuration(3900) === '1h 5m'`. + - `pluralInsoles(1) === '1 inlegzool'`, `pluralInsoles(2) === '2 inlegzolen'`. + - `formatDate`/`formatTime` return non-empty strings for a known ISO input (locale-tolerant: assert + they are strings of length > 0, not exact locale formatting). Implement `format.ts`. Green. + +- [ ] **Step 2:** Build `history.tsx` and `tasks.tsx` per the references, using `format.ts`, `api`, and + React Query. Dutch strings exact per the §7 inventory. + +- [ ] **Step 3:** Typecheck + `format.test.ts` green, commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): Geschiedenis + Instellingen screens + format helpers"` + +--- + +# Mobile Task 6: Component render smoke test + web/device run verification + +**Files (create):** +- `apps/mobile/src/app/__tests__/login.render.test.tsx` (component render smoke test) +- optionally `apps/mobile/src/app/(tabs)/__tests__/history.render.test.tsx` + +- [ ] **Step 1 (test first):** A jest-expo + `@testing-library/react-native` render test mounting the + `login.tsx` screen (with `api`/`auth` mocked) and asserting the Dutch strings render: e.g. + `getByText('Inloggen')` and the email/password inputs are present. (At least ONE component render smoke + test is required; add the history one if time permits, mocking `api.getSessions` to return a fixture + array and asserting an `activity_name` renders, plus the empty-state string for `[]`.) + +- [ ] **Step 2:** Make the render test(s) pass (fix providers/mocks as needed — wrap in + `AppQueryProvider` + `AuthProvider` as required). `corepack yarn workspace @solelog/mobile test` and + `corepack yarn workspace @solelog/mobile typecheck` both green. + +- [ ] **Step 3 (manual run verification, real commands):** + - Start the backend (`apps/api`: `corepack yarn db:migrate && corepack yarn db:seed && corepack yarn + start`). + - **Web target:** from `apps/mobile`, `EXPO_PUBLIC_BASE_URL=http://localhost:3000` then + `npx expo start --web`; in the browser: sign up, see seeded activities in Instellingen and the + Stopwatch handling picker, start → stop a session, see it in Geschiedenis, export CSV. Confirm CORS + lets the browser call `:3000` from the Expo web origin. + - **Device target:** set `EXPO_PUBLIC_BASE_URL=http://:3000` in `apps/mobile/.env`, + `npx expo start`, open in Expo Go over the same LAN, repeat the round-trip. (Backend already binds + all interfaces via `@hono/node-server`; ensure the host firewall allows `:3000`.) + - Record outcomes in the commit message / session notes. Do not fabricate; if a step fails, debug it + (`superpowers:systematic-debugging`) before claiming done. + +- [ ] **Step 4:** Commit: + `git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "test(mobile): login/history render smoke tests + web/device run verified"` + +--- + +# Phase 1 Definition of Done + +- Backend: all original tests still pass **plus** new suites (`schema`/`seed`, `activities`, `sessions`, + `export`, `cors`) — every endpoint covered for: 401 without token, ownership scoping (user A cannot + see/stop user B's session), start→stop lifecycle (server-computed `duration_seconds`), discard, and CSV + output. `tsc --noEmit` clean; `npx oxlint` clean. `drizzle-orm`/`drizzle-kit` unchanged; auth tables + untouched; one NEW migration (`0001`). +- Mobile: `@solelog/mobile` runs on Expo web and on a device via Expo Go; logs in against the backend and + attaches the bearer token; the three Dutch screens work against the live API; jest-expo unit tests + (api client, timer logic, format helpers, token store, auth) + at least one component render smoke test + pass; `tsc --noEmit` clean. No restored Create plumbing; no unused libraries. +- All work committed in small Conventional-Commit units as specified per task.