# 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.