Files
solelog/docs/plans/phase-1-worker-timing.md
2026-06-17 15:07:43 +02:00

60 KiB

Phase 1 — Worker Timing Implementation Plan

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

Goal: Deliver "Worker timing" end-to-end. The backend (apps/api) gains domain tables (activities, work-sessions), a user-scoped REST surface for managing activities and starting/stopping/discarding server-authoritative work sessions, a history list, an "active session" recovery endpoint, and a CSV export — all behind the existing better-auth bearer session. A fresh, lean Expo Router app (apps/mobile, package @solelog/mobile) is added that logs in, attaches the bearer token to every call, and reproduces the three Dutch screens (Stopwatch / Geschiedenis / Instellingen) against this backend. Done when: a worker can pick an activity, start/stop a server-side session, see history, and export CSV; all backend endpoints are user-scoped and covered by vitest; the mobile app runs on Expo web and on a device via Expo Go, with jest-expo unit tests and a clean tsc --noEmit.

Architecture: The backend remains the single owner of auth + DB (Decision A from the roadmap). The mobile app is a pure client: it holds no business logic beyond UI state and the live elapsed-timer display; start/stop/discard are server calls, so an open session survives a phone restart and can be recovered. Request/response shapes are zod schemas in packages/shared, imported by both apps/api (validation) and apps/mobile (typed client). All new domain routes resolve the user from the better-auth session (the exact auth.api.getSession({ headers }) pattern already used by src/routes/me.ts) and scope every query to that user_id; no token → 401.

Tech Stack (already installed — these versions are authoritative; do not bump):

Package Installed version Notes
hono 4.12.25 router + hono/cors middleware
@hono/node-server 1.19.14 server entry
better-auth 1.6.18 bearer plugin; set-auth-token response header on sign-in
drizzle-orm 0.36.4 pinned — do NOT bump (tracked as SL-9)
drizzle-kit 0.30.6 pinned — do NOT bump (tracked as SL-9)
@libsql/client 0.14.0 SQLite driver (no native build)
zod 3.25.76 contracts
vitest 3.2.6 backend tests
typescript 5.9.3 tsc --noEmit
tsx 4.22.4 run/seed scripts

Mobile (to be added, latest within each major at install time, pinned exact after install): expo (SDK 54 line), expo-router, react, react-native, react-dom, react-native-web, expo-secure-store, expo-status-bar, @tanstack/react-query, @solelog/shared (workspace:*), and dev deps jest-expo, jest, @testing-library/react-native, react-test-renderer, typescript, @types/react, @types/jest.

Global Constraints

  • Package manager: Yarn 4.12.0 (Berry), nodeLinker: node-modules. Run from the repo root via corepack: corepack yarn install, corepack yarn …. Workspaces are apps/* + packages/* (already configured in root package.json). The mobile app simply lives at apps/mobile, so it is picked up automatically.
  • Run commands — backend from apps/api (corepack yarn test, corepack yarn typecheck), mobile from apps/mobile. Git always as git -C D:/Sven ….
  • Do NOT modify the better-auth files (src/auth.ts, the user/session/account/verification tables in src/db/schema.ts) beyond adding new domain tables/relations to schema.ts and reusing auth/auth.api.getSession. Do NOT regenerate the auth tables.
  • Do NOT bump drizzle-orm / drizzle-kit (pinned; SL-9). Use the installed API only.
  • Keep apps/api green: the existing 4 test files / 5 tests must stay passing. Run the full suite after every backend task.
  • Strict TDD, no test weakening. If installed-library behaviour differs from any sample code in this plan, the installed library is authoritative — adapt the code and make the real test pass; never loosen an assertion to paper over a real bug.
  • Mobile is greenfield and minimal. Do NOT restore the deleted Create/Anything export, its __create plumbing, the web-sandbox iframe layer, analytics/Sentry, patched deps, or any unused library (ads/IAP/maps/3D/audio/sensors/lucide/NativeWind). Add a dependency only when a task needs it.
  • Out of scope (do NOT build): workbenches / QR scanning (Phase 4), the admin web panel (Phase 3), admin user-management / roles beyond per-user scoping (Phase 2), offline-first, push notifications.
  • Commit style: Conventional Commits, one commit per task (or per step where a task says so). Commit only after that task's tests are green.

SQLite storage & cross-cutting decisions (resolved here, referenced by tasks)

  1. insole_types array storage. SQLite has no array type. Store it with Drizzle text('insole_types', { mode: 'json' }).$type<InsoleType[]>(). drizzle-orm 0.36.4 serialises the JS array to a JSON string on write and JSON.parses on read, so route code sees a real string[]. The zod contract validates it as z.array(InsoleType). Never filter inside SQL on this column; the ?insole_type= filter on GET /api/activities is applied in JS after fetching the user-visible rows (the dataset is tiny — a handful of activities). Document this in a code comment.
  2. Timestamps. Reuse the better-auth convention already in schema.ts: integer({ mode: 'timestamp_ms' }) (epoch-ms; Drizzle maps to/from Date). start_time is set at start; end_time is null while active. The API contract serialises timestamps as ISO-8601 strings (Date.toISOString()), so the mobile client and CSV are timezone-explicit (UTC). The wire/JSON shape is always ISO strings; the DB stores epoch-ms.
  3. duration_seconds. Server-authoritative and computed on stop as Math.round((end_time - start_time) / 1000) (whole seconds). This differs deliberately from the legacy client-tick count (see docs/reference/legacy-lessons-and-gotchas.md §6 — tick counting under-counts when backgrounded). The mobile timer is display-only; the server number is the source of truth. notes and a future pause model are out of scope for Phase 1 (pause does not change the server session; it only freezes the on-screen display).
  4. CSV format. Match docs/reference/legacy-backend.md §4 as closely as the new (user-scoped, completed-only) data allows: text/csv; charset=utf-8, Content-Disposition: attachment; filename="insole-production-report.csv", every cell quoted with " doubling, rows joined with \n, ordered start_time ASC. Columns are reduced to the set this ticket specifies (activity name, insole type, pair count, start, end, duration_seconds) — see Backend Task 7 for the exact header row. Format dates/times explicitly in UTC ISO to avoid the legacy server-timezone fragility.
  5. CORS. Add hono/cors so Expo web (a browser origin, e.g. http://localhost:8081) can call the API cross-origin with Authorization: Bearer …. Because auth is bearer-token only for the mobile/web client (no cookies), CORS does not need credentials: true; allow the Authorization and Content-Type request headers and expose set-auth-token. The allow-list is env-driven and kept consistent with better-auth trustedOrigins by reading the same env var (see Backend Task 8).
  6. EXPO_PUBLIC_BASE_URL. The mobile client's typed API client reads process.env.EXPO_PUBLIC_BASE_URL and defaults to http://localhost:3000. For device testing over LAN, set it to the PC's LAN IP (e.g. http://192.168.1.50:3000) in apps/mobile/.env. The EXPO_PUBLIC_ prefix makes Expo inline it into the bundle. Document both values in .env.example.

Domain data model (added to apps/api/src/db/schema.ts)

activities
  id            integer  PK autoincrement
  name          text     NOT NULL
  insole_types  text(json) NOT NULL  -- InsoleType[] subset of 'Kurk'|'Berk'|'3D'
  created_at    integer  timestamp_ms  DEFAULT now  NOT NULL

work_sessions
  id                integer PK autoincrement
  user_id           text    NOT NULL  FK -> user.id  ON DELETE CASCADE
  activity_id       integer NOT NULL  FK -> activities.id
  insole_type       text    NOT NULL  -- 'Kurk'|'Berk'|'3D'
  pair_count        integer NOT NULL  DEFAULT 2
  start_time        integer timestamp_ms  NOT NULL
  end_time          integer timestamp_ms  NULL   -- null = active
  duration_seconds  integer NULL              -- null until stopped
  status            text    NOT NULL  DEFAULT 'active'   -- 'active'|'completed'|'discarded'
  source            text    NOT NULL  DEFAULT 'app'      -- 'app'|'manual'
  notes             text    NULL
  created_at        integer timestamp_ms  DEFAULT now  NOT NULL

Indices: work_sessions(user_id), work_sessions(activity_id), work_sessions(user_id, status).


Backend Task 1: Domain contracts in packages/shared

Files:

  • Modify: packages/shared/src/index.ts
  • Create: packages/shared/test/contracts.test.ts
  • Modify: packages/shared/package.json (add vitest devDep + test script) and create packages/shared/vitest.config.ts

Interfaces produced (all exported as const zod schema + inferred type of the same name, the existing convention in this file):

// 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:
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(<a fully-populated active fixture with end_time:null, duration_seconds:null>) succeeds and WorkSession.safeParse(<same but status:'paused'>).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):
import type { InsoleType } from '@solelog/shared';
// (add `sqliteTable, text, integer, index` are already imported at the top)

export const activities = sqliteTable('activities', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  // SQLite has no array type: store InsoleType[] as a JSON string. Drizzle
  // (mode:'json') serialises on write and JSON.parses on read. NEVER filter on
  // this column in SQL — the ?insole_type= filter is applied in JS (tiny dataset).
  insoleTypes: text('insole_types', { mode: 'json' }).$type<InsoleType[]>().notNull(),
  createdAt: integer('created_at', { mode: 'timestamp_ms' })
    .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
    .notNull(),
});

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

export const activitiesRelations = relations(activities, ({ many }) => ({
  sessions: many(workSessions),
}));
export const workSessionsRelations = relations(workSessions, ({ one }) => ({
  user: one(user, { fields: [workSessions.userId], references: [user.id] }),
  activity: one(activities, { fields: [workSessions.activityId], references: [activities.id] }),
}));

If verbatimModuleSyntax/import type for InsoleType causes a value/type clash, keep it as a type import (it is only used in $type<>()). If the installed drizzle-kit emits the JSON column or the autoincrement PK differently from this sample, the generated SQL is authoritative — keep the schema, regenerate, and adjust the schema only if generation errors.

  • Step 2: Generate the migration: from apps/api, corepack yarn db:generate (drizzle-kit generate). This must produce a NEW drizzle/0001_*.sql containing CREATE TABLE activities and CREATE TABLE work_sessions (and indices) and a 0001 journal entry — it must NOT rewrite 0000. Inspect the generated SQL and confirm it does not alter the auth tables.

  • Step 3 (test first): Write apps/api/test/schema.test.ts:

    • imports db and { activities, workSessions } from schema;
    • inserts an activity with insoleTypes: ['Kurk', 'Berk'], reads it back, and asserts the value is a real array ['Kurk','Berk'] (proves JSON round-trips), and createdAt instanceof Date;
    • inserts a work_sessions row referencing a created user + the activity with endTime: null, reads it back, asserts status === 'active', pairCount === 2 (default), endTime === null, durationSeconds === null.
    • To have a user.id to reference, create a user first via the better-auth sign-up route through createApp() (as the auth test does), then db.select().from(user) to grab the id — OR insert a user row directly with db.insert(user).values({...}). Prefer the sign-up route (closer to reality).

    Run corepack yarn workspace @solelog/api testschema.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:

// apps/api/src/db/seed.ts
import { db } from './client';
import { activities } from './schema';

const SEED_ACTIVITIES: { name: string; insole_types: ('Kurk' | 'Berk' | '3D')[] }[] = [
  { name: 'Uitsnijden', insole_types: ['Kurk', 'Berk', '3D'] },
  { name: 'Leerrand', insole_types: ['Kurk', 'Berk'] },
  { name: 'Slijpen', insole_types: ['Kurk', 'Berk', '3D'] },
  { name: 'Lijmen', insole_types: ['Kurk', 'Berk'] },
  { name: 'Bekleden', insole_types: ['Kurk', 'Berk', '3D'] },
  { name: 'Frezen', insole_types: ['3D'] },
  { name: 'Afwerken', insole_types: ['Kurk', 'Berk', '3D'] },
];

export async function seed(): Promise<void> {
  const existing = await db.select().from(activities).limit(1);
  if (existing.length > 0) {
    console.log('activities already seeded — skipping');
    return;
  }
  await db.insert(activities).values(SEED_ACTIVITIES.map((a) => ({ name: a.name, insoleTypes: a.insole_types })));
  console.log(`seeded ${SEED_ACTIVITIES.length} activities`);
}

import { pathToFileURL } from 'node:url';
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  seed().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
}

Add "db:seed": "tsx src/db/seed.ts" to apps/api/package.json scripts. (Export seed so route tests can also seed programmatically.)

  • Step 5 (test first): add a seed test to schema.test.ts (or a new seed.test.ts): import { seed }, call it, assert db.select().from(activities) returns 7 rows with array insole_types; call seed() again and assert it is still 7 (idempotent). Make it green.

  • Step 6: Full suite green (corepack yarn workspace @solelog/api test — now 6 files), typecheck clean, then commit: git -C D:/Sven add apps/api packages && git -C D:/Sven commit -m "feat(api): activities & work_sessions tables, migration, seed"


Backend Task 3: Auth helper + Activities routes (GET/POST)

Files:

  • Create: apps/api/src/routes/_auth.ts (a tiny shared helper)
  • Create: apps/api/src/routes/activities.ts
  • Modify: apps/api/src/app.ts (mount activities)
  • Create: apps/api/test/activities.test.ts

Interfaces produced: requireUser(c) helper returning the authenticated user or throwing a 401 JSON response (used by every domain route); a Hono activities router exposing GET /api/activities and POST /api/activities. Consumes CreateActivityInput, Activity, ActivityList from @solelog/shared and the activities table.

  • Step 1: Create apps/api/src/routes/_auth.ts:
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<AuthedUser> {
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
  if (!session) {
    throw new HTTPException(401, { res: c.json({ error: 'Unauthorized' }, 401) });
  }
  return { id: session.user.id, email: session.user.email, name: session.user.name };
}

Verify hono/http-exception and the { res } option exist in hono 4.12.25. If the installed API differs, fall back to returning a 401 directly from each route (the me.ts pattern) instead of throwing — the installed library is authoritative. Whichever form is used, the observable contract is: no/invalid token → HTTP 401 { "error": "Unauthorized" }.

  • Step 2 (helpers): Add a serialiser in activities.ts mapping a DB row → the Activity wire shape (created_at: row.createdAt.toISOString(), insole_types: row.insoleTypes). Activities are a shared catalogue (not per-user) in Phase 1 — they are managed in Instellingen and used by all workers — so GET/POST/PUT/DELETE /api/activities require a valid session (401 without) but are NOT filtered by user_id. (Per-activity ownership is not in the data model; only work_sessions carry user_id.) The 401-without-token requirement still applies to every activities route.

  • Step 3 (test first): Write apps/api/test/activities.test.ts. Add a shared test helper inline (or in test/helpers.ts, see Task 5) that signs a user up + in and returns the bearer token:

async function tokenFor(app, email) {
  const json = { 'content-type': 'application/json' };
  await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json,
    body: JSON.stringify({ email, password: 'sterk-wachtwoord-123', name: email.split('@')[0] }) });
  const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json,
    body: JSON.stringify({ email, password: 'sterk-wachtwoord-123' }) });
  return signin.headers.get('set-auth-token');
}
const authHeaders = (t) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });

Tests:

  • GET /api/activities without a token → 401.
  • POST /api/activities without a token → 401.
  • With a token, POST /api/activities { name: 'Leerrand', insole_types: ['Kurk','Berk'] }201 (or 200), body validates against Activity, insole_types is ['Kurk','Berk'].
  • POST with { name: '', insole_types: ['Kurk'] }400.
  • POST with { name: 'X', insole_types: [] }400.
  • GET /api/activities with a token returns an array including the created activity, validates against ActivityList.
  • GET /api/activities?insole_type=3D returns only activities whose insole_types include 3D (create one ['3D'] and one ['Kurk'], assert filtering happens in JS).

Run — fails (route not mounted).

  • Step 4: Implement activities.ts:

    • GET /api/activities: requireUser(c); read optional c.req.query('insole_type'), validate it with InsoleType.safeParse (ignore if invalid/absent); db.select().from(activities).orderBy(activities.name); if filter present, rows.filter(r => r.insole_types.includes(filter)) in JS; map to Activity[]; c.json(list).
    • POST /api/activities: requireUser(c); parse body with CreateActivityInput.safeParse; on failure c.json({ error: 'Invalid input' }, 400); insert { name, insoleTypes: insole_types } with .returning(); map and c.json(activity, 201).

    Mount in app.ts: app.route('/', activities); (after me). Re-run until green.

  • Step 5: Full suite + typecheck green, commit: git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET/POST /api/activities (user-authed)"


Backend Task 4: Activities routes (PUT/DELETE)

Files:

  • Modify: apps/api/src/routes/activities.ts (add PUT/DELETE /api/activities/:id)
  • Modify: apps/api/test/activities.test.ts

Interfaces: PUT /api/activities/:id (consumes UpdateActivityInput, returns Activity), DELETE /api/activities/:id (returns { success: true }).

  • Step 1 (test first): Add tests:

    • PUT without token → 401; DELETE without token → 401.
    • PUT /api/activities/:id with { name: 'Slijpen', insole_types: ['3D'] } updates and returns the row (validates Activity, name + types changed).
    • PUT a non-existent id (e.g. 999999) → 404 { error: 'Activity not found' }.
    • PUT with empty name → 400.
    • DELETE /api/activities/:id200 { success: true }; a subsequent GET no longer lists it.
    • Decide and TEST the cascade choice: deleting an activity that has work_sessions — Phase 1 keeps it simple and blocks deletion when sessions reference it: return 409 { error: 'Activity in use' } if any work_sessions.activity_id = :id exists. Add a test: start a session against an activity, then DELETE it → 409; the activity still lists. (This avoids destroying a worker's history, unlike the legacy cascade. Document the divergence in a comment.)
  • Step 2: Implement:

    • PUT: requireUser; UpdateActivityInput.safeParse400; db.update(activities).set({ name, insoleTypes }).where(eq(activities.id, id)).returning(); empty result → 404; else map + c.json. Parse id with Number(c.req.param('id')); non-numeric → 404.
    • DELETE: requireUser; check db.select().from(workSessions).where(eq(workSessions.activityId, id)).limit(1); if found → 409; else db.delete(activities).where(eq(activities.id, id)); c.json({ success: true }).
  • Step 3: Green + typecheck, commit: git -C D:/Sven commit -am "feat(api): PUT/DELETE /api/activities with in-use guard"


Backend Task 5: Sessions lifecycle — start / stop / discard

Files:

  • Create: apps/api/test/helpers.ts (extract tokenFor, authHeaders for reuse)
  • Create: apps/api/src/routes/sessions.ts
  • Modify: apps/api/src/app.ts (mount sessions)
  • Create: apps/api/test/sessions.test.ts

Interfaces produced: a sessions Hono router with POST /api/sessions/start, POST /api/sessions/:id/stop, POST /api/sessions/:id/discard. Consumes StartSessionInput, returns WorkSession. A toWorkSession(row, activityName) serialiser (ISO timestamps, null-safe end_time/duration_seconds).

  • Step 1: Create apps/api/test/helpers.ts exporting tokenFor(app, email) and authHeaders(token) (move them out of activities.test.ts and import them there too, keeping that suite green).

  • Step 2 (test first): Write apps/api/test/sessions.test.ts. Seed an activity (call seed() or POST one). Cases:

    • POST /api/sessions/start without token → 401.
    • POST /api/sessions/start { activity_id, insole_type:'Kurk', pair_count:3 }201, validates WorkSession: status:'active', end_time:null, duration_seconds:null, source:'app', pair_count:3, activity_name set, user_id = caller.
    • POST /api/sessions/start with pair_count omitted defaults to 2.
    • POST /api/sessions/start with an activity_id that does not exist → 404 { error: 'Activity not found' }.
    • POST /api/sessions/start with insole_type:'Kurk' but the activity does NOT support 'Kurk'400 { error: 'Insole type not valid for activity' } (create an activity ['3D'], start with 'Kurk'). Add a test.
    • Stop lifecycle: start a session, then POST /api/sessions/:id/stop200, 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/stop404 (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/discard200, status:'discarded'. Discarding a non-owned session → 404. Discarding an already-closed session → 409.
    • :id non-numeric or unknown → 404.
  • Step 3: Implement sessions.ts:

    • start: requireUser; StartSessionInput.safeParse(body)400; load the activity by id, 404 if missing; if !activity.insole_types.includes(input.insole_type)400; insert { userId, activityId, insoleType, pairCount, startTime: new Date(), status:'active', source:'app' } with .returning(); c.json(toWorkSession(row, activity.name), 201).
    • stop: requireUser; parse id; load the row where(and(eq(id), eq(userId, user.id))); missing → 404; if row.status !== 'active' || row.endTime !== null409; compute end = new Date(), duration = Math.max(0, Math.round((end.getTime() - row.startTime.getTime()) / 1000)); db.update(workSessions).set({ endTime: end, durationSeconds: duration, status: 'completed' }) .where(eq(workSessions.id, id)).returning(); join the activity name; c.json(...).
    • discard: requireUser; same ownership load; 409 if not active; set status:'discarded' (leave end_time/duration null — it was thrown away); return the row.
    • Use import { and, eq } from 'drizzle-orm'. For the activity name, either a join or a second select on activities by activityId. Mount app.route('/', sessions).
  • Step 4: Green + typecheck, commit: git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): work-session start/stop/discard (owner-scoped, server-authoritative duration)"


Backend Task 6: Sessions reads — history + active recovery

Files:

  • Modify: apps/api/src/routes/sessions.ts (add GET /api/sessions, GET /api/sessions/active)
  • Modify: apps/api/test/sessions.test.ts

Routing order matters: register GET /api/sessions/active BEFORE any /:id-style route so active is not captured as an id. (The lifecycle routes are all under /api/sessions/:id/<verb>, so there is no direct conflict, but keep active explicit and first among GETs.)

Interfaces: GET /api/sessionsWorkSessionList (caller's sessions, all statuses, newest first by start_time, each with activity_name). GET /api/sessions/activeWorkSessionList (the caller's status:'active' sessions only, newest first) for crash/recovery on app launch.

  • Step 1 (test first): Add tests:

    • GET /api/sessions without token → 401. GET /api/sessions/active without token → 401.
    • Ownership scoping: A starts+stops one session and starts a second (active); B starts one. GET /api/sessions as A returns exactly A's 2 sessions (and none of B's); validates WorkSessionList; ordered newest-first by start_time (assert the active/newer one is [0]).
    • GET /api/sessions/active as A returns exactly the 1 active session; after stopping it, returns [].
    • Each returned item has activity_name populated (join correctness).
  • Step 2: Implement:

    • GET /api/sessions: requireUser; select work_sessions joined to activities where(eq(workSessions.userId, user.id)).orderBy(desc(workSessions.startTime)); map → WorkSession[].
    • GET /api/sessions/active: same but and(eq(userId), eq(status, 'active')).
    • Use import { desc } from 'drizzle-orm'. Prefer a Drizzle innerJoin(activities, eq(...)) selecting explicit columns + activities.name so the serialiser has the name without a second query.
  • Step 3: Green + typecheck, commit: git -C D:/Sven commit -am "feat(api): GET /api/sessions history + /api/sessions/active recovery"


Backend Task 7: CSV export

Files:

  • Create: apps/api/src/routes/export.ts
  • Modify: apps/api/src/app.ts (mount export)
  • Create: apps/api/test/export.test.ts

Interfaces: GET /api/exporttext/csv of the caller's completed sessions, ordered start_time ASC. Header row (exact): "Activity","Insole Type","Pair Count","Start","End","Duration (s)". Each data cell quoted with ", embedded " doubled; rows joined with \n. Start/End are ISO-8601 UTC strings; Duration (s) is duration_seconds. Response headers: Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="insole-production-report.csv".

  • Step 1 (test first): Write apps/api/test/export.test.ts:

    • GET /api/export without token → 401.
    • As user A: start+stop two sessions on a seeded activity (and start one active session that must NOT appear). GET /api/export200; Content-Type includes text/csv; Content-Disposition contains insole-production-report.csv. Parse the body: first line equals the exact header above; there are exactly 2 data rows (active one excluded); each row has 6 quoted fields; the Activity cell equals the activity name; Duration (s) is the integer string. Ordered ascending by start.
    • Ownership: B's completed sessions never appear in A's export (start+stop one as B; A's export still has only its 2 rows).
    • A cell-quoting test: create an activity whose name contains a " (e.g. He"llo), stop a session on it, assert the CSV contains "He""llo".
  • Step 2: Implement export.ts:

    • requireUser; select completed sessions joined to activity name where(and(eq(userId, user.id), eq(status, 'completed'))).orderBy(asc(workSessions.startTime)).
    • const quote = (v: unknown) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
    • Header: ['Activity','Insole Type','Pair Count','Start','End','Duration (s)'].map(quote).join(',').
    • Each row: [activityName, insoleType, pairCount, startTime.toISOString(), endTime?.toISOString() ?? '', durationSeconds ?? ''].map(quote).join(',').
    • Body = [header, ...rows].join('\n'). Return via c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="insole-production-report.csv"' }) (verify the c.body(...) header-object signature against hono 4.12.25; otherwise build a Response with new Response(csv, { headers })).
  • Step 3: Green + typecheck, commit: git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET /api/export CSV of completed sessions (owner-scoped)"


Backend Task 8: CORS + trusted-origins consistency

Files:

  • Modify: apps/api/src/env.ts (add CORS_ORIGINS)
  • Modify: apps/api/src/app.ts (apply hono/cors)
  • Modify: apps/api/src/auth.ts (derive trustedOrigins from the SAME env) — adding to the trustedOrigins array is allowed; do NOT change any other auth option
  • Modify: apps/api/.env.example and docker-compose.yml (document CORS_ORIGINS)
  • Create: apps/api/test/cors.test.ts

Interfaces: env.CORS_ORIGINS: string[] (parsed from a comma-separated env var, defaulting to the local dev origins). CORS middleware allows those origins; better-auth trustedOrigins includes the same list so a browser client passes CSRF/origin checks.

  • Step 1: In env.ts add:
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:

import { cors } from 'hono/cors';
// inside createApp(), first:
app.use('*', cors({
  origin: env.CORS_ORIGINS,
  allowHeaders: ['Authorization', 'Content-Type'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  exposeHeaders: ['set-auth-token'],
}));

Bearer-only auth means credentials: true is NOT required. Verify the origin option accepts a string array in hono 4.12.25 (it does; otherwise pass a function that returns the origin when it is in env.CORS_ORIGINS). The installed middleware is authoritative — shape the config + the test assertions to its real behaviour.

  • Step 5: Update .env.example (add CORS_ORIGINS=http://localhost:8081,http://localhost:3000) and add the same env to docker-compose.yml. Full suite green, typecheck clean, commit: git -C D:/Sven add apps/api docker-compose.yml && git -C D:/Sven commit -m "feat(api): CORS for browser clients, trustedOrigins consistency"

Backend Task 9: Backend wrap-up — full green + manual smoke

Files: none (verification only) — optionally a short apps/api/README.md note.

  • Step 1: From apps/api: corepack yarn test (ALL suites: the original 4 files + the new schema, seed, activities, sessions, export, cors), corepack yarn typecheck, npx oxlint (root config). All must pass. Record the real counts.
  • Step 2 (manual smoke, real commands): rm -rf apps/api/data then from apps/api: corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start. With curl (or PowerShell Invoke-RestMethod): sign up, sign in (capture set-auth-token), then GET /api/activities, POST /api/sessions/start, POST /api/sessions/:id/stop, GET /api/sessions, GET /api/export — confirm each works with the bearer header and 401 without. Do not commit the local data/ DB (it is gitignored).
  • Step 3: Commit any doc note only: git -C D:/Sven commit -am "docs(api): Phase 1 backend smoke notes" (skip if nothing changed).

Mobile Task 1: Scaffold the fresh Expo app + workspace wiring

Files (create):

  • apps/mobile/package.json, apps/mobile/app.json, apps/mobile/tsconfig.json, apps/mobile/babel.config.js, apps/mobile/.env.example, apps/mobile/.gitignore, apps/mobile/index.ts (Expo Router entry), apps/mobile/src/app/_layout.tsx (placeholder), apps/mobile/metro.config.js (default, monorepo-aware), apps/mobile/jest.config.js, apps/mobile/jest.setup.ts.

Interfaces produced: the @solelog/mobile workspace, runnable with npx expo start / --web, typechecking with tsc --noEmit, and a jest-expo harness that runs.

  • Step 1: Create apps/mobile/package.json. Use Expo Router's standard entry. Minimal deps ONLY:
{
  "name": "@solelog/mobile",
  "version": "0.0.0",
  "private": true,
  "main": "expo-router/entry",
  "scripts": {
    "start": "expo start",
    "web": "expo start --web",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "typecheck": "tsc --noEmit",
    "test": "jest"
  },
  "dependencies": {
    "@solelog/shared": "workspace:*",
    "@tanstack/react-query": "^5.0.0",
    "expo": "*",
    "expo-router": "*",
    "expo-secure-store": "*",
    "expo-status-bar": "*",
    "react": "*",
    "react-dom": "*",
    "react-native": "*",
    "react-native-web": "*"
  },
  "devDependencies": {
    "@testing-library/react-native": "*",
    "@types/jest": "*",
    "@types/react": "*",
    "jest": "*",
    "jest-expo": "*",
    "react-test-renderer": "*",
    "typescript": "*"
  }
}

Install correctly with Expo's resolver so versions match the SDK: from apps/mobile, prefer npx create-expo-app@latest . --template blank-typescript into a temp dir to learn the exact pinned versions, OR run npx expo install expo-router react-native-web react-dom expo-secure-store expo-status-bar and npx expo install --dev jest-expo, which writes SDK-correct versions. Then PIN the resolved exact versions (no ^/*) per the repo convention, add @solelog/shared and @tanstack/react-query, and run corepack yarn install from the repo root so the workspace links. The * above are placeholders to be replaced by the resolved exact versions — do not ship *.

  • Step 2: apps/mobile/app.json — minimal Expo config with the router plugin and web bundler:
{
  "expo": {
    "name": "SoleLog",
    "slug": "solelog",
    "scheme": "solelog",
    "version": "1.0.0",
    "orientation": "portrait",
    "userInterfaceStyle": "light",
    "newArchEnabled": true,
    "web": { "bundler": "metro", "output": "single" },
    "plugins": ["expo-router"]
  }
}
  • Step 3: apps/mobile/tsconfig.json extends expo/tsconfig.base, strict: true, and a @/*src/* path alias:
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
  • Step 4: apps/mobile/babel.config.js: module.exports = (api) => { api.cache(true); return { presets: ['babel-preset-expo'] }; };. metro.config.js: default Expo config made monorepo-aware (watch the repo root, resolve node_modules from both app and root) so @solelog/shared resolves:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;
  • Step 5: apps/mobile/jest.config.js (preset: 'jest-expo', setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], a transformIgnorePatterns that lets @solelog/shared, expo, react-native, @tanstack transform). jest.setup.ts imports @testing-library/react-native matchers if needed. Add a trivial smoke test apps/mobile/src/__tests__/smoke.test.ts (expect(1 + 1).toBe(2)) and run corepack yarn workspace @solelog/mobile test to prove the harness runs.

  • Step 6: Placeholder src/app/_layout.tsx rendering a <Stack /> (expo-router) wrapped in a React Query provider (defined in Mobile Task 2) — for now a bare <Stack /> so expo start boots. .env.example: EXPO_PUBLIC_BASE_URL=http://localhost:3000 plus a commented LAN example # EXPO_PUBLIC_BASE_URL=http://192.168.1.50:3000. .gitignore: .expo/, node_modules/, dist/, *.log, .env.

  • Step 7: Verify: from apps/mobile, corepack yarn typecheck clean and corepack yarn test green. (Do not block the plan on launching a simulator; expo start --web is exercised in Mobile Task 6.) Commit: git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): scaffold fresh @solelog/mobile Expo Router app + workspace wiring"


Mobile Task 2: Token storage + typed API client + React Query provider

Files (create):

  • apps/mobile/src/lib/tokenStore.ts, apps/mobile/src/lib/api.ts, apps/mobile/src/lib/queryClient.tsx
  • apps/mobile/src/lib/__tests__/api.test.ts, apps/mobile/src/lib/__tests__/tokenStore.test.ts

Interfaces produced:

  • tokenStore: getToken(): Promise<string|null>, setToken(t: string): Promise<void>, clearToken(): Promise<void> — 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 <token> 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 setTokens it; returns the user.
    • getActivities(insoleType?), createActivity(input), updateActivity(id, input), deleteActivity(id).
    • startSession(input), stopSession(id), discardSession(id), getSessions(), getActiveSessions().
    • exportUrl() → returns the ${baseUrl}/api/export string (the screen opens/shares it).
    • Return types use @solelog/shared (Activity, WorkSession, etc.); validate responses with the zod schemas where cheap (at least WorkSession.parse on session mutations) so contract drift fails loudly in dev.
  • Step 1 (test first): tokenStore.test.ts — mock expo-secure-store (jest jest.mock('expo-secure-store')) and assert setToken/getToken/clearToken call the right SecureStore methods with a stable key ('solelog-auth-token'). Implement tokenStore.ts. Green.

  • Step 2 (test first): api.test.ts — mock global.fetch. Assert:

    • signIn POSTs to ${baseUrl}/api/auth/sign-in/email with the email/password body, reads the set-auth-token header from the mocked response, and calls tokenStore.setToken with it (mock tokenStore).
    • getActivities() GETs ${baseUrl}/api/activities with Authorization: Bearer <stored token> when a token is present (mock getToken'tok'), and WITHOUT the header when no token.
    • getActivities('3D') appends ?insole_type=3D.
    • startSession({activity_id:1,insole_type:'Kurk',pair_count:2}) POSTs to /api/sessions/start and returns a parsed WorkSession (feed a valid fixture from the mock).
    • A non-2xx response throws ApiError with the right status. Implement api.ts. Green.
  • Step 3: queryClient.tsx exports a QueryClient (defaults: staleTime 5 min, retry 1, refetchOnWindowFocus: false — matching the legacy app) and an <AppQueryProvider> wrapping QueryClientProvider. Wire it into src/app/_layout.tsx. (No new test needed; covered by the screen render smoke test in Mobile Task 6.)

  • Step 4: Typecheck + test green, commit: git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): secure token store + typed API client + query provider"


Mobile Task 3: Auth gate + login/sign-up screen

Files (create):

  • apps/mobile/src/lib/auth.tsx (an AuthProvider + useAuth() hook: token, isReady, signIn, signUp, signOut)
  • apps/mobile/src/app/login.tsx (the login/sign-up screen, Dutch)
  • Modify: apps/mobile/src/app/_layout.tsx (gate: while !isReady render null/splash; if no token redirect to /login; else render the tabs)
  • apps/mobile/src/app/(tabs)/_layout.tsx (3-tab navigator placeholder so routing exists)
  • apps/mobile/src/lib/__tests__/auth.test.tsx

Interfaces produced: useAuth() context. On mount it calls tokenStore.getToken() to restore the session (with a timeout escape hatch so a stuck read can't freeze launch — lesson from docs/reference/legacy-lessons-and-gotchas.md §1), sets isReady. signIn/signUp delegate to api, store the token, set token in state; signOut clears it.

  • Step 1 (test first): auth.test.tsx — render a component using useAuth() inside <AuthProvider> with api/tokenStore mocked. Assert: starts isReady:false, becomes isReady:true with token from getToken; calling signIn sets token; signOut clears it. Implement auth.tsx. Green.

  • Step 2: Build login.tsx (RN StyleSheet, no extra libs): email TextInput (keyboardType:'email-address', autoCapitalize:'none'), password TextInput (secureTextEntry), a primary button Inloggen ("Sign in") calling signIn, and a small text affordance Account aanmaken ("Create account") that toggles to sign-up (button becomes Registreren). On error show the message (Dutch fallback Inloggen mislukt / Registreren mislukt). On success the auth state flips and the gate routes into the tabs. Use the blue palette from docs/reference/legacy-mobile-app.md §2 for consistency.

  • Step 3: _layout.tsx gate + (tabs)/_layout.tsx (expo-router Tabs with three screens in order — index titled Stopwatch, history titled Geschiedenis, tasks titled Instellingen; tab tint #2563EB/#6B7280). Tabs may use simple text labels (NO lucide/icon libraries — keep deps minimal; an emoji or omitted icon is fine). Add temporary stub screens for the three tabs so navigation compiles.

  • Step 4: Typecheck + test green, commit: git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): auth gate + Dutch login/sign-up screen + 3-tab shell"


Mobile Task 4: Stopwatch screen ((tabs)/index.tsx)

Files (create/modify):

  • apps/mobile/src/lib/timer.ts (pure timing helpers — unit-testable, no React)
  • apps/mobile/src/app/(tabs)/index.tsx (the Stopwatch screen)
  • apps/mobile/src/lib/__tests__/timer.test.ts

Behaviour (from docs/reference/legacy-mobile-app.md §4), adapted to server-authoritative sessions:

  • Type zool segmented selector (Kurk/Berk/3D, default Kurk); changing it clears the chosen activity. Type handeling dropdown listing activities filtered to the chosen zool (activity.insole_types.includes(insoleType)); placeholder Kies een handeling...; empty-state Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen. Aantal zolen stepper (default 2, min 1). All three selectors lock while a session is active.

  • Activities fetched via React Query ['activities']api.getActivities().

  • Server-authoritative lifecycle:

    • Start (enabled only when an activity is chosen): api.startSession({ activity_id, insole_type, pair_count }); store the returned WorkSession (its id, start_time) as the active session.
    • The on-screen timer is display-only, computed by wall-clock delta from the server start_time (elapsed = now - new Date(session.start_time)), updated every second via setInterval. Pause/resume only freezes the display (do NOT call the server; Phase 1 has no server pause). Use timer.formatHMS(seconds).
    • Stop & Opslaan: api.stopSession(activeSession.id); on success invalidate ['sessions'] + ['activeSessions'], reset the timer (keep the selections, like legacy).
    • Annuleren double-press discard (3 s arm window, Nogmaals tikken ter bevestiging when armed): second tap calls api.discardSession(activeSession.id) and resets.
  • Recovery on launch: useQuery(['activeSessions'], api.getActiveSessions); if it returns a session, adopt it as the active session and resume the display timer from its start_time (so a phone restart doesn't lose an open session). Dutch strings exactly per the reference §7 inventory.

  • Step 1 (test first): timer.test.ts for pure helpers in timer.ts:

    • formatHMS(0) === '00:00:00', formatHMS(65) === '00:01:05', formatHMS(3661) === '01:01:01', formatHMS(360000) === '100:00:00' (hours can exceed 99).
    • elapsedSeconds(startISO, nowMs) returns whole seconds between an ISO start and a now epoch-ms, floored, never negative (clamp to 0 if now < start). Test a 65 000 ms gap → 65; a negative gap → 0. Implement timer.ts. Green.
  • Step 2: Build index.tsx using timer.ts, the API client, and React Query. State machine and Dutch strings per §4/§4.9 of the reference, but every start/stop/discard is a server call as above. No extra libraries (RN Modal for the picker sheet is fine; no animation lib required — a simple modal list is acceptable for Phase 1).

  • Step 3: Typecheck + timer.test.ts green, commit: git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): server-authoritative Stopwatch screen + timing helpers"


Mobile Task 5: Geschiedenis (history) + Instellingen (settings) screens

Files (create/modify):

  • apps/mobile/src/app/(tabs)/history.tsx
  • apps/mobile/src/app/(tabs)/tasks.tsx
  • apps/mobile/src/lib/format.ts (formatDuration, formatDate, formatTime, pluralInsoles) + apps/mobile/src/lib/__tests__/format.test.ts

History (docs/reference/legacy-mobile-app.md §5): header Geschiedenis + an Exporteer CSV action; list via React Query ['sessions']api.getSessions(). Each card: activity_name, date/time line, badges for insole_type, pair count (inlegzool/inlegzolen singular/plural), and formatDuration(duration_seconds) (Xh Ym / Ym Zs / Zs). Empty-state Nog geen opgeslagen sessies. The CSV action opens api.exportUrl() — on web, open in a new tab / trigger download (window.open / an <a download>); 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://<PC-LAN-IP>: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.