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

1198 lines
59 KiB
Markdown
Raw Permalink Blame History

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