Files
solelog/docs/plans/phase-1-worker-timing.md

59 KiB
Raw Permalink Blame History

Phase 1 — Worker Timing Implementation Plan (Web Client)

For agentic workers: REQUIRED SUB-SKILL — use superpowers:test-driven-development for every task and superpowers:subagent-driven-development (or superpowers:executing-plans) to drive the plan task-by-task. Steps use checkbox (- [ ]) syntax. Strict TDD: write the test, watch it fail for the right reason, then write the code to make it pass. Never weaken, skip, or delete a test to make it pass. Run REAL commands; never fabricate output.

⚠ This plan REPLACES the earlier Expo/React-Native plan that previously lived at this path. The client in Phase 1 is a Vite + React + TypeScript single-page web app (PWA). There is NO Expo, NO React Native, NO react-native-web, NO ngrok / tunnelling anywhere in this phase.


Goal

Deliver "Worker timing" end-to-end as a backend plus a web client:

  • Backend (apps/api) gains domain tables (activities, work_sessions), a user-scoped REST surface to manage activities and to start / stop / discard server-authoritative work sessions, a history list, an "active session" recovery endpoint, and a CSV export — all behind the existing better-auth bearer session. Request/response shapes are zod schemas in packages/shared.
  • Client (apps/worker, package @solelog/worker) is a fresh, lean Vite + React + TS SPA, installable as a PWA, that logs in (email+password → bearer token in localStorage), attaches Authorization: Bearer <token> 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>  (token from /api/auth/sign-in/email)
             ▼
┌──────────────────────────┐
│  apps/api (Hono)          │   localhost:3000 — better-auth + Drizzle, CORS for :5173
│  better-auth (bearer) +   │
│  domain routes (scoped)   │
└────────────┬─────────────┘
             ▼
        ┌──────────┐
        │ SQLite   │  libsql file; activities + work_sessions + better-auth tables
        └──────────┘

Tech Stack (installed versions are AUTHORITATIVE — do NOT bump)

These were read from node_modules at planning time. If a code sample below disagrees with the installed API, the installed library wins — adapt the code and make the real test pass.

Package Installed version Notes
hono 4.12.25 router + hono/cors middleware (verified present)
@hono/node-server 1.x server entry (already used by apps/api/src/index.ts)
better-auth 1.6.18 bearer plugin; set-auth-token response header on sign-in
drizzle-orm 0.36.4 pinned — do NOT bump (SL-9); text, integer, index, eq, and, desc all verified
drizzle-kit 0.30.6 pinned — do NOT bump (SL-9)
@libsql/client 0.14.0 SQLite driver (no native build)
zod 3.25.76 contracts (shared)
vitest 3.2.6 backend + worker tests

Client deps to add (latest compatible at install time, pinned in apps/worker/package.json): vite, @vitejs/plugin-react, react, react-dom, react-router-dom, @tanstack/react-query, typescript, @solelog/shared (workspace:*); dev: vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, @types/react, @types/react-dom. A web app manifest for installability via a public/manifest.webmanifest plus two PNG icons referenced from index.html (NO vite-plugin-pwa, NO service worker — offline is out of scope). Keep deps minimal — no UI kitchen-sink libraries.

Global Constraints

  • Strict TDD. Test first; never weaken/skip/delete a test to pass it. Real commands only.
  • The client is a Vite + React PWA, NOT Expo. No Expo / React Native / react-native-web / ngrok.
  • Do NOT modify the better-auth config beyond using it (apps/api/src/auth.ts). You MAY add 'http://localhost:5173' to its trustedOrigins array (that is using it, and required for the cross-origin SPA) — but do not change plugins, hashing, or session config.
  • Do NOT bump drizzle-orm / drizzle-kit (pinned, tracked as SL-9). Use the installed API.
  • Keep apps/api green at every commit (yarn workspace @solelog/api test passes).
  • SQLite array storage: insole_types is stored via Drizzle text('insole_types', { mode: 'json' }) (libsql stores it as a JSON string; Drizzle (de)serialises to/from string[]). The shared zod schema validates the subset of 'Kurk' | 'Berk' | '3D'.
  • Timestamps: store start_time / end_time / created_at as integer({ mode: 'timestamp_ms' }) (epoch-ms, same convention better-auth uses in schema.ts). Contracts serialise them as ISO-8601 strings at the HTTP boundary.
  • Commands. Run git as git -C D:/Sven .... Run yarn from the repo root (Yarn 4.12.0 via corepack). Run a single workspace's tests with yarn workspace @solelog/api test / yarn workspace @solelog/worker test. Commit frequently — one commit per task minimum.
  • Migrations: generate with yarn workspace @solelog/api db:generate (drizzle-kit). Do NOT touch the existing better-auth migration (drizzle/0000_stiff_captain_britain.sql) — domain tables go in a NEW migration file. The test harness (apps/api/test/setup.ts) runs runMigrations() against a fresh ./.tmp/test.db before each run, so a new migration is picked up automatically.

Insole-type and seed reference (from docs/reference/legacy-mobile-app.md §3 and §6.2)

  • Valid insole types (verbatim, ordered): ['Kurk', 'Berk', '3D']. Default selected: 'Kurk'.
  • An activity with empty/missing insole_types defaults to all three.
  • Seed activities (realistic Dutch handeling names; Leerrand is the doc's example step):
    name insole_types
    Leerrand ['Kurk','Berk','3D']
    Frezen ['Kurk','Berk']
    Slijpen ['Kurk','Berk','3D']
    Bekleden ['Kurk','Berk','3D']
    Afwerken ['Kurk','Berk','3D']
    Printen ['3D']

CSV contract (from docs/reference/legacy-backend.md §4)

GET /api/export returns text/csv; charset=utf-8, Content-Disposition: attachment; filename="insole-production-report.csv". The user's completed sessions, ordered start_time ASC. Columns (in order), every cell and header quoted with " (embedded " doubled), rows joined with \n:

# Header Source / formatting
1 ID session id
2 Task activity name
3 Insole Type insole_type ?? 'Kurk'
4 No. of Insoles pair_count ?? 2
5 Date start_timetoLocaleDateString('nl-BE', { day:'2-digit', month:'2-digit', year:'numeric' })
6 Total Duration duration_secondsHH:MM:SS (zero-padded; hours can exceed 99)
7 Start Time start_timetoLocaleTimeString('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):

const quote = (value: unknown) => `"${String(value).replace(/"/g, '""')}"`;
function formatDuration(totalSeconds: number): string {
  const s = totalSeconds || 0;
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}

Backend Task 1: Domain schema + migration + shared contracts (activities & work_sessions)

Outcome: activities and work_sessions tables exist in a NEW migration; the zod contracts for both live in packages/shared; apps/api still green. No routes yet.

Files

  • apps/api/src/db/schema.ts — APPEND domain tables (do not touch the better-auth tables above).
  • packages/shared/src/index.ts — APPEND contracts.
  • apps/api/drizzle/0001_*.sql + apps/api/drizzle/meta/* — generated, committed.
  • apps/api/test/schema.test.ts — NEW test.

Schema (append to apps/api/src/db/schema.ts)

// ---- SoleLog domain tables (Phase 1) ----
export const activities = sqliteTable('activities', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  // subset of 'Kurk' | 'Berk' | '3D' — stored as a JSON string by libsql.
  insoleTypes: text('insole_types', { mode: 'json' })
    .$type<string[]>()
    .notNull()
    .default(['Kurk', 'Berk', '3D']),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
    .notNull(),
});

export const workSessions = sqliteTable(
  'work_sessions',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    userId: text('user_id')
      .notNull()
      .references(() => user.id, { onDelete: 'cascade' }),
    activityId: integer('activity_id')
      .notNull()
      .references(() => activities.id),
    insoleType: text('insole_type'),
    pairCount: integer('pair_count').notNull().default(2),
    startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
    endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active
    durationSeconds: integer('duration_seconds'),
    status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded'
    source: text('source').notNull().default('app'), // 'app' | 'manual'
    notes: text('notes'),
    createdAt: integer('created_at', { mode: 'timestamp_ms' })
      .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
      .notNull(),
  },
  (table) => ({
    workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId),
    workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime),
  })
);

sql and index are already imported at the top of schema.ts. user is defined above in the same file. If sqliteTable's second-arg callback-returns-object form is deprecated in 0.36.4, adapt to the array form the installed version expects (installed API wins).

Contracts (append to packages/shared/src/index.ts)

export const InsoleType = z.enum(['Kurk', 'Berk', '3D']);
export type InsoleType = z.infer<typeof InsoleType>;

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<typeof Activity>;

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<typeof CreateActivityInput>;

export const UpdateActivityInput = CreateActivityInput;
export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>;

export const SessionStatus = z.enum(['active', 'completed', 'discarded']);
export type SessionStatus = z.infer<typeof SessionStatus>;

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<typeof WorkSession>;

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<typeof StartSessionInput>;

Test — apps/api/test/schema.test.ts

Describe "domain schema":

  • it('creates and reads back an activity with a json insole_types array'): import db from ../src/db/client, activities from ../src/db/schema, eq from drizzle-orm. Insert { name: 'Frezen', insoleTypes: ['Kurk','Berk'] }, select it back by id, assert row.insoleTypes deep-equals ['Kurk','Berk'] (proves the JSON round-trip) and row.name === 'Frezen'.
  • it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time'): needs a real user.id, so first sign a user up through the app (mirror me.test.ts: build the app, POST /api/auth/sign-up/email, then read the created user's id from the user table via db.select().from(user)), then insert a work_sessions row with only the required fields (userId, activityId from the activity above, startTime: new Date()), select it back, and assert status === 'active', source === 'app', pairCount === 2, endTime === null, durationSeconds === null.

This test exercises the live migration (the setup file migrates ./.tmp/test.db before tests), so it fails until the migration is generated.

Steps

  • Append the two contracts blocks above to packages/shared/src/index.ts.
  • Write apps/api/test/schema.test.ts. Run yarn workspace @solelog/api test schema — watch it fail (no activities table).
  • Append the schema tables to apps/api/src/db/schema.ts.
  • Generate the migration: yarn workspace @solelog/api db:generate. Confirm a new drizzle/0001_*.sql appears creating only activities + work_sessions (NOT re-creating better-auth tables) and that drizzle/meta/_journal.json gained an entry.
  • Run yarn workspace @solelog/api test schema — green. Run the full suite — still green. Run yarn workspace @solelog/api typecheck — clean.
  • Commit: feat(api): add activities + work_sessions domain schema and shared contracts.

Backend Task 2: Auth helper + activities CRUD routes (user-scoped)

Outcome: GET/POST /api/activities, PUT/DELETE /api/activities/:id, all behind the bearer session, with full vitest coverage. Activities are shared shop data (not per-user) but all routes require a valid session (401 without).

Files

  • apps/api/src/lib/require-user.ts — NEW shared auth helper.
  • apps/api/src/routes/activities.ts — NEW route module.
  • apps/api/src/app.ts — mount the route (CORS added in Task 6; just mount here).
  • apps/api/test/activities.test.ts — NEW test.

apps/api/src/lib/require-user.ts

A helper that resolves the better-auth session the same way me.ts does and returns the user, or null. Routes turn null into a 401.

import type { Context } from 'hono';
import { auth } from '../auth';

export async function getSessionUser(c: Context): Promise<{ id: string } | null> {
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
  if (!session) return null;
  return { id: session.user.id };
}

apps/api/src/routes/activities.ts

A Hono sub-app. Behaviour (mirror legacy task rules from legacy-backend.md §3, scoped behind auth):

  • GET /api/activities — 401 if no user. Optional ?insole_type=Kurk|Berk|3D filter: return activities whose insoleTypes array includes that value (filter in JS after the select, since the column is JSON text). Order by name ASC. Map rows → Activity shape (created_at to ISO).
  • POST /api/activities — 401 if no user. Parse body with CreateActivityInput.safeParse; on failure 400 { error: 'Invalid input' }. Empty/missing insole_types defaults to all three (the zod .default handles this). Insert, return the created Activity with status 200 (match the legacy Response.json convention; the test asserts 200).
  • PUT /api/activities/:id — 401 if no user. id parsed as int. Validate body as above. If no row updated → 404 { error: 'Activity not found' }. Return updated Activity.
  • DELETE /api/activities/:id — 401 if no user. Delete the activity's work_sessions first, then the activity (reproduce legacy cascade behaviour explicitly). Return { success: true }. (The FK has no cascade declared, so the explicit delete is required to avoid a constraint error when sessions reference it.)

Use Drizzle query builder (db.select().from(activities), db.insert(...).values(...).returning(), db.update(...).set(...).where(eq(...)).returning(), db.delete(...)). Serialise timestamps with new Date(row.createdAt).toISOString().

Test — apps/api/test/activities.test.ts

Add a helper at the top to sign up + sign in and return a bearer token (copy the pattern from me.test.ts; factor a local async function authToken(app, email) returning the set-auth-token). Use a UNIQUE email per test to avoid cross-test collisions in the shared file DB.

Describe "activities routes":

  • it('401s GET /api/activities without a token') → status 401.
  • it('401s POST /api/activities without a token') → status 401.
  • it('creates an activity and lists it'): POST { name:'Frezen', insole_types:['Kurk','Berk'] } with token → status 200, body matches Activity (insole_types deep-equals ['Kurk','Berk']); then GET /api/activities → array contains it.
  • it('defaults insole_types to all three when omitted'): POST { name:'Slijpen' }insole_types deep-equals ['Kurk','Berk','3D'].
  • it('filters by ?insole_type'): create one ['3D']-only activity and one ['Kurk']-only; GET /api/activities?insole_type=3D returns only the 3D one.
  • it('400s POST with an empty name') → status 400.
  • it('updates an activity'): PUT changes name + types; assert returned body reflects it.
  • it('404s PUT for a missing id') → status 404.
  • it('deletes an activity and its sessions'): create an activity, insert a work_sessions row against it directly via db with the test user's id, DELETE the activity, assert { success: true } and that the work_sessions row is gone (db.select() empty).

Steps

  • Write apps/api/test/activities.test.ts. Run it — fails (route not mounted).
  • Implement require-user.ts and activities.ts; mount app.route('/', activities) in app.ts.
  • Run the activities test — green. Full suite + typecheck — green.
  • Commit: feat(api): user-scoped activities CRUD with shared auth helper.

Backend Task 3: Session lifecycle routes — start / stop / discard (ownership-enforced)

Outcome: POST /api/sessions/start, POST /api/sessions/:id/stop, POST /api/sessions/:id/discard, all behind the bearer session and scoped to the owning user. Ownership is enforced: user B cannot stop/discard user A's session (treated as not-found → 404).

Files

  • apps/api/src/routes/sessions.ts — NEW route module (also holds the read endpoints in Task 4 and CSV in Task 5; create it here with the write endpoints).
  • apps/api/src/app.ts — mount it.
  • apps/api/test/sessions.test.ts — NEW test.

Behaviour

  • POST /api/sessions/start — 401 if no user. Body via StartSessionInput.safeParse; 400 on fail. Verify the activity_id exists (else 404 { error: 'Activity not found' }). Insert a work_sessions row: userId = session user, activityId, insoleType, pairCount, startTime: new Date(), endTime: null, durationSeconds: null, status: 'active', source: 'app'. Return the created WorkSession.
  • POST /api/sessions/:id/stop — 401 if no user. Load the session by id AND userId (scope to owner). If not found (missing or not owned) → 404 { error: 'Session not found' }. If its status !== 'active' (already closed) → 409 { error: 'Session already closed' }. Otherwise set endTime = new Date(), durationSeconds = Math.round((endTime - startTime)/1000) (wall-clock delta — server-authoritative; roadmap prefers wall-clock over the legacy tick count), status = 'completed'. Return the updated WorkSession.
  • POST /api/sessions/:id/discard — 401 if no user. Same owner-scoped load → 404 if not found. Reject if already closed (409) the same way. Set status = 'discarded', endTime = new Date(), leave durationSeconds null. Return the updated WorkSession.

Ownership rule: always filter the load by and(eq(id), eq(userId)). A row owned by someone else is indistinguishable from a missing row → 404. This is the security boundary the test below proves.

Test — apps/api/test/sessions.test.ts

Reuse the authToken helper pattern. Add a small helper to create an activity via the API and return its id. Describe "session lifecycle":

  • it('401s start/stop/discard without a token') → three requests, each 401.
  • it('starts an active session'): with token, create activity, POST /api/sessions/start { activity_id, insole_type:'Kurk', pair_count:2 } → body matches WorkSession, status === 'active', end_time === null, duration_seconds === null.
  • it('400s start with a bad body') → POST start with {} → 400.
  • it('404s start for a missing activity'){ activity_id: 999999, insole_type:'Kurk' } → 404.
  • it('completes a session and computes duration'): start a session; to make the duration deterministic, directly db.update(workSessions).set({ startTime: new Date(Date.now() - 5000) }) for that session id, then POST /api/sessions/:id/stop. Assert status === 'completed', end_time non-null, duration_seconds === 5 (exact — never a fuzzy range).
  • it('409s stopping an already-completed session'): start, stop, stop again → 409.
  • it('discards an active session'): start, discard → status === 'discarded', duration_seconds === null.
  • it('does not let user B stop user A\'s session'): token A starts a session; token B (different email) POSTs /api/sessions/:id/stop404; then verify via db (or token A read) the session is still active.

Steps

  • Write apps/api/test/sessions.test.ts. Run it — fails.
  • Implement the three write endpoints in apps/api/src/routes/sessions.ts; mount in app.ts.
  • Run the sessions test — green. Full suite + typecheck — green.
  • Commit: feat(api): server-authoritative session start/stop/discard with ownership scoping.

Backend Task 4: Session read routes — history & active recovery (joined, scoped)

Outcome: GET /api/sessions (history, newest first, joined to activity name, includes active) and GET /api/sessions/active (the user's open session(s) for recovery), both user-scoped.

Files

  • apps/api/src/routes/sessions.ts — ADD the two GET endpoints.
  • apps/api/test/sessions.test.ts — ADD a describe('session reads') block.

Behaviour

  • GET /api/sessions — 401 if no user. Select all work_sessions where userId = user.id, LEFT JOIN activities on activityId to get activity_name, ordered startTime DESC (newest first). Map to WorkSession[] (activity_name set; timestamps to ISO; end_time/duration_seconds null-safe). Includes active, completed, and discarded sessions for this user.
  • GET /api/sessions/active — 401 if no user. Select work_sessions where userId = user.id AND status = 'active', joined to activity name, ordered startTime DESC. Return WorkSession[] (usually 0 or 1; return an array so the client can pick the most recent).

Path note: /api/sessions/active and /api/sessions are distinct exact paths; there is no /api/sessions/:id GET in this phase, so no route shadows another. Keep paths exact.

Test additions — describe('session reads')

  • it('401s GET /api/sessions and /api/sessions/active without a token').
  • it('returns the user\'s sessions joined with activity name, newest first'): token A starts two sessions against named activities (e.g. Frezen, Slijpen); to control ordering, db.update their startTimes so one is clearly newer; GET /api/sessions → length 2, new Date(res[0].start_time) > new Date(res[1].start_time), and res[0].activity_name is the newer one.
  • it('scopes history to the requesting user'): token A has sessions; token B GETs /api/sessions → none of B's results carry A's session ids (B sees only its own).
  • it('returns only active sessions from /api/sessions/active'): token A starts one session and starts+stops another; /api/sessions/active → length 1, that one is status === 'active'.

Steps

  • Add the read-route tests. Run — fail.
  • Implement the two GET endpoints in sessions.ts.
  • Run sessions test — green. Full suite + typecheck — green.
  • Commit: feat(api): session history and active-session recovery endpoints.

Backend Task 5: CSV export (completed sessions, scoped, legacy format)

Outcome: GET /api/export returns the bearer user's completed sessions as CSV matching the legacy format (see Global Constraints → CSV contract).

Files

  • apps/api/src/routes/sessions.ts — ADD GET /api/export (or a small apps/api/src/routes/export.ts mounted in app.ts; either is fine — keep it in one place and mount it).
  • apps/api/src/lib/csv.ts — NEW: the quote + formatDuration helpers (so they are unit-testable).
  • apps/api/test/export.test.ts — NEW test.

Behaviour

  • 401 if no user. Select the user's work_sessions where status = 'completed', joined to activity name, ordered startTime ASC (oldest first — note this is the OPPOSITE of history). Build the CSV exactly per the contract table. Format dates/times with nl-BE locale as specified. Response: text/csv; charset=utf-8, Content-Disposition: attachment; filename="insole-production-report.csv", body = header row + data rows joined with \n.

apps/api/src/lib/csv.ts

Export quote and formatDuration exactly as in the Global Constraints section.

Test — apps/api/test/export.test.ts

  • it('401s without a token') → GET /api/export no token → 401.
  • it('exports completed sessions as CSV with the legacy header'): token user creates an activity Frezen, starts a session, db.updates its startTime to exactly 90s before its endTime/stop so the duration is exactly 90, stops it. GET /api/export with token. Assert:
    • res.headers.get('content-type') includes text/csv.
    • res.headers.get('content-disposition') === attachment; filename="insole-production-report.csv".
    • body first line === "ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Start Time","End Time".
    • body has exactly 2 lines (header + 1 row); the data row contains "Frezen", the insole type, and the Total Duration cell "00:01:30" (90s, computed exactly).
  • it('excludes active and discarded sessions and scopes to the user'): the same user also has an active and a discarded session; another user has a completed session. The CSV for the first user has only its own completed row(s) (still 2 lines).
  • describe('csv helpers'): quote('a"b')"a""b"; formatDuration(3661)01:01:01; formatDuration(0)00:00:00.

Locale note: nl-BE toLocaleString depends on the platform's ICU. Assert the Total Duration cell exactly (pure arithmetic, no locale) and the header + structure exactly. For the Date/Start/End cells, assert they are non-empty quoted strings rather than a hard-coded locale rendering (avoids a brittle ICU dependency while still proving the columns populate).

Steps

  • Write apps/api/test/export.test.ts. Run — fail.
  • Implement lib/csv.ts and the /api/export route; mount it.
  • Run export test — green. Full suite + typecheck — green.
  • Commit: feat(api): user-scoped CSV export matching legacy format.

Backend Task 6: Seed script + CORS for the SPA origin

Outcome: a seed script inserts the reference activities (idempotent); CORS allows the SPA at http://localhost:5173 to call the API with a bearer token and read the set-auth-token response header; apps/api/src/auth.ts trustedOrigins includes :5173.

Files

  • apps/api/src/db/seed.ts — NEW; apps/api/package.json db:seed script.
  • apps/api/src/app.ts — add cors() middleware.
  • apps/api/src/auth.ts — add 'http://localhost:5173' to trustedOrigins (allowed: using auth).
  • apps/api/test/cors.test.ts — NEW test.
  • apps/api/test/seed.test.ts — NEW test.

Seed — apps/api/src/db/seed.ts

Idempotent: for each reference activity (table in Global Constraints), insert it only if no activity with that name exists (db.select().from(activities).where(eq(activities.name, name))). Export a seed() function and add the direct-run guard (copy the pathToFileURL(process.argv[1]) pattern from migrate.ts). Add "db:seed": "tsx src/db/seed.ts" to apps/api/package.json.

CORS — apps/api/src/app.ts

Add BEFORE the routes:

import { cors } from 'hono/cors';
// ...
app.use(
  '/api/*',
  cors({
    origin: ['http://localhost:5173'],
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
    exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in
    credentials: true,
  })
);

exposeHeaders: ['set-auth-token'] is load-bearing: better-auth returns the bearer token in that response header on sign-in, and a cross-origin browser fetch can only read it if it is exposed.

Test — apps/api/test/cors.test.ts

  • it('answers a CORS preflight for the SPA origin'): OPTIONS /api/activities with headers Origin: http://localhost:5173 and Access-Control-Request-Method: GETaccess-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:5173access-control-expose-headers contains set-auth-token (case-insensitive check).

Test — apps/api/test/seed.test.ts

  • it('seeds the reference activities idempotently'): import seed from ../src/db/seed; run it twice; the count of activities with the seeded names is unchanged after the second run (no duplicates); assert Printen exists with insole_types deep-equal ['3D'].

Steps

  • Write cors.test.ts and seed.test.ts. Run — fail.
  • Add CORS to app.ts; add :5173 to auth.ts trustedOrigins; write seed.ts + db:seed.
  • Run both tests — green. Full suite + typecheck — green.
  • Run the seed once for real against the dev DB: yarn workspace @solelog/api db:migrate && yarn workspace @solelog/api db:seed and confirm it prints success (sanity, not a test).
  • Commit: feat(api): seed reference activities and enable CORS for the worker SPA.

Client Task 1: Scaffold the Vite + React + TS PWA workspace + API client + token storage

Outcome: apps/worker exists as workspace @solelog/worker, builds, typechecks, has a vitest setup, a PWA manifest, a typed apiFetch wrapper that attaches the bearer token from localStorage, and tests for the token storage + fetch wrapper. No screens yet (a placeholder root).

Files (new app skeleton)

apps/worker/
  package.json
  tsconfig.json
  tsconfig.node.json
  vite.config.ts
  vitest.config.ts
  index.html
  public/manifest.webmanifest
  public/icon-192.png       (a simple solid-colour PNG, 192x192)
  public/icon-512.png       (512x512)
  src/main.tsx
  src/App.tsx               (placeholder: renders "SoleLog")
  src/test/setup.ts         (jest-dom)
  src/lib/auth-storage.ts
  src/lib/api.ts
  src/lib/auth-storage.test.ts
  src/lib/api.test.ts

apps/worker/package.json

{
  "name": "@solelog/worker",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@solelog/shared": "workspace:*",
    "@tanstack/react-query": "^5.0.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.26.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.4.0",
    "@testing-library/react": "^16.0.0",
    "@testing-library/user-event": "^14.5.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.0",
    "jsdom": "^25.0.0",
    "typescript": "^5.7.2",
    "vite": "^5.4.0",
    "vitest": "^3.0.0"
  }
}

Install resolves these to concrete versions; whatever Yarn picks is authoritative — adapt code to the installed React 18/19 + Router 6/7 + RQ 5 API. (React 19's createRoot call/types are identical; Router 7 still exports the react-router-dom symbols used here.) Keep the dep list to exactly these — no extras.

apps/worker/vite.config.ts

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

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 <head> link the manifest and theme: <link rel="manifest" href="/manifest.webmanifest">, <meta name="theme-color" content="#2563EB">, <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">, <link rel="apple-touch-icon" href="/icon-192.png">. Body: <div id="root"></div> + <script type="module" src="/src/main.tsx"></script>. Title SoleLog.

apps/worker/public/manifest.webmanifest

{
  "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

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 <token> when a token is stored. Sign-in reads the token from the set-auth-token response header and stores it.

import { getToken, setToken } from './auth-storage';

export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';

export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
  const token = getToken();
  const headers = new Headers(init.headers);
  if (token) headers.set('Authorization', `Bearer ${token}`);
  if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
  const res = await fetch(`${API_URL}${path}`, { ...init, headers });
  if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
  const text = await res.text();
  return (text ? JSON.parse(text) : undefined) as T;
}

// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
export async function signIn(email: string, password: string): Promise<void> {
  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<void> {
  const res = await fetch(`${API_URL}/api/auth/sign-up/email`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }),
  });
  if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt');
}

The installed apps/api better-auth /sign-up/email requires name (no backfill hook in apps/api), so the SPA supplies it from the email local-part.

apps/worker/src/main.tsx

createRoot(document.getElementById('root')!).render(<App />) wrapped in <React.StrictMode> and a <QueryClientProvider client={new QueryClient()}>. (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 35.
  • 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 <Login />. If authed, render <BrowserRouter> with routes '/' → Stopwatch, '/history' → History, '/settings' → Settings, plus the <TabBar />.
  • 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 <NavLink>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 <App /> (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 <App />; 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<Activity[]>('/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 <Settings /> 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 zoolType 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.

export function formatTime(totalSeconds: number): string {
  const s = Math.max(0, Math.floor(totalSeconds));
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}

// elapsed seconds since startMs, excluding accumulated paused ms, evaluated at nowMs.
export function elapsedSeconds(startMs: number, nowMs: number, pausedMs: number): number {
  return Math.max(0, Math.floor((nowMs - startMs - pausedMs) / 1000));
}

Behaviour (from legacy-mobile-app.md §4)

  • Section Type zool: three segmented buttons Kurk/Berk/3D (default Kurk). Disabled while running. Changing the zool resets the selected handling (activeActivityId = null).
  • Section Type handeling: a select listing activities filtered by the chosen zool (insole_types.includes(insoleType)); placeholder Kies een handeling.... Disabled while running. Empty state when none match: Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen..
  • Section Aantal zolen: a stepper [n] +, default 2, min 1, free-typed; sent as pair_count.
  • Stopwatch display: HH:MM:SS (large). Tap target with status pill: not-running+can-start → Tik om te starten; running → Tik om te pauzeren; paused → Gepauzeerd — tik om te hervatten.
  • Buttons: not running → Start Stopwatch (enabled only if a handling is chosen). Running → red Stop & Opslaan + the double-press discard (Annuleren → armed Nogmaals tikken ter bevestiging, 3s window).
  • Server calls: Start → POST /api/sessions/start { activity_id, insole_type, pair_count } → store the returned session id + its start_time. Pause/resume are client-only (accumulate paused ms) — the server stays open. Stop & Save → POST /api/sessions/:id/stop. Discard (2nd tap) → POST /api/sessions/:id/discard. After stop/discard, reset the timer (selections persist).
  • Recovery on load: GET /api/sessions/active; if one exists, adopt it (set running, set activeActivityId, insoleType, pairCount, and base the live timer on its start_time). This is the "phone died, resume from another device" path.
  • Live timer: a 1s setInterval re-render; the displayed value is elapsedSeconds(startMs, Date.now(), pausedMs).

Tests

apps/worker/src/lib/stopwatch.test.ts (describe "stopwatch logic"):

  • formatTime(0) === '00:00:00', formatTime(65) === '00:01:05', formatTime(3661) === '01:01:01'.
  • elapsedSeconds(1000, 6000, 0) === 5; elapsedSeconds(1000, 6000, 2000) === 3 (paused time excluded); elapsedSeconds(5000, 1000, 0) === 0 (never negative).

apps/worker/src/screens/Stopwatch.test.tsx (mock lib/api/the session+activity hooks):

  • it('renders the three sections and Start button in Dutch'): with activities mocked, assert Type zool, Type handeling, Aantal zolen, Start Stopwatch; the Kurk/Berk/3D buttons; default count 2.
  • it('disables Start until a handling is chosen'): initially Start Stopwatch disabled; after selecting a handling it is enabled.
  • it('filters handlings by the chosen zooltype'): activities [{name:'Printen',insole_types:['3D']}, {name:'Frezen',insole_types:['Kurk','Berk']}]; with Kurk selected the handling options include Frezen but not Printen; selecting 3D shows Printen and the inverse.
  • it('calls start with the selected values'): mock the start mutation; pick Berk, a handling, set count 3, click Start Stopwatch; assert the mutation was called with { activity_id, insole_type:'Berk', pair_count:3 }.
  • it('arms discard on first Annuleren tap and discards on the second'): with a running session (mock post-start state), first Annuleren tap shows Nogmaals tikken ter bevestiging; second tap calls the discard mutation. (Use fake timers if you assert the 3s auto-disarm; at minimum assert the two-tap path.)

Steps

  • Write lib/stopwatch.test.ts. Run — fail. Implement lib/stopwatch.ts. Green.
  • Write Stopwatch.test.tsx. Run — fail. Implement api/sessions.ts + Stopwatch.tsx. Green.
  • typecheck + build — clean.
  • Commit: feat(worker): server-authoritative Stopwatch screen with active-session recovery.

Client Task 5: Geschiedenis (History) + CSV export

Outcome: the Geschiedenis screen lists the user's sessions (newest first) via GET /api/sessions and offers a CSV export action that downloads GET /api/export with the bearer token.

Files

  • apps/worker/src/screens/History.tsx — full implementation.
  • apps/worker/src/api/sessions.ts — ADD useSessions() (GET /api/sessions).
  • apps/worker/src/lib/export.tsdownloadExport() helper (authenticated blob download).
  • apps/worker/src/screens/History.test.tsx — test.

Behaviour (from legacy-mobile-app.md §5)

  • Header Geschiedenis; a pill button Exporteer CSV.
  • Body: list of session cards via useSessions(). Empty state (not loading, no sessions): Nog geen opgeslagen sessies..
  • Each card: title = activity_name; a date/time line ({date} • {time} from start_time, device locale); badges: insole-type pill (verbatim Kurk/Berk/3D), a count pill {pair_count} {pair_count === 1 ? 'inlegzool' : 'inlegzolen'}, and a duration pill formatted like the legacy formatDuration (1h 5m / 3m 20s / 45s).
  • CSV export: because the download needs the bearer token, it cannot be a plain <a href> to the API. downloadExport() does an authenticated fetch of /api/export, reads the blob, and triggers a download via an object URL + a synthetic <a download> click. On error show a Dutch message (Fout / Kan de export niet openen).

apps/worker/src/lib/export.ts

import { API_URL } from './api';
import { getToken } from './auth-storage';

export async function downloadExport(): Promise<void> {
  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:11 inlegzool.
  • it('triggers the CSV download on Exporteer CSV'): click the button; assert the mocked downloadExport was called.

Steps

  • Write History.test.tsx. Run — fail.
  • Implement useSessions, lib/export.ts, History.tsx.
  • Run worker tests — green. typecheck + build — clean.
  • Commit: feat(worker): Geschiedenis screen with session list and CSV export.

Client Task 6: End-to-end manual smoke + README, final green check

Outcome: the whole phase verified together, run instructions documented, everything green.

Files

  • apps/worker/README.md — NEW: how to run (no Expo, no tunnel).
  • (no code changes expected; docs + verification)

apps/worker/README.md

Document: prerequisites (yarn install from repo root); run the API (yarn workspace @solelog/api db:migrate && yarn workspace @solelog/api db:seed && yarn workspace @solelog/api start on :3000); run the worker (yarn workspace @solelog/worker dev on :5173); open http://localhost:5173 in any browser, or on a phone via http://<PC-LAN-IP>: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://<PC-LAN-IP>:3000) so the SPA targets the API on the LAN, and add that origin to the API CORS origin list + better-auth trustedOrigins. Installability: use the browser's "Add to Home Screen" / "Install" to install the PWA (the manifest + icons enable it).

Verification (run REAL commands; paste real output into the session log)

  • yarn workspace @solelog/api test — all backend tests green.
  • yarn workspace @solelog/api typecheck — clean.
  • yarn workspace @solelog/worker test — all worker tests green.
  • yarn workspace @solelog/worker typecheck — clean.
  • yarn workspace @solelog/worker build — succeeds; dist/manifest.webmanifest + icons present.
  • npx oxlint from repo root — no new errors.
  • Manual smoke (recommended): start API + worker, sign up, sign in, create an activity, run a session start→stop, see it in Geschiedenis, export CSV, install the PWA.
  • Commit: docs(worker): run instructions and Phase 1 verification.

Self-review notes (writing-plans discipline)

  • Zero-context check. Every task names exact file paths, the contract types it produces/consumes (from packages/shared), complete code or precise specs, exact test names, and a commit. Installed library versions were read from node_modules and flagged authoritative over the samples (cors verified at hono/cors; drizzle-orm 0.36.4 text/integer/index/eq/and/desc verified).
  • TDD honoured. Each task writes its test(s) first and watches them fail before implementing.
  • No drizzle bump. Schema uses the installed 0.36.4 API; the migration is generated, not hand-written; the existing better-auth migration is untouched (new domain tables → 0001_*.sql).
  • CORS / token / array storage / manifest / VITE_API_URL all resolved concretely in Global Constraints + the relevant tasks (exposeHeaders: ['set-auth-token'], text({mode:'json'}), import.meta.env.VITE_API_URL default, public/manifest.webmanifest).
  • Resolved risk — better-auth trustedOrigins. Adding :5173 is "using" the auth config, not rewriting it; no plugin/hashing/session change. CORS exposeHeaders is what lets the browser read the bearer token cross-origin — both required for the SPA, both explicitly called out.
  • Resolved risk — locale-sensitive CSV cells. Tests assert the header, structure, and the arithmetic Total Duration cell exactly, but treat the nl-BE Date/time cells as non-empty (avoids a brittle ICU dependency while still proving the columns populate).
  • Resolved risk — deterministic duration. The stop/export tests control start_time via a direct db.update, so duration_seconds is asserted exactly, never as a fuzzy range.
  • Ownership boundary tested. A cross-user stop returns 404 and leaves the victim's session active; history and active-session reads are scoped to the requester.
  • Web-only, no Expo/tunnel is restated in the header, architecture, and run instructions; the worker app's dependency list contains no Expo/RN/ngrok packages.