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

1081 lines
60 KiB
Markdown

# 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.parse`s on read, so route code sees a real `string[]`. The
zod contract validates it as `z.array(InsoleType)`. **Never** filter inside SQL on this column; the
`?insole_type=` filter on `GET /api/activities` is applied **in JS** after fetching the user-visible
rows (the dataset is tiny — a handful of activities). Document this in a code comment.
2. **Timestamps.** Reuse the better-auth convention already in `schema.ts`:
`integer({ mode: 'timestamp_ms' })` (epoch-ms; Drizzle maps to/from `Date`). `start_time` is set at
`start`; `end_time` is `null` while active. The API contract serialises timestamps as **ISO-8601
strings** (`Date.toISOString()`), so the mobile client and CSV are timezone-explicit (UTC). The
wire/JSON shape is always ISO strings; the DB stores epoch-ms.
3. **`duration_seconds`.** Server-authoritative and **computed on stop** as
`Math.round((end_time - start_time) / 1000)` (whole seconds). This differs deliberately from the
legacy client-tick count (see `docs/reference/legacy-lessons-and-gotchas.md` §6 — tick counting
under-counts when backgrounded). The mobile timer is display-only; the server number is the source of
truth. `notes` and a future pause model are out of scope for Phase 1 (pause does not change the
server session; it only freezes the on-screen display).
4. **CSV format.** Match `docs/reference/legacy-backend.md` §4 as closely as the new (user-scoped,
completed-only) data allows: `text/csv; charset=utf-8`,
`Content-Disposition: attachment; filename="insole-production-report.csv"`, every cell quoted with
`"` doubling, rows joined with `\n`, ordered `start_time ASC`. Columns are reduced to the set this
ticket specifies (activity name, insole type, pair count, start, end, duration_seconds) — see Backend
Task 7 for the exact header row. Format dates/times explicitly in UTC ISO to avoid the legacy
server-timezone fragility.
5. **CORS.** Add `hono/cors` so Expo web (a browser origin, e.g. `http://localhost:8081`) can call the
API cross-origin with `Authorization: Bearer …`. Because auth is **bearer-token only** for the
mobile/web client (no cookies), CORS does not need `credentials: true`; allow the `Authorization` and
`Content-Type` request headers and expose `set-auth-token`. The allow-list is env-driven and kept
**consistent with better-auth `trustedOrigins`** by reading the same env var (see Backend Task 8).
6. **`EXPO_PUBLIC_BASE_URL`.** The mobile client's typed API client reads
`process.env.EXPO_PUBLIC_BASE_URL` and defaults to `http://localhost:3000`. For device testing over
LAN, set it to the PC's LAN IP (e.g. `http://192.168.1.50:3000`) in `apps/mobile/.env`. The
`EXPO_PUBLIC_` prefix makes Expo inline it into the bundle. Document both values in `.env.example`.
## Domain data model (added to `apps/api/src/db/schema.ts`)
```
activities
id integer PK autoincrement
name text NOT NULL
insole_types text(json) NOT NULL -- InsoleType[] subset of 'Kurk'|'Berk'|'3D'
created_at integer timestamp_ms DEFAULT now NOT NULL
work_sessions
id integer PK autoincrement
user_id text NOT NULL FK -> user.id ON DELETE CASCADE
activity_id integer NOT NULL FK -> activities.id
insole_type text NOT NULL -- 'Kurk'|'Berk'|'3D'
pair_count integer NOT NULL DEFAULT 2
start_time integer timestamp_ms NOT NULL
end_time integer timestamp_ms NULL -- null = active
duration_seconds integer NULL -- null until stopped
status text NOT NULL DEFAULT 'active' -- 'active'|'completed'|'discarded'
source text NOT NULL DEFAULT 'app' -- 'app'|'manual'
notes text NULL
created_at integer timestamp_ms DEFAULT now NOT NULL
```
Indices: `work_sessions(user_id)`, `work_sessions(activity_id)`, `work_sessions(user_id, status)`.
---
# Backend Task 1: Domain contracts in `packages/shared`
**Files:**
- Modify: `packages/shared/src/index.ts`
- Create: `packages/shared/test/contracts.test.ts`
- Modify: `packages/shared/package.json` (add `vitest` devDep + `test` script) and create
`packages/shared/vitest.config.ts`
**Interfaces produced** (all exported as `const` zod schema + inferred `type` of the same name, the
existing convention in this file):
```ts
// enums
InsoleType = z.enum(['Kurk', 'Berk', '3D'])
SessionStatus = z.enum(['active', 'completed', 'discarded'])
SessionSource = z.enum(['app', 'manual'])
// Activity (response shape; timestamps are ISO strings on the wire)
Activity = z.object({
id: z.number().int(),
name: z.string(),
insole_types: z.array(InsoleType),
created_at: z.string(), // ISO
})
// Activity write payloads
CreateActivityInput = z.object({
name: z.string().trim().min(1),
insole_types: z.array(InsoleType).min(1),
})
UpdateActivityInput = CreateActivityInput // same shape
// WorkSession (response shape)
WorkSession = z.object({
id: z.number().int(),
user_id: z.string(),
activity_id: z.number().int(),
activity_name: z.string(), // joined from activities.name
insole_type: InsoleType,
pair_count: z.number().int(),
start_time: z.string(), // ISO
end_time: z.string().nullable(),// ISO or null
duration_seconds: z.number().int().nullable(),
status: SessionStatus,
source: SessionSource,
notes: z.string().nullable(),
created_at: z.string(),
})
// Session write payloads
StartSessionInput = z.object({
activity_id: z.number().int(),
insole_type: InsoleType,
pair_count: z.number().int().positive().default(2),
})
// list responses
ActivityList = z.array(Activity)
WorkSessionList = z.array(WorkSession)
```
Keep the existing `HealthResponse`, `PublicUser`, `MeResponse` exports untouched.
- [ ] **Step 1 (test first):** Create `packages/shared/vitest.config.ts`:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node' } });
```
Add to `packages/shared/package.json`: `"scripts": { "test": "vitest run" }` and
`"devDependencies": { "vitest": "^3.0.0" }`. Run `corepack yarn install` from the repo root.
- [ ] **Step 2 (test first):** Write `packages/shared/test/contracts.test.ts` asserting:
- `InsoleType.parse('Kurk')` succeeds; `InsoleType.safeParse('Leer').success === false`.
- `CreateActivityInput.parse({ name: ' Leerrand ', insole_types: ['Kurk'] })` returns
`name: 'Leerrand'` (trim) and the types array.
- `CreateActivityInput.safeParse({ name: '', insole_types: ['Kurk'] }).success === false` (empty name).
- `CreateActivityInput.safeParse({ name: 'X', insole_types: [] }).success === false` (≥1 type).
- `StartSessionInput.parse({ activity_id: 1, insole_type: 'Berk' }).pair_count === 2` (default applied).
- `StartSessionInput.safeParse({ activity_id: 1, insole_type: 'Berk', pair_count: 0 }).success === false`.
- `WorkSession.parse(<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):
```ts
import type { InsoleType } from '@solelog/shared';
// (add `sqliteTable, text, integer, index` are already imported at the top)
export const activities = sqliteTable('activities', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
// SQLite has no array type: store InsoleType[] as a JSON string. Drizzle
// (mode:'json') serialises on write and JSON.parses on read. NEVER filter on
// this column in SQL — the ?insole_type= filter is applied in JS (tiny dataset).
insoleTypes: text('insole_types', { mode: 'json' }).$type<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 test``schema.test.ts` fails (tables not migrated in the
test DB). NOTE: `test/setup.ts` runs `runMigrations()` which applies `./drizzle`, so once `0001` exists
it is applied automatically to the fresh `.tmp/test.db`. The failure before generating is the FK/table
missing; after Step 2 + implementing the schema it goes green.
- [ ] **Step 4:** Create the seed script `apps/api/src/db/seed.ts` — idempotent (insert only if the
`activities` table is empty), realistic activities per `docs/reference/legacy-mobile-app.md`
(`Leerrand` example + plausible insole-production steps). Use this exact seed set:
```ts
// apps/api/src/db/seed.ts
import { db } from './client';
import { activities } from './schema';
const SEED_ACTIVITIES: { name: string; insole_types: ('Kurk' | 'Berk' | '3D')[] }[] = [
{ name: 'Uitsnijden', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Leerrand', insole_types: ['Kurk', 'Berk'] },
{ name: 'Slijpen', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Lijmen', insole_types: ['Kurk', 'Berk'] },
{ name: 'Bekleden', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Frezen', insole_types: ['3D'] },
{ name: 'Afwerken', insole_types: ['Kurk', 'Berk', '3D'] },
];
export async function seed(): Promise<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`:
```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:
```ts
async function tokenFor(app, email) {
const json = { 'content-type': 'application/json' };
await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json,
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123', name: email.split('@')[0] }) });
const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json,
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123' }) });
return signin.headers.get('set-auth-token');
}
const authHeaders = (t) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });
```
Tests:
- `GET /api/activities` **without** a token → `401`.
- `POST /api/activities` without a token → `401`.
- With a token, `POST /api/activities` `{ name: 'Leerrand', insole_types: ['Kurk','Berk'] }``201`
(or `200`), body validates against `Activity`, `insole_types` is `['Kurk','Berk']`.
- `POST` with `{ name: '', insole_types: ['Kurk'] }``400`.
- `POST` with `{ name: 'X', insole_types: [] }``400`.
- `GET /api/activities` with a token returns an array including the created activity, validates against
`ActivityList`.
- `GET /api/activities?insole_type=3D` returns only activities whose `insole_types` include `3D`
(create one `['3D']` and one `['Kurk']`, assert filtering happens in JS).
Run — fails (route not mounted).
- [ ] **Step 4:** Implement `activities.ts`:
- `GET /api/activities`: `requireUser(c)`; read optional `c.req.query('insole_type')`, validate it with
`InsoleType.safeParse` (ignore if invalid/absent); `db.select().from(activities).orderBy(activities.name)`;
if filter present, `rows.filter(r => r.insole_types.includes(filter))` **in JS**; map to `Activity[]`;
`c.json(list)`.
- `POST /api/activities`: `requireUser(c)`; parse body with `CreateActivityInput.safeParse`; on failure
`c.json({ error: 'Invalid input' }, 400)`; insert `{ name, insoleTypes: insole_types }` with
`.returning()`; map and `c.json(activity, 201)`.
Mount in `app.ts`: `app.route('/', activities);` (after `me`). Re-run until green.
- [ ] **Step 5:** Full suite + typecheck green, commit:
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET/POST /api/activities (user-authed)"`
---
# Backend Task 4: Activities routes (PUT/DELETE)
**Files:**
- Modify: `apps/api/src/routes/activities.ts` (add `PUT`/`DELETE /api/activities/:id`)
- Modify: `apps/api/test/activities.test.ts`
**Interfaces:** `PUT /api/activities/:id` (consumes `UpdateActivityInput`, returns `Activity`),
`DELETE /api/activities/:id` (returns `{ success: true }`).
- [ ] **Step 1 (test first):** Add tests:
- `PUT` without token → `401`; `DELETE` without token → `401`.
- `PUT /api/activities/:id` with `{ name: 'Slijpen', insole_types: ['3D'] }` updates and returns the
row (validates `Activity`, name + types changed).
- `PUT` a non-existent id (e.g. `999999`) → `404 { error: 'Activity not found' }`.
- `PUT` with empty name → `400`.
- `DELETE /api/activities/:id``200 { success: true }`; a subsequent `GET` no longer lists it.
- Decide and TEST the cascade choice: deleting an activity that has `work_sessions` — Phase 1 keeps it
simple and **blocks** deletion when sessions reference it: return `409 { error: 'Activity in use' }`
if any `work_sessions.activity_id = :id` exists. Add a test: start a session against an activity,
then `DELETE` it → `409`; the activity still lists. (This avoids destroying a worker's history,
unlike the legacy cascade. Document the divergence in a comment.)
- [ ] **Step 2:** Implement:
- `PUT`: `requireUser`; `UpdateActivityInput.safeParse``400`; `db.update(activities).set({ name,
insoleTypes }).where(eq(activities.id, id)).returning()`; empty result → `404`; else map + `c.json`.
Parse `id` with `Number(c.req.param('id'))`; non-numeric → `404`.
- `DELETE`: `requireUser`; check `db.select().from(workSessions).where(eq(workSessions.activityId,
id)).limit(1)`; if found → `409`; else `db.delete(activities).where(eq(activities.id, id))`;
`c.json({ success: true })`.
- [ ] **Step 3:** Green + typecheck, commit:
`git -C D:/Sven commit -am "feat(api): PUT/DELETE /api/activities with in-use guard"`
---
# Backend Task 5: Sessions lifecycle — start / stop / discard
**Files:**
- Create: `apps/api/test/helpers.ts` (extract `tokenFor`, `authHeaders` for reuse)
- Create: `apps/api/src/routes/sessions.ts`
- Modify: `apps/api/src/app.ts` (mount `sessions`)
- Create: `apps/api/test/sessions.test.ts`
**Interfaces produced:** a `sessions` Hono router with
`POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `POST /api/sessions/:id/discard`.
Consumes `StartSessionInput`, returns `WorkSession`. A `toWorkSession(row, activityName)` serialiser
(ISO timestamps, `null`-safe `end_time`/`duration_seconds`).
- [ ] **Step 1:** Create `apps/api/test/helpers.ts` exporting `tokenFor(app, email)` and
`authHeaders(token)` (move them out of `activities.test.ts` and import them there too, keeping that
suite green).
- [ ] **Step 2 (test first):** Write `apps/api/test/sessions.test.ts`. Seed an activity (call `seed()` or
POST one). Cases:
- `POST /api/sessions/start` without token → `401`.
- `POST /api/sessions/start` `{ activity_id, insole_type:'Kurk', pair_count:3 }` → `201`, validates
`WorkSession`: `status:'active'`, `end_time:null`, `duration_seconds:null`, `source:'app'`,
`pair_count:3`, `activity_name` set, `user_id` = caller.
- `POST /api/sessions/start` with `pair_count` omitted defaults to `2`.
- `POST /api/sessions/start` with an `activity_id` that does not exist → `404
{ error: 'Activity not found' }`.
- `POST /api/sessions/start` with `insole_type:'Kurk'` but the activity does NOT support `'Kurk'` →
`400 { error: 'Insole type not valid for activity' }` (create an activity `['3D']`, start with
`'Kurk'`). Add a test.
- **Stop lifecycle:** start a session, then `POST /api/sessions/:id/stop` → `200`,
`status:'completed'`, `end_time` is a non-null ISO string, `duration_seconds` is an integer `>= 0`.
Because start/stop happen within the same test tick, assert `duration_seconds >= 0` (not strictly
positive) and that `end_time >= start_time`.
- **Stop is owner-scoped:** user A starts a session; user B (`tokenFor(app,'b@…')`) calls
`POST /api/sessions/:idOfA/stop` → `404` (B must not learn A's session exists; treat
not-owned == not-found). The session stays `active` for A.
- **Double-stop rejected:** stop a session, stop it again → `409 { error: 'Session already closed' }`.
- **Discard:** start a session, `POST /api/sessions/:id/discard` → `200`, `status:'discarded'`.
Discarding a non-owned session → `404`. Discarding an already-closed session → `409`.
- `:id` non-numeric or unknown → `404`.
- [ ] **Step 3:** Implement `sessions.ts`:
- `start`: `requireUser`; `StartSessionInput.safeParse(body)` → `400`; load the activity by id, `404`
if missing; if `!activity.insole_types.includes(input.insole_type)` → `400`; insert
`{ userId, activityId, insoleType, pairCount, startTime: new Date(), status:'active',
source:'app' }` with `.returning()`; `c.json(toWorkSession(row, activity.name), 201)`.
- `stop`: `requireUser`; parse id; load the row `where(and(eq(id), eq(userId, user.id)))`; missing →
`404`; if `row.status !== 'active' || row.endTime !== null` → `409`; compute `end = new Date()`,
`duration = Math.max(0, Math.round((end.getTime() - row.startTime.getTime()) / 1000))`;
`db.update(workSessions).set({ endTime: end, durationSeconds: duration, status: 'completed' })
.where(eq(workSessions.id, id)).returning()`; join the activity name; `c.json(...)`.
- `discard`: `requireUser`; same ownership load; `409` if not `active`; set `status:'discarded'`
(leave `end_time`/`duration` null — it was thrown away); return the row.
- Use `import { and, eq } from 'drizzle-orm'`. For the activity name, either a join or a second select
on `activities` by `activityId`. Mount `app.route('/', sessions)`.
- [ ] **Step 4:** Green + typecheck, commit:
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): work-session start/stop/discard (owner-scoped, server-authoritative duration)"`
---
# Backend Task 6: Sessions reads — history + active recovery
**Files:**
- Modify: `apps/api/src/routes/sessions.ts` (add `GET /api/sessions`, `GET /api/sessions/active`)
- Modify: `apps/api/test/sessions.test.ts`
> **Routing order matters:** register `GET /api/sessions/active` BEFORE any `/:id`-style route so
> `active` is not captured as an id. (The lifecycle routes are all under `/api/sessions/:id/<verb>`, so
> there is no direct conflict, but keep `active` explicit and first among GETs.)
**Interfaces:** `GET /api/sessions` → `WorkSessionList` (caller's sessions, all statuses, newest first by
`start_time`, each with `activity_name`). `GET /api/sessions/active` → `WorkSessionList` (the caller's
`status:'active'` sessions only, newest first) for crash/recovery on app launch.
- [ ] **Step 1 (test first):** Add tests:
- `GET /api/sessions` without token → `401`. `GET /api/sessions/active` without token → `401`.
- **Ownership scoping:** A starts+stops one session and starts a second (active); B starts one. `GET
/api/sessions` as A returns exactly A's 2 sessions (and none of B's); validates `WorkSessionList`;
ordered newest-first by `start_time` (assert the active/newer one is `[0]`).
- `GET /api/sessions/active` as A returns exactly the 1 active session; after stopping it, returns `[]`.
- Each returned item has `activity_name` populated (join correctness).
- [ ] **Step 2:** Implement:
- `GET /api/sessions`: `requireUser`; select `work_sessions` joined to `activities`
`where(eq(workSessions.userId, user.id)).orderBy(desc(workSessions.startTime))`; map → `WorkSession[]`.
- `GET /api/sessions/active`: same but `and(eq(userId), eq(status, 'active'))`.
- Use `import { desc } from 'drizzle-orm'`. Prefer a Drizzle `innerJoin(activities, eq(...))` selecting
explicit columns + `activities.name` so the serialiser has the name without a second query.
- [ ] **Step 3:** Green + typecheck, commit:
`git -C D:/Sven commit -am "feat(api): GET /api/sessions history + /api/sessions/active recovery"`
---
# Backend Task 7: CSV export
**Files:**
- Create: `apps/api/src/routes/export.ts`
- Modify: `apps/api/src/app.ts` (mount `export`)
- Create: `apps/api/test/export.test.ts`
**Interfaces:** `GET /api/export` → `text/csv` of the **caller's completed** sessions, ordered
`start_time ASC`. Header row (exact):
`"Activity","Insole Type","Pair Count","Start","End","Duration (s)"`. Each data cell quoted with `"`,
embedded `"` doubled; rows joined with `\n`. `Start`/`End` are ISO-8601 UTC strings; `Duration (s)` is
`duration_seconds`. Response headers: `Content-Type: text/csv; charset=utf-8`,
`Content-Disposition: attachment; filename="insole-production-report.csv"`.
- [ ] **Step 1 (test first):** Write `apps/api/test/export.test.ts`:
- `GET /api/export` without token → `401`.
- As user A: start+stop two sessions on a seeded activity (and start one active session that must NOT
appear). `GET /api/export` → `200`; `Content-Type` includes `text/csv`; `Content-Disposition`
contains `insole-production-report.csv`. Parse the body: first line equals the exact header above;
there are exactly 2 data rows (active one excluded); each row has 6 quoted fields; the `Activity`
cell equals the activity name; `Duration (s)` is the integer string. Ordered ascending by start.
- **Ownership:** B's completed sessions never appear in A's export (start+stop one as B; A's export
still has only its 2 rows).
- A cell-quoting test: create an activity whose name contains a `"` (e.g. `He"llo`), stop a session on
it, assert the CSV contains `"He""llo"`.
- [ ] **Step 2:** Implement `export.ts`:
- `requireUser`; select completed sessions joined to activity name
`where(and(eq(userId, user.id), eq(status, 'completed'))).orderBy(asc(workSessions.startTime))`.
- `const quote = (v: unknown) => '"' + String(v ?? '').replace(/"/g, '""') + '"';`
- Header: `['Activity','Insole Type','Pair Count','Start','End','Duration (s)'].map(quote).join(',')`.
- Each row: `[activityName, insoleType, pairCount, startTime.toISOString(),
endTime?.toISOString() ?? '', durationSeconds ?? ''].map(quote).join(',')`.
- Body = `[header, ...rows].join('\n')`. Return via `c.body(csv, 200, { 'Content-Type': 'text/csv;
charset=utf-8', 'Content-Disposition': 'attachment; filename="insole-production-report.csv"' })`
(verify the `c.body(...)` header-object signature against hono 4.12.25; otherwise build a `Response`
with `new Response(csv, { headers })`).
- [ ] **Step 3:** Green + typecheck, commit:
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET /api/export CSV of completed sessions (owner-scoped)"`
---
# Backend Task 8: CORS + trusted-origins consistency
**Files:**
- Modify: `apps/api/src/env.ts` (add `CORS_ORIGINS`)
- Modify: `apps/api/src/app.ts` (apply `hono/cors`)
- Modify: `apps/api/src/auth.ts` (derive `trustedOrigins` from the SAME env) — *adding to the
trustedOrigins array is allowed; do NOT change any other auth option*
- Modify: `apps/api/.env.example` and `docker-compose.yml` (document `CORS_ORIGINS`)
- Create: `apps/api/test/cors.test.ts`
**Interfaces:** `env.CORS_ORIGINS: string[]` (parsed from a comma-separated env var, defaulting to the
local dev origins). CORS middleware allows those origins; better-auth `trustedOrigins` includes the
same list so a browser client passes CSRF/origin checks.
- [ ] **Step 1:** In `env.ts` add:
```ts
CORS_ORIGINS: (process.env.CORS_ORIGINS ?? 'http://localhost:8081,http://localhost:19006,http://localhost:3000')
.split(',').map((s) => s.trim()).filter(Boolean),
```
(`8081` = Expo web/Metro default; `19006` = legacy Expo web; `3000` = the API's own origin.)
- [ ] **Step 2:** In `auth.ts`, change `trustedOrigins` to merge the existing values with
`env.CORS_ORIGINS` (dedup). Do NOT alter `secret`, `database`, `emailAndPassword`, or `plugins`.
Example: `trustedOrigins: Array.from(new Set([env.BETTER_AUTH_URL, 'http://localhost:3000',
...env.CORS_ORIGINS]))`.
- [ ] **Step 3 (test first):** Write `apps/api/test/cors.test.ts`:
- A browser **preflight**: `OPTIONS /api/activities` with headers `Origin: http://localhost:8081`,
`Access-Control-Request-Method: GET`, `Access-Control-Request-Headers: authorization` →
response has `Access-Control-Allow-Origin: http://localhost:8081` and the allow-headers list
includes `authorization` (case-insensitive check).
- A real `GET /api/activities` (no token) with `Origin: http://localhost:8081` still returns `401`
AND carries `Access-Control-Allow-Origin` (CORS headers present even on error responses).
- An origin NOT in the list (`http://evil.example`) does not get an `Access-Control-Allow-Origin:
http://evil.example` echo. (Assert the header is absent or not equal to the evil origin, per the
installed `hono/cors` behaviour — adapt the assertion to what the middleware actually does.)
- [ ] **Step 4:** In `app.ts`, apply CORS **before** the routes:
```ts
import { cors } from 'hono/cors';
// inside createApp(), first:
app.use('*', cors({
origin: env.CORS_ORIGINS,
allowHeaders: ['Authorization', 'Content-Type'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
exposeHeaders: ['set-auth-token'],
}));
```
> Bearer-only auth means `credentials: true` is NOT required. Verify the `origin` option accepts a
> string array in hono 4.12.25 (it does; otherwise pass a function that returns the origin when it is in
> `env.CORS_ORIGINS`). The **installed middleware is authoritative** — shape the config + the test
> assertions to its real behaviour.
- [ ] **Step 5:** Update `.env.example` (add `CORS_ORIGINS=http://localhost:8081,http://localhost:3000`)
and add the same env to `docker-compose.yml`. Full suite green, typecheck clean, commit:
`git -C D:/Sven add apps/api docker-compose.yml && git -C D:/Sven commit -m "feat(api): CORS for browser clients, trustedOrigins consistency"`
---
# Backend Task 9: Backend wrap-up — full green + manual smoke
**Files:** none (verification only) — optionally a short `apps/api/README.md` note.
- [ ] **Step 1:** From `apps/api`: `corepack yarn test` (ALL suites: the original 4 files + the new
`schema`, `seed`, `activities`, `sessions`, `export`, `cors`), `corepack yarn typecheck`,
`npx oxlint` (root config). All must pass. Record the real counts.
- [ ] **Step 2 (manual smoke, real commands):** `rm -rf apps/api/data` then from `apps/api`:
`corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start`. With `curl` (or
PowerShell `Invoke-RestMethod`): sign up, sign in (capture `set-auth-token`), then `GET
/api/activities`, `POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `GET /api/sessions`,
`GET /api/export` — confirm each works with the bearer header and `401` without. Do not commit the
local `data/` DB (it is gitignored).
- [ ] **Step 3:** Commit any doc note only:
`git -C D:/Sven commit -am "docs(api): Phase 1 backend smoke notes"` (skip if nothing changed).
---
# Mobile Task 1: Scaffold the fresh Expo app + workspace wiring
**Files (create):**
- `apps/mobile/package.json`, `apps/mobile/app.json`, `apps/mobile/tsconfig.json`,
`apps/mobile/babel.config.js`, `apps/mobile/.env.example`, `apps/mobile/.gitignore`,
`apps/mobile/index.ts` (Expo Router entry), `apps/mobile/src/app/_layout.tsx` (placeholder),
`apps/mobile/metro.config.js` (default, monorepo-aware), `apps/mobile/jest.config.js`,
`apps/mobile/jest.setup.ts`.
**Interfaces produced:** the `@solelog/mobile` workspace, runnable with `npx expo start` /
`--web`, typechecking with `tsc --noEmit`, and a jest-expo harness that runs.
- [ ] **Step 1:** Create `apps/mobile/package.json`. Use Expo Router's standard entry. Minimal deps ONLY:
```json
{
"name": "@solelog/mobile",
"version": "0.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"web": "expo start --web",
"android": "expo start --android",
"ios": "expo start --ios",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@solelog/shared": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"expo": "*",
"expo-router": "*",
"expo-secure-store": "*",
"expo-status-bar": "*",
"react": "*",
"react-dom": "*",
"react-native": "*",
"react-native-web": "*"
},
"devDependencies": {
"@testing-library/react-native": "*",
"@types/jest": "*",
"@types/react": "*",
"jest": "*",
"jest-expo": "*",
"react-test-renderer": "*",
"typescript": "*"
}
}
```
> **Install correctly with Expo's resolver** so versions match the SDK: from `apps/mobile`, prefer
> `npx create-expo-app@latest . --template blank-typescript` into a temp dir to learn the exact pinned
> versions, OR run `npx expo install expo-router react-native-web react-dom expo-secure-store
> expo-status-bar` and `npx expo install --dev jest-expo`, which writes SDK-correct versions. Then PIN
> the resolved exact versions (no `^`/`*`) per the repo convention, add `@solelog/shared` and
> `@tanstack/react-query`, and run `corepack yarn install` from the repo root so the workspace links.
> The `*` above are placeholders to be replaced by the resolved exact versions — **do not ship `*`**.
- [ ] **Step 2:** `apps/mobile/app.json` — minimal Expo config with the router plugin and web bundler:
```json
{
"expo": {
"name": "SoleLog",
"slug": "solelog",
"scheme": "solelog",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"web": { "bundler": "metro", "output": "single" },
"plugins": ["expo-router"]
}
}
```
- [ ] **Step 3:** `apps/mobile/tsconfig.json` extends `expo/tsconfig.base`, `strict: true`, and a
`@/*``src/*` path alias:
```json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
```
- [ ] **Step 4:** `apps/mobile/babel.config.js`: `module.exports = (api) => { api.cache(true); return {
presets: ['babel-preset-expo'] }; };`. `metro.config.js`: default Expo config made monorepo-aware
(watch the repo root, resolve `node_modules` from both app and root) so `@solelog/shared` resolves:
```js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;
```
- [ ] **Step 5:** `apps/mobile/jest.config.js` (`preset: 'jest-expo'`,
`setupFilesAfterEnv: ['<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 `setToken`s it; returns the user.
- `getActivities(insoleType?)`, `createActivity(input)`, `updateActivity(id, input)`,
`deleteActivity(id)`.
- `startSession(input)`, `stopSession(id)`, `discardSession(id)`, `getSessions()`,
`getActiveSessions()`.
- `exportUrl()` → returns the `${baseUrl}/api/export` string (the screen opens/shares it).
- Return types use `@solelog/shared` (`Activity`, `WorkSession`, etc.); validate responses with the
zod schemas where cheap (at least `WorkSession.parse` on session mutations) so contract drift fails
loudly in dev.
- [ ] **Step 1 (test first):** `tokenStore.test.ts` — mock `expo-secure-store` (jest
`jest.mock('expo-secure-store')`) and assert `setToken`/`getToken`/`clearToken` call the right
SecureStore methods with a stable key (`'solelog-auth-token'`). Implement `tokenStore.ts`. Green.
- [ ] **Step 2 (test first):** `api.test.ts` — mock `global.fetch`. Assert:
- `signIn` POSTs to `${baseUrl}/api/auth/sign-in/email` with the email/password body, reads the
`set-auth-token` header from the mocked response, and calls `tokenStore.setToken` with it
(mock `tokenStore`).
- `getActivities()` GETs `${baseUrl}/api/activities` with `Authorization: Bearer <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.