59 KiB
Phase 1 — Worker Timing Implementation Plan (Web Client)
For agentic workers: REQUIRED SUB-SKILL — use
superpowers:test-driven-developmentfor every task andsuperpowers:subagent-driven-development(orsuperpowers: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 inpackages/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 inlocalStorage), attachesAuthorization: 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 itstrustedOriginsarray (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/apigreen at every commit (yarn workspace @solelog/api testpasses). - SQLite array storage:
insole_typesis stored via Drizzletext('insole_types', { mode: 'json' })(libsql stores it as a JSON string; Drizzle (de)serialises to/fromstring[]). The shared zod schema validates the subset of'Kurk' | 'Berk' | '3D'. - Timestamps: store
start_time/end_time/created_atasinteger({ mode: 'timestamp_ms' })(epoch-ms, same convention better-auth uses inschema.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 withyarn 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) runsrunMigrations()against a fresh./.tmp/test.dbbefore 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_typesdefaults to all three. - Seed activities (realistic Dutch handeling names;
Leerrandis 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):
const quote = (value: unknown) => `"${String(value).replace(/"/g, '""')}"`;
function formatDuration(totalSeconds: number): string {
const s = totalSeconds || 0;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
Backend Task 1: Domain schema + migration + shared contracts (activities & work_sessions)
Outcome: activities and work_sessions tables exist in a NEW migration; the zod contracts for
both live in packages/shared; apps/api still green. No routes yet.
Files
apps/api/src/db/schema.ts— APPEND domain tables (do not touch the better-auth tables above).packages/shared/src/index.ts— APPEND contracts.apps/api/drizzle/0001_*.sql+apps/api/drizzle/meta/*— generated, committed.apps/api/test/schema.test.ts— NEW test.
Schema (append to apps/api/src/db/schema.ts)
// ---- SoleLog domain tables (Phase 1) ----
export const activities = sqliteTable('activities', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
// subset of 'Kurk' | 'Berk' | '3D' — stored as a JSON string by libsql.
insoleTypes: text('insole_types', { mode: 'json' })
.$type<string[]>()
.notNull()
.default(['Kurk', 'Berk', '3D']),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
});
export const workSessions = sqliteTable(
'work_sessions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
activityId: integer('activity_id')
.notNull()
.references(() => activities.id),
insoleType: text('insole_type'),
pairCount: integer('pair_count').notNull().default(2),
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active
durationSeconds: integer('duration_seconds'),
status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded'
source: text('source').notNull().default('app'), // 'app' | 'manual'
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => ({
workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId),
workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime),
})
);
sqlandindexare already imported at the top ofschema.ts.useris defined above in the same file. IfsqliteTable's second-arg callback-returns-object form is deprecated in 0.36.4, adapt to the array form the installed version expects (installed API wins).
Contracts (append to packages/shared/src/index.ts)
export const InsoleType = z.enum(['Kurk', 'Berk', '3D']);
export type InsoleType = z.infer<typeof InsoleType>;
export const Activity = z.object({
id: z.number().int(),
name: z.string(),
insole_types: z.array(InsoleType),
created_at: z.string(), // ISO-8601
});
export type Activity = z.infer<typeof Activity>;
export const CreateActivityInput = z.object({
name: z.string().trim().min(1),
insole_types: z.array(InsoleType).default(['Kurk', 'Berk', '3D']),
});
export type CreateActivityInput = z.infer<typeof CreateActivityInput>;
export const UpdateActivityInput = CreateActivityInput;
export type UpdateActivityInput = z.infer<typeof UpdateActivityInput>;
export const SessionStatus = z.enum(['active', 'completed', 'discarded']);
export type SessionStatus = z.infer<typeof SessionStatus>;
export const WorkSession = z.object({
id: z.number().int(),
user_id: z.string(),
activity_id: z.number().int(),
activity_name: z.string().optional(), // present on history/active joins
insole_type: InsoleType.nullable(),
pair_count: z.number().int(),
start_time: z.string(), // ISO-8601
end_time: z.string().nullable(),
duration_seconds: z.number().int().nullable(),
status: SessionStatus,
source: z.enum(['app', 'manual']),
notes: z.string().nullable(),
created_at: z.string(),
});
export type WorkSession = z.infer<typeof WorkSession>;
export const StartSessionInput = z.object({
activity_id: z.number().int(),
insole_type: InsoleType,
pair_count: z.number().int().min(1).default(2),
});
export type StartSessionInput = z.infer<typeof StartSessionInput>;
Test — apps/api/test/schema.test.ts
Describe "domain schema":
it('creates and reads back an activity with a json insole_types array'): importdbfrom../src/db/client,activitiesfrom../src/db/schema,eqfromdrizzle-orm. Insert{ name: 'Frezen', insoleTypes: ['Kurk','Berk'] }, select it back by id, assertrow.insoleTypesdeep-equals['Kurk','Berk'](proves the JSON round-trip) androw.name === 'Frezen'.it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time'): needs a realuser.id, so first sign a user up through the app (mirrorme.test.ts: build the app,POST /api/auth/sign-up/email, then read the created user's id from theusertable viadb.select().from(user)), then insert awork_sessionsrow with only the required fields (userId,activityIdfrom the activity above,startTime: new Date()), select it back, and assertstatus === 'active',source === 'app',pairCount === 2,endTime === null,durationSeconds === null.
This test exercises the live migration (the setup file migrates
./.tmp/test.dbbefore 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. Runyarn workspace @solelog/api test schema— watch it fail (noactivitiestable). - Append the schema tables to
apps/api/src/db/schema.ts. - Generate the migration:
yarn workspace @solelog/api db:generate. Confirm a newdrizzle/0001_*.sqlappears creating onlyactivities+work_sessions(NOT re-creating better-auth tables) and thatdrizzle/meta/_journal.jsongained an entry. - Run
yarn workspace @solelog/api test schema— green. Run the full suite — still green. Runyarn workspace @solelog/api typecheck— clean. - Commit:
feat(api): add activities + work_sessions domain schema and shared contracts.
Backend Task 2: Auth helper + activities CRUD routes (user-scoped)
Outcome: GET/POST /api/activities, PUT/DELETE /api/activities/:id, all behind the bearer
session, with full vitest coverage. Activities are shared shop data (not per-user) but all routes
require a valid session (401 without).
Files
apps/api/src/lib/require-user.ts— NEW shared auth helper.apps/api/src/routes/activities.ts— NEW route module.apps/api/src/app.ts— mount the route (CORS added in Task 6; just mount here).apps/api/test/activities.test.ts— NEW test.
apps/api/src/lib/require-user.ts
A helper that resolves the better-auth session the same way me.ts does and returns the user, or
null. Routes turn null into a 401.
import type { Context } from 'hono';
import { auth } from '../auth';
export async function getSessionUser(c: Context): Promise<{ id: string } | null> {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) return null;
return { id: session.user.id };
}
apps/api/src/routes/activities.ts
A Hono sub-app. Behaviour (mirror legacy task rules from legacy-backend.md §3, scoped behind auth):
GET /api/activities— 401 if no user. Optional?insole_type=Kurk|Berk|3Dfilter: return activities whoseinsoleTypesarray includes that value (filter in JS after the select, since the column is JSON text). Order byname ASC. Map rows →Activityshape (created_atto ISO).POST /api/activities— 401 if no user. Parse body withCreateActivityInput.safeParse; on failure400 { error: 'Invalid input' }. Empty/missinginsole_typesdefaults to all three (the zod.defaulthandles this). Insert, return the createdActivitywith status200(match the legacyResponse.jsonconvention; the test asserts200).PUT /api/activities/:id— 401 if no user.idparsed as int. Validate body as above. If no row updated →404 { error: 'Activity not found' }. Return updatedActivity.DELETE /api/activities/:id— 401 if no user. Delete the activity'swork_sessionsfirst, 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 matchesActivity(insole_typesdeep-equals['Kurk','Berk']); then GET/api/activities→ array contains it.it('defaults insole_types to all three when omitted'): POST{ name:'Slijpen' }→insole_typesdeep-equals['Kurk','Berk','3D'].it('filters by ?insole_type'): create one['3D']-only activity and one['Kurk']-only; GET/api/activities?insole_type=3Dreturns 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 awork_sessionsrow against it directly viadbwith the test user's id, DELETE the activity, assert{ success: true }and that thework_sessionsrow is gone (db.select()empty).
Steps
- Write
apps/api/test/activities.test.ts. Run it — fails (route not mounted). - Implement
require-user.tsandactivities.ts; mountapp.route('/', activities)inapp.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 viaStartSessionInput.safeParse;400on fail. Verify theactivity_idexists (else404 { error: 'Activity not found' }). Insert awork_sessionsrow:userId= session user,activityId,insoleType,pairCount,startTime: new Date(),endTime: null,durationSeconds: null,status: 'active',source: 'app'. Return the createdWorkSession.POST /api/sessions/:id/stop— 401 if no user. Load the session by id ANDuserId(scope to owner). If not found (missing or not owned) →404 { error: 'Session not found' }. If itsstatus !== 'active'(already closed) →409 { error: 'Session already closed' }. Otherwise setendTime = 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 updatedWorkSession.POST /api/sessions/:id/discard— 401 if no user. Same owner-scoped load →404if not found. Reject if already closed (409) the same way. Setstatus = 'discarded',endTime = new Date(), leavedurationSecondsnull. Return the updatedWorkSession.
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 matchesWorkSession,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, directlydb.update(workSessions).set({ startTime: new Date(Date.now() - 5000) })for that session id, then POST/api/sessions/:id/stop. Assertstatus === 'completed',end_timenon-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 viadb(or token A read) the session is stillactive.
Steps
- Write
apps/api/test/sessions.test.ts. Run it — fails. - Implement the three write endpoints in
apps/api/src/routes/sessions.ts; mount inapp.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 adescribe('session reads')block.
Behaviour
GET /api/sessions— 401 if no user. Select allwork_sessionswhereuserId = user.id, LEFT JOINactivitiesonactivityIdto getactivity_name, orderedstartTime DESC(newest first). Map toWorkSession[](activity_nameset; timestamps to ISO;end_time/duration_secondsnull-safe). Includes active, completed, and discarded sessions for this user.GET /api/sessions/active— 401 if no user. Selectwork_sessionswhereuserId = user.id AND status = 'active', joined to activity name, orderedstartTime DESC. ReturnWorkSession[](usually 0 or 1; return an array so the client can pick the most recent).
Path note:
/api/sessions/activeand/api/sessionsare distinct exact paths; there is no/api/sessions/:idGET 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.updatetheirstartTimes so one is clearly newer; GET/api/sessions→ length 2,new Date(res[0].start_time) > new Date(res[1].start_time), andres[0].activity_nameis 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 isstatus === '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— ADDGET /api/export(or a smallapps/api/src/routes/export.tsmounted inapp.ts; either is fine — keep it in one place and mount it).apps/api/src/lib/csv.ts— NEW: thequote+formatDurationhelpers (so they are unit-testable).apps/api/test/export.test.ts— NEW test.
Behaviour
- 401 if no user. Select the user's
work_sessionswherestatus = 'completed', joined to activity name, orderedstartTime ASC(oldest first — note this is the OPPOSITE of history). Build the CSV exactly per the contract table. Format dates/times withnl-BElocale 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/exportno token → 401.it('exports completed sessions as CSV with the legacy header'): token user creates an activityFrezen, starts a session,db.updates itsstartTimeto exactly 90s before itsendTime/stop so the duration is exactly 90, stops it. GET/api/exportwith token. Assert:res.headers.get('content-type')includestext/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 theTotal Durationcell"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-BEtoLocaleStringdepends 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.tsand the/api/exportroute; 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.jsondb:seedscript.apps/api/src/app.ts— addcors()middleware.apps/api/src/auth.ts— add'http://localhost:5173'totrustedOrigins(allowed: using auth).apps/api/test/cors.test.ts— NEW test.apps/api/test/seed.test.ts— NEW test.
Seed — apps/api/src/db/seed.ts
Idempotent: for each reference activity (table in Global Constraints), insert it only if no activity
with that name exists (db.select().from(activities).where(eq(activities.name, name))). Export a
seed() function and add the direct-run guard (copy the pathToFileURL(process.argv[1]) pattern from
migrate.ts). Add "db:seed": "tsx src/db/seed.ts" to apps/api/package.json.
CORS — apps/api/src/app.ts
Add BEFORE the routes:
import { cors } from 'hono/cors';
// ...
app.use(
'/api/*',
cors({
origin: ['http://localhost:5173'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['set-auth-token'], // so the SPA can read the bearer token on sign-in
credentials: true,
})
);
exposeHeaders: ['set-auth-token']is load-bearing: better-auth returns the bearer token in that response header on sign-in, and a cross-origin browser fetch can only read it if it is exposed.
Test — apps/api/test/cors.test.ts
it('answers a CORS preflight for the SPA origin'):OPTIONS /api/activitieswith headersOrigin: http://localhost:5173andAccess-Control-Request-Method: GET→access-control-allow-origin===http://localhost:5173and the response allows GET.it('exposes set-auth-token to the SPA origin'): any/api/*request withOrigin: http://localhost:5173→access-control-expose-headerscontainsset-auth-token(case-insensitive check).
Test — apps/api/test/seed.test.ts
it('seeds the reference activities idempotently'): importseedfrom../src/db/seed; run it twice; the count of activities with the seeded names is unchanged after the second run (no duplicates); assertPrintenexists withinsole_typesdeep-equal['3D'].
Steps
- Write
cors.test.tsandseed.test.ts. Run — fail. - Add CORS to
app.ts; add:5173toauth.tstrustedOrigins; writeseed.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:seedand confirm it prints success (sanity, not a test). - Commit:
feat(api): seed reference activities and enable CORS for the worker SPA.
Client Task 1: Scaffold the Vite + React + TS PWA workspace + API client + token storage
Outcome: apps/worker exists as workspace @solelog/worker, builds, typechecks, has a vitest
setup, a PWA manifest, a typed apiFetch wrapper that attaches the bearer token from localStorage,
and tests for the token storage + fetch wrapper. No screens yet (a placeholder root).
Files (new app skeleton)
apps/worker/
package.json
tsconfig.json
tsconfig.node.json
vite.config.ts
vitest.config.ts
index.html
public/manifest.webmanifest
public/icon-192.png (a simple solid-colour PNG, 192x192)
public/icon-512.png (512x512)
src/main.tsx
src/App.tsx (placeholder: renders "SoleLog")
src/test/setup.ts (jest-dom)
src/lib/auth-storage.ts
src/lib/api.ts
src/lib/auth-storage.test.ts
src/lib/api.test.ts
apps/worker/package.json
{
"name": "@solelog/worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@solelog/shared": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.0",
"vitest": "^3.0.0"
}
}
Install resolves these to concrete versions; whatever Yarn picks is authoritative — adapt code to the installed React 18/19 + Router 6/7 + RQ 5 API. (React 19's
createRootcall/types are identical; Router 7 still exports thereact-router-domsymbols used here.) Keep the dep list to exactly these — no extras.
apps/worker/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { host: true, port: 5173 }, // host:true → reachable from a phone on the LAN
});
apps/worker/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
});
apps/worker/tsconfig.json
Standard Vite React tsconfig: target ES2020, lib ['ES2020','DOM','DOM.Iterable'],
module 'ESNext', moduleResolution 'Bundler', jsx 'react-jsx', strict true, noEmit true,
types ['vitest/globals', '@testing-library/jest-dom'], skipLibCheck true. Include src.
tsconfig.node.json covers vite.config.ts (composite, moduleResolution 'Bundler').
apps/worker/index.html
Minimal; in <head> link the manifest and theme: <link rel="manifest" href="/manifest.webmanifest">,
<meta name="theme-color" content="#2563EB">,
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">,
<link rel="apple-touch-icon" href="/icon-192.png">. Body: <div id="root"></div> +
<script type="module" src="/src/main.tsx"></script>. Title SoleLog.
apps/worker/public/manifest.webmanifest
{
"name": "SoleLog",
"short_name": "SoleLog",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563EB",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Generate the two PNG icons as simple solid blue squares (a one-off node script writing minimal valid PNGs, or commit two tiny pre-made PNGs). They only need to be valid PNGs of the right dimensions for installability — no design work.
apps/worker/src/lib/auth-storage.ts
const TOKEN_KEY = 'solelog.token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
apps/worker/src/lib/api.ts
A typed fetch wrapper feeding React Query. Base URL from import.meta.env.VITE_API_URL (default
http://localhost:3000). Attaches Authorization: Bearer <token> when a token is stored. Sign-in
reads the token from the set-auth-token response header and stores it.
import { getToken, setToken } from './auth-storage';
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(init.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
export async function signIn(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
const token = res.headers.get('set-auth-token');
if (!token) throw new ApiError(500, 'Geen token ontvangen');
setToken(token);
}
// Sign up affordance for testing: POST /api/auth/sign-up/email.
export async function signUp(email: string, password: string): Promise<void> {
const res = await fetch(`${API_URL}/api/auth/sign-up/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }),
});
if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt');
}
The installed
apps/apibetter-auth/sign-up/emailrequiresname(no backfill hook inapps/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 provideslocalStorage.)
apps/worker/src/lib/api.test.ts (describe "api client"):
it('attaches the bearer token to requests'): stubglobal.fetchwithvi.fnreturningnew Response(JSON.stringify({ ok: true }), { status: 200 });setToken('tok'); callapiFetch('/api/me'); assert thefetchmock was called with the URLhttp://localhost:3000/api/meand aHeaderscontainingAuthorization: Bearer tok.it('throws ApiError on a non-2xx response'): mock fetch →status 401; expectapiFetchrejects with anApiErrorwhose.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' } }); callsignIn('a@b.c','pw'); assertgetToken() === 'xyz'.it('signIn throws when the header is missing'): mock fetch → 200, no header → rejects.
Steps
- Create the
apps/workerskeleton files above (placeholderApp.tsxrenderingSoleLog). - 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 implementingauth-storage.tsandapi.ts. yarn workspace @solelog/worker typecheck— clean.yarn workspace @solelog/worker build— succeeds (headless build check; producesdist/with the manifest).- Commit:
feat(worker): scaffold Vite+React PWA with token storage and typed API client.
Client Task 2: App shell — auth gate, login screen, router, tab layout
Outcome: the app boots into a login screen when there is no token; after sign-in it shows a 3-tab shell (Stopwatch / Geschiedenis / Instellingen) wired to React Router routes (empty screen stubs for now). Dutch strings throughout. A component smoke test renders the login screen.
Files
apps/worker/src/App.tsx— router + auth gate.apps/worker/src/auth/AuthContext.tsx— token presence state +signIn/signUp/signOut.apps/worker/src/screens/Login.tsx— email/password form + a sign-up toggle.apps/worker/src/components/TabBar.tsx— bottom nav:Stopwatch/Geschiedenis/Instellingen.apps/worker/src/screens/Stopwatch.tsx,History.tsx,Settings.tsx— stubs (each renders its Dutch title) — filled in Client Tasks 3–5.apps/worker/src/styles.css— palette + mobile-first layout.apps/worker/src/App.test.tsx— smoke test.
Behaviour
AuthContextholdsisAuthedderived fromgetToken();signIn/signUpcalllib/api.ts;signOutclears the token and flips state. (No network "is my token valid" check in Phase 1 — a401from any API call surfaces as an error and the user can re-login. Keep it minimal.)App.tsx: if!isAuthedrender<Login />. If authed, render<BrowserRouter>with routes'/'→ Stopwatch,'/history'→ History,'/settings'→ Settings, plus the<TabBar />.Login.tsx: headingSoleLog; email input (labelE-mailadres), password input (labelWachtwoord), a primary button. A toggle switches between sign-in (Inloggen) and a sign-up affordance (Registreren). On submit callsignIn(orsignUpthensignIn); 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 fromlegacy-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 theInloggenbutton andE-mailadreslabel are in the document.it('shows the tab bar when a token is present'):setToken('tok'); render<App />; assert the three Dutch tab titlesStopwatch,Geschiedenis,Instellingenare present. (StubapiFetch/ network so the stub screens render without real requests.)
Steps
- Write
App.test.tsx. Run — fail. - Implement
AuthContext,Login,TabBar,Appwiring,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) usingapiFetch+ theActivityzod type from shared.apps/worker/src/screens/Settings.test.tsx— test.
Behaviour (from legacy-mobile-app.md §6)
- Header
Instellingen; subtitleBeheer handelingen per zooltype. - "Add new handling" card: label
Nieuwe handeling toevoegen; name input placeholderNaam van de stap, bijv. Leerrand; aVan toepassing oprow with three toggle pills (Kurk/Berk/3D, default all three selected); add buttonStap 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 stateNog 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 + theVan toepassing optoggles +Opslaan/Annuleren. - Delete confirmation (use
window.confirmfor 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_COLORSin §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'): assertInstellingen,Nieuwe handeling toevoegen, placeholderNaam van de stap, bijv. Leerrand, and buttonStap toevoegenpresent.it('lists activities returned by the API'): mockapiFetch→[{ id:1, name:'Frezen', insole_types:['Kurk','Berk'], created_at:'...' }]; assertFrezenand its type badges render and the header showsHuidige stappen (1).it('disables the add button until a name is entered'): empty form →Stap toevoegendisabled; 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.tsandSettings.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.
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 buttonsKurk/Berk/3D(defaultKurk). 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)); placeholderKies 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 aspair_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 → redStop & Opslaan+ the double-press discard (Annuleren→ armedNogmaals tikken ter bevestiging, 3s window). - Server calls: Start →
POST /api/sessions/start { activity_id, insole_type, pair_count }→ store the returned session id + itsstart_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, setactiveActivityId,insoleType,pairCount, and base the live timer on itsstart_time). This is the "phone died, resume from another device" path. - Live timer: a 1s
setIntervalre-render; the displayed value iselapsedSeconds(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, assertType zool,Type handeling,Aantal zolen,Start Stopwatch; theKurk/Berk/3Dbuttons; default count2.it('disables Start until a handling is chosen'): initiallyStart Stopwatchdisabled; 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']}]; withKurkselected the handling options includeFrezenbut notPrinten; selecting3DshowsPrintenand the inverse.it('calls start with the selected values'): mock the start mutation; pickBerk, a handling, set count 3, clickStart 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), firstAnnulerentap showsNogmaals 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. Implementlib/stopwatch.ts. Green. - Write
Stopwatch.test.tsx. Run — fail. Implementapi/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— ADDuseSessions()(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 buttonExporteer 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}fromstart_time, device locale); badges: insole-type pill (verbatimKurk/Berk/3D), a count pill{pair_count} {pair_count === 1 ? 'inlegzool' : 'inlegzolen'}, and a duration pill formatted like the legacyformatDuration(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 authenticatedfetchof/api/export, reads the blob, and triggers a download via an object URL + a synthetic<a download>click. On error show a Dutch message (Fout/Kan de export niet openen).
apps/worker/src/lib/export.ts
import { API_URL } from './api';
import { getToken } from './auth-storage';
export async function downloadExport(): Promise<void> {
const token = getToken();
const res = await fetch(`${API_URL}/api/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error('Kan de export niet openen');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'insole-production-report.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
Test — apps/worker/src/screens/History.test.tsx
vi.mock lib/api's apiFetch (for useSessions) and lib/export's downloadExport.
it('renders the header and export button in Dutch'): assertGeschiedenisandExporteer 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, ... }; assertFrezen,Kurk,2 inlegzolen, and a duration like1h 1mrender.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 mockeddownloadExportwas 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 oxlintfrom 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 fromnode_modulesand flagged authoritative over the samples (corsverified athono/cors;drizzle-orm0.36.4text/integer/index/eq/and/descverified). - 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_URLdefault,public/manifest.webmanifest). - Resolved risk — better-auth
trustedOrigins. Adding:5173is "using" the auth config, not rewriting it; no plugin/hashing/session change. CORSexposeHeadersis 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 Durationcell exactly, but treat thenl-BEDate/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_timevia a directdb.update, soduration_secondsis asserted exactly, never as a fuzzy range. - Ownership boundary tested. A cross-user stop returns
404and leaves the victim's sessionactive; 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.