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