60 KiB
Phase 1 — Worker Timing Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task, andsuperpowers:test-driven-developmentfor every task. Steps use checkbox (- [ ]) syntax for tracking. Implement strict TDD: write the test, watch it fail for the right reason, then write the code to make it pass. Never weaken, skip, or delete a test to make it pass. Run REAL commands; never fabricate output.
Goal: Deliver "Worker timing" end-to-end. The backend (apps/api) gains domain tables (activities,
work-sessions), a user-scoped REST surface for managing activities and starting/stopping/discarding
server-authoritative work sessions, a history list, an "active session" recovery endpoint, and a CSV
export — all behind the existing better-auth bearer session. A fresh, lean Expo Router app
(apps/mobile, package @solelog/mobile) is added that logs in, attaches the bearer token to every
call, and reproduces the three Dutch screens (Stopwatch / Geschiedenis / Instellingen) against this
backend. Done when: a worker can pick an activity, start/stop a server-side session, see history, and
export CSV; all backend endpoints are user-scoped and covered by vitest; the mobile app runs on Expo
web and on a device via Expo Go, with jest-expo unit tests and a clean tsc --noEmit.
Architecture: The backend remains the single owner of auth + DB (Decision A from the roadmap). The
mobile app is a pure client: it holds no business logic beyond UI state and the live elapsed-timer
display; start/stop/discard are server calls, so an open session survives a phone restart and can be
recovered. Request/response shapes are zod schemas in packages/shared, imported by both apps/api
(validation) and apps/mobile (typed client). All new domain routes resolve the user from the
better-auth session (the exact auth.api.getSession({ headers }) pattern already used by
src/routes/me.ts) and scope every query to that user_id; no token → 401.
Tech Stack (already installed — these versions are authoritative; do not bump):
| Package | Installed version | Notes |
|---|---|---|
hono |
4.12.25 | router + hono/cors middleware |
@hono/node-server |
1.19.14 | server entry |
better-auth |
1.6.18 | bearer plugin; set-auth-token response header on sign-in |
drizzle-orm |
0.36.4 | pinned — do NOT bump (tracked as SL-9) |
drizzle-kit |
0.30.6 | pinned — do NOT bump (tracked as SL-9) |
@libsql/client |
0.14.0 | SQLite driver (no native build) |
zod |
3.25.76 | contracts |
vitest |
3.2.6 | backend tests |
typescript |
5.9.3 | tsc --noEmit |
tsx |
4.22.4 | run/seed scripts |
Mobile (to be added, latest within each major at install time, pinned exact after install):
expo (SDK 54 line), expo-router, react, react-native, react-dom, react-native-web,
expo-secure-store, expo-status-bar, @tanstack/react-query, @solelog/shared (workspace:*), and
dev deps jest-expo, jest, @testing-library/react-native, react-test-renderer, typescript,
@types/react, @types/jest.
Global Constraints
- Package manager: Yarn 4.12.0 (Berry),
nodeLinker: node-modules. Run from the repo root via corepack:corepack yarn install,corepack yarn …. Workspaces areapps/*+packages/*(already configured in rootpackage.json). The mobile app simply lives atapps/mobile, so it is picked up automatically. - Run commands — backend from
apps/api(corepack yarn test,corepack yarn typecheck), mobile fromapps/mobile. Git always asgit -C D:/Sven …. - Do NOT modify the better-auth files (
src/auth.ts, theuser/session/account/verificationtables insrc/db/schema.ts) beyond adding new domain tables/relations toschema.tsand reusingauth/auth.api.getSession. Do NOT regenerate the auth tables. - Do NOT bump
drizzle-orm/drizzle-kit(pinned; SL-9). Use the installed API only. - Keep
apps/apigreen: the existing 4 test files / 5 tests must stay passing. Run the full suite after every backend task. - Strict TDD, no test weakening. If installed-library behaviour differs from any sample code in this plan, the installed library is authoritative — adapt the code and make the real test pass; never loosen an assertion to paper over a real bug.
- Mobile is greenfield and minimal. Do NOT restore the deleted Create/Anything export, its
__createplumbing, the web-sandbox iframe layer, analytics/Sentry, patched deps, or any unused library (ads/IAP/maps/3D/audio/sensors/lucide/NativeWind). Add a dependency only when a task needs it. - Out of scope (do NOT build): workbenches / QR scanning (Phase 4), the admin web panel (Phase 3), admin user-management / roles beyond per-user scoping (Phase 2), offline-first, push notifications.
- Commit style: Conventional Commits, one commit per task (or per step where a task says so). Commit only after that task's tests are green.
SQLite storage & cross-cutting decisions (resolved here, referenced by tasks)
insole_typesarray storage. SQLite has no array type. Store it with Drizzletext('insole_types', { mode: 'json' }).$type<InsoleType[]>(). drizzle-orm 0.36.4 serialises the JS array to a JSON string on write andJSON.parses on read, so route code sees a realstring[]. The zod contract validates it asz.array(InsoleType). Never filter inside SQL on this column; the?insole_type=filter onGET /api/activitiesis applied in JS after fetching the user-visible rows (the dataset is tiny — a handful of activities). Document this in a code comment.- Timestamps. Reuse the better-auth convention already in
schema.ts:integer({ mode: 'timestamp_ms' })(epoch-ms; Drizzle maps to/fromDate).start_timeis set atstart;end_timeisnullwhile active. The API contract serialises timestamps as ISO-8601 strings (Date.toISOString()), so the mobile client and CSV are timezone-explicit (UTC). The wire/JSON shape is always ISO strings; the DB stores epoch-ms. duration_seconds. Server-authoritative and computed on stop asMath.round((end_time - start_time) / 1000)(whole seconds). This differs deliberately from the legacy client-tick count (seedocs/reference/legacy-lessons-and-gotchas.md§6 — tick counting under-counts when backgrounded). The mobile timer is display-only; the server number is the source of truth.notesand a future pause model are out of scope for Phase 1 (pause does not change the server session; it only freezes the on-screen display).- CSV format. Match
docs/reference/legacy-backend.md§4 as closely as the new (user-scoped, completed-only) data allows:text/csv; charset=utf-8,Content-Disposition: attachment; filename="insole-production-report.csv", every cell quoted with"doubling, rows joined with\n, orderedstart_time ASC. Columns are reduced to the set this ticket specifies (activity name, insole type, pair count, start, end, duration_seconds) — see Backend Task 7 for the exact header row. Format dates/times explicitly in UTC ISO to avoid the legacy server-timezone fragility. - CORS. Add
hono/corsso Expo web (a browser origin, e.g.http://localhost:8081) can call the API cross-origin withAuthorization: Bearer …. Because auth is bearer-token only for the mobile/web client (no cookies), CORS does not needcredentials: true; allow theAuthorizationandContent-Typerequest headers and exposeset-auth-token. The allow-list is env-driven and kept consistent with better-authtrustedOriginsby reading the same env var (see Backend Task 8). EXPO_PUBLIC_BASE_URL. The mobile client's typed API client readsprocess.env.EXPO_PUBLIC_BASE_URLand defaults tohttp://localhost:3000. For device testing over LAN, set it to the PC's LAN IP (e.g.http://192.168.1.50:3000) inapps/mobile/.env. TheEXPO_PUBLIC_prefix makes Expo inline it into the bundle. Document both values in.env.example.
Domain data model (added to apps/api/src/db/schema.ts)
activities
id integer PK autoincrement
name text NOT NULL
insole_types text(json) NOT NULL -- InsoleType[] subset of 'Kurk'|'Berk'|'3D'
created_at integer timestamp_ms DEFAULT now NOT NULL
work_sessions
id integer PK autoincrement
user_id text NOT NULL FK -> user.id ON DELETE CASCADE
activity_id integer NOT NULL FK -> activities.id
insole_type text NOT NULL -- 'Kurk'|'Berk'|'3D'
pair_count integer NOT NULL DEFAULT 2
start_time integer timestamp_ms NOT NULL
end_time integer timestamp_ms NULL -- null = active
duration_seconds integer NULL -- null until stopped
status text NOT NULL DEFAULT 'active' -- 'active'|'completed'|'discarded'
source text NOT NULL DEFAULT 'app' -- 'app'|'manual'
notes text NULL
created_at integer timestamp_ms DEFAULT now NOT NULL
Indices: work_sessions(user_id), work_sessions(activity_id), work_sessions(user_id, status).
Backend Task 1: Domain contracts in packages/shared
Files:
- Modify:
packages/shared/src/index.ts - Create:
packages/shared/test/contracts.test.ts - Modify:
packages/shared/package.json(addvitestdevDep +testscript) and createpackages/shared/vitest.config.ts
Interfaces produced (all exported as const zod schema + inferred type of the same name, the
existing convention in this file):
// enums
InsoleType = z.enum(['Kurk', 'Berk', '3D'])
SessionStatus = z.enum(['active', 'completed', 'discarded'])
SessionSource = z.enum(['app', 'manual'])
// Activity (response shape; timestamps are ISO strings on the wire)
Activity = z.object({
id: z.number().int(),
name: z.string(),
insole_types: z.array(InsoleType),
created_at: z.string(), // ISO
})
// Activity write payloads
CreateActivityInput = z.object({
name: z.string().trim().min(1),
insole_types: z.array(InsoleType).min(1),
})
UpdateActivityInput = CreateActivityInput // same shape
// WorkSession (response shape)
WorkSession = z.object({
id: z.number().int(),
user_id: z.string(),
activity_id: z.number().int(),
activity_name: z.string(), // joined from activities.name
insole_type: InsoleType,
pair_count: z.number().int(),
start_time: z.string(), // ISO
end_time: z.string().nullable(),// ISO or null
duration_seconds: z.number().int().nullable(),
status: SessionStatus,
source: SessionSource,
notes: z.string().nullable(),
created_at: z.string(),
})
// Session write payloads
StartSessionInput = z.object({
activity_id: z.number().int(),
insole_type: InsoleType,
pair_count: z.number().int().positive().default(2),
})
// list responses
ActivityList = z.array(Activity)
WorkSessionList = z.array(WorkSession)
Keep the existing HealthResponse, PublicUser, MeResponse exports untouched.
- Step 1 (test first): Create
packages/shared/vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node' } });
Add to packages/shared/package.json: "scripts": { "test": "vitest run" } and
"devDependencies": { "vitest": "^3.0.0" }. Run corepack yarn install from the repo root.
-
Step 2 (test first): Write
packages/shared/test/contracts.test.tsasserting:InsoleType.parse('Kurk')succeeds;InsoleType.safeParse('Leer').success === false.CreateActivityInput.parse({ name: ' Leerrand ', insole_types: ['Kurk'] })returnsname: 'Leerrand'(trim) and the types array.CreateActivityInput.safeParse({ name: '', insole_types: ['Kurk'] }).success === false(empty name).CreateActivityInput.safeParse({ name: 'X', insole_types: [] }).success === false(≥1 type).StartSessionInput.parse({ activity_id: 1, insole_type: 'Berk' }).pair_count === 2(default applied).StartSessionInput.safeParse({ activity_id: 1, insole_type: 'Berk', pair_count: 0 }).success === false.WorkSession.parse(<a fully-populated active fixture with end_time:null, duration_seconds:null>)succeeds andWorkSession.safeParse(<same but status:'paused'>).success === false.
Run
corepack yarn workspace @solelog/shared test— it must fail (schemas don't exist yet). -
Step 3: Implement the schemas/types in
packages/shared/src/index.ts. Re-run the test until green. Run the existingapps/apisuite (corepack yarn workspace @solelog/api test) to confirm the new exports didn't break the imports inhealth.ts/me.ts(they shouldn't — only additions). -
Step 4:
corepack yarn workspace @solelog/api typecheck(must stay clean), then commit:git -C D:/Sven add packages/shared && git -C D:/Sven commit -m "feat(shared): Phase 1 activity & work-session zod contracts"
Backend Task 2: Domain tables + migration + seed data
Files:
- Modify:
apps/api/src/db/schema.ts(ADDactivities,work_sessionstables + relations; do NOT touch the auth tables above them) - Create:
apps/api/src/db/seed.ts - Modify:
apps/api/package.json(add"db:seed": "tsx src/db/seed.ts"script) - Generated:
apps/api/drizzle/0001_*.sql(+ updateddrizzle/meta/*) viadrizzle-kit generate - Create:
apps/api/test/schema.test.ts
Interfaces produced: activities, workSessions Drizzle tables (+ activitiesRelations,
workSessionsRelations) exported from db/schema.ts, consumed by all route tasks and the seed.
- Step 1: Append to
apps/api/src/db/schema.ts(after the existing auth tables/relations):
import type { InsoleType } from '@solelog/shared';
// (add `sqliteTable, text, integer, index` are already imported at the top)
export const activities = sqliteTable('activities', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
// SQLite has no array type: store InsoleType[] as a JSON string. Drizzle
// (mode:'json') serialises on write and JSON.parses on read. NEVER filter on
// this column in SQL — the ?insole_type= filter is applied in JS (tiny dataset).
insoleTypes: text('insole_types', { mode: 'json' }).$type<InsoleType[]>().notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
});
export const workSessions = sqliteTable(
'work_sessions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
activityId: integer('activity_id')
.notNull()
.references(() => activities.id),
insoleType: text('insole_type').$type<InsoleType>().notNull(),
pairCount: integer('pair_count').default(2).notNull(),
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
endTime: integer('end_time', { mode: 'timestamp_ms' }),
durationSeconds: integer('duration_seconds'),
status: text('status').$type<'active' | 'completed' | 'discarded'>().default('active').notNull(),
source: text('source').$type<'app' | 'manual'>().default('app').notNull(),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => ({
workSessionsUserIdIdx: index('work_sessions_user_id_idx').on(table.userId),
workSessionsActivityIdIdx: index('work_sessions_activity_id_idx').on(table.activityId),
workSessionsUserStatusIdx: index('work_sessions_user_status_idx').on(table.userId, table.status),
})
);
export const activitiesRelations = relations(activities, ({ many }) => ({
sessions: many(workSessions),
}));
export const workSessionsRelations = relations(workSessions, ({ one }) => ({
user: one(user, { fields: [workSessions.userId], references: [user.id] }),
activity: one(activities, { fields: [workSessions.activityId], references: [activities.id] }),
}));
If
verbatimModuleSyntax/import typeforInsoleTypecauses a value/type clash, keep it as atypeimport (it is only used in$type<>()). If the installed drizzle-kit emits the JSON column or the autoincrement PK differently from this sample, the generated SQL is authoritative — keep the schema, regenerate, and adjust the schema only if generation errors.
-
Step 2: Generate the migration: from
apps/api,corepack yarn db:generate(drizzle-kit generate). This must produce a NEWdrizzle/0001_*.sqlcontainingCREATE TABLE activitiesandCREATE TABLE work_sessions(and indices) and a0001journal entry — it must NOT rewrite0000. Inspect the generated SQL and confirm it does not alter the auth tables. -
Step 3 (test first): Write
apps/api/test/schema.test.ts:- imports
dband{ activities, workSessions }from schema; - inserts an activity with
insoleTypes: ['Kurk', 'Berk'], reads it back, and asserts the value is a real array['Kurk','Berk'](proves JSON round-trips), andcreatedAt instanceof Date; - inserts a
work_sessionsrow referencing a created user + the activity withendTime: null, reads it back, assertsstatus === 'active',pairCount === 2(default),endTime === null,durationSeconds === null. - To have a
user.idto reference, create a user first via the better-auth sign-up route throughcreateApp()(as the auth test does), thendb.select().from(user)to grab the id — OR insert a user row directly withdb.insert(user).values({...}). Prefer the sign-up route (closer to reality).
Run
corepack yarn workspace @solelog/api test—schema.test.tsfails (tables not migrated in the test DB). NOTE:test/setup.tsrunsrunMigrations()which applies./drizzle, so once0001exists it is applied automatically to the fresh.tmp/test.db. The failure before generating is the FK/table missing; after Step 2 + implementing the schema it goes green. - imports
-
Step 4: Create the seed script
apps/api/src/db/seed.ts— idempotent (insert only if theactivitiestable is empty), realistic activities perdocs/reference/legacy-mobile-app.md(Leerrandexample + plausible insole-production steps). Use this exact seed set:
// apps/api/src/db/seed.ts
import { db } from './client';
import { activities } from './schema';
const SEED_ACTIVITIES: { name: string; insole_types: ('Kurk' | 'Berk' | '3D')[] }[] = [
{ name: 'Uitsnijden', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Leerrand', insole_types: ['Kurk', 'Berk'] },
{ name: 'Slijpen', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Lijmen', insole_types: ['Kurk', 'Berk'] },
{ name: 'Bekleden', insole_types: ['Kurk', 'Berk', '3D'] },
{ name: 'Frezen', insole_types: ['3D'] },
{ name: 'Afwerken', insole_types: ['Kurk', 'Berk', '3D'] },
];
export async function seed(): Promise<void> {
const existing = await db.select().from(activities).limit(1);
if (existing.length > 0) {
console.log('activities already seeded — skipping');
return;
}
await db.insert(activities).values(SEED_ACTIVITIES.map((a) => ({ name: a.name, insoleTypes: a.insole_types })));
console.log(`seeded ${SEED_ACTIVITIES.length} activities`);
}
import { pathToFileURL } from 'node:url';
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
seed().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
}
Add "db:seed": "tsx src/db/seed.ts" to apps/api/package.json scripts. (Export seed so route
tests can also seed programmatically.)
-
Step 5 (test first): add a
seedtest toschema.test.ts(or a newseed.test.ts): import{ seed }, call it, assertdb.select().from(activities)returns 7 rows with arrayinsole_types; callseed()again and assert it is still 7 (idempotent). Make it green. -
Step 6: Full suite green (
corepack yarn workspace @solelog/api test— now 6 files), typecheck clean, then commit:git -C D:/Sven add apps/api packages && git -C D:/Sven commit -m "feat(api): activities & work_sessions tables, migration, seed"
Backend Task 3: Auth helper + Activities routes (GET/POST)
Files:
- Create:
apps/api/src/routes/_auth.ts(a tiny shared helper) - Create:
apps/api/src/routes/activities.ts - Modify:
apps/api/src/app.ts(mountactivities) - Create:
apps/api/test/activities.test.ts
Interfaces produced: requireUser(c) helper returning the authenticated user or throwing a 401
JSON response (used by every domain route); a Hono activities router exposing GET /api/activities
and POST /api/activities. Consumes CreateActivityInput, Activity, ActivityList from
@solelog/shared and the activities table.
- Step 1: Create
apps/api/src/routes/_auth.ts:
import type { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { auth } from '../auth';
export type AuthedUser = { id: string; email: string; name: string };
// Resolves the better-auth session from the request (bearer token or cookie),
// exactly like src/routes/me.ts. Throws a 401 JSON HTTPException when absent —
// callers wrap their body in try/catch or let Hono's onError surface it.
export async function requireUser(c: Context): Promise<AuthedUser> {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
throw new HTTPException(401, { res: c.json({ error: 'Unauthorized' }, 401) });
}
return { id: session.user.id, email: session.user.email, name: session.user.name };
}
Verify
hono/http-exceptionand the{ res }option exist in hono 4.12.25. If the installed API differs, fall back to returning a401directly from each route (theme.tspattern) instead of throwing — the installed library is authoritative. Whichever form is used, the observable contract is: no/invalid token → HTTP 401{ "error": "Unauthorized" }.
-
Step 2 (helpers): Add a serialiser in
activities.tsmapping a DB row → theActivitywire shape (created_at: row.createdAt.toISOString(),insole_types: row.insoleTypes). Activities are a shared catalogue (not per-user) in Phase 1 — they are managed in Instellingen and used by all workers — soGET/POST/PUT/DELETE /api/activitiesrequire a valid session (401 without) but are NOT filtered byuser_id. (Per-activity ownership is not in the data model; onlywork_sessionscarryuser_id.) The 401-without-token requirement still applies to every activities route. -
Step 3 (test first): Write
apps/api/test/activities.test.ts. Add a shared test helper inline (or intest/helpers.ts, see Task 5) that signs a user up + in and returns the bearer token:
async function tokenFor(app, email) {
const json = { 'content-type': 'application/json' };
await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json,
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123', name: email.split('@')[0] }) });
const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json,
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123' }) });
return signin.headers.get('set-auth-token');
}
const authHeaders = (t) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });
Tests:
GET /api/activitieswithout a token →401.POST /api/activitieswithout a token →401.- With a token,
POST /api/activities{ name: 'Leerrand', insole_types: ['Kurk','Berk'] }→201(or200), body validates againstActivity,insole_typesis['Kurk','Berk']. POSTwith{ name: '', insole_types: ['Kurk'] }→400.POSTwith{ name: 'X', insole_types: [] }→400.GET /api/activitieswith a token returns an array including the created activity, validates againstActivityList.GET /api/activities?insole_type=3Dreturns only activities whoseinsole_typesinclude3D(create one['3D']and one['Kurk'], assert filtering happens in JS).
Run — fails (route not mounted).
-
Step 4: Implement
activities.ts:GET /api/activities:requireUser(c); read optionalc.req.query('insole_type'), validate it withInsoleType.safeParse(ignore if invalid/absent);db.select().from(activities).orderBy(activities.name); if filter present,rows.filter(r => r.insole_types.includes(filter))in JS; map toActivity[];c.json(list).POST /api/activities:requireUser(c); parse body withCreateActivityInput.safeParse; on failurec.json({ error: 'Invalid input' }, 400); insert{ name, insoleTypes: insole_types }with.returning(); map andc.json(activity, 201).
Mount in
app.ts:app.route('/', activities);(afterme). Re-run until green. -
Step 5: Full suite + typecheck green, commit:
git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET/POST /api/activities (user-authed)"
Backend Task 4: Activities routes (PUT/DELETE)
Files:
- Modify:
apps/api/src/routes/activities.ts(addPUT/DELETE /api/activities/:id) - Modify:
apps/api/test/activities.test.ts
Interfaces: PUT /api/activities/:id (consumes UpdateActivityInput, returns Activity),
DELETE /api/activities/:id (returns { success: true }).
-
Step 1 (test first): Add tests:
PUTwithout token →401;DELETEwithout token →401.PUT /api/activities/:idwith{ name: 'Slijpen', insole_types: ['3D'] }updates and returns the row (validatesActivity, name + types changed).PUTa non-existent id (e.g.999999) →404 { error: 'Activity not found' }.PUTwith empty name →400.DELETE /api/activities/:id→200 { success: true }; a subsequentGETno longer lists it.- Decide and TEST the cascade choice: deleting an activity that has
work_sessions— Phase 1 keeps it simple and blocks deletion when sessions reference it: return409 { error: 'Activity in use' }if anywork_sessions.activity_id = :idexists. Add a test: start a session against an activity, thenDELETEit →409; the activity still lists. (This avoids destroying a worker's history, unlike the legacy cascade. Document the divergence in a comment.)
-
Step 2: Implement:
PUT:requireUser;UpdateActivityInput.safeParse→400;db.update(activities).set({ name, insoleTypes }).where(eq(activities.id, id)).returning(); empty result →404; else map +c.json. ParseidwithNumber(c.req.param('id')); non-numeric →404.DELETE:requireUser; checkdb.select().from(workSessions).where(eq(workSessions.activityId, id)).limit(1); if found →409; elsedb.delete(activities).where(eq(activities.id, id));c.json({ success: true }).
-
Step 3: Green + typecheck, commit:
git -C D:/Sven commit -am "feat(api): PUT/DELETE /api/activities with in-use guard"
Backend Task 5: Sessions lifecycle — start / stop / discard
Files:
- Create:
apps/api/test/helpers.ts(extracttokenFor,authHeadersfor reuse) - Create:
apps/api/src/routes/sessions.ts - Modify:
apps/api/src/app.ts(mountsessions) - Create:
apps/api/test/sessions.test.ts
Interfaces produced: a sessions Hono router with
POST /api/sessions/start, POST /api/sessions/:id/stop, POST /api/sessions/:id/discard.
Consumes StartSessionInput, returns WorkSession. A toWorkSession(row, activityName) serialiser
(ISO timestamps, null-safe end_time/duration_seconds).
-
Step 1: Create
apps/api/test/helpers.tsexportingtokenFor(app, email)andauthHeaders(token)(move them out ofactivities.test.tsand import them there too, keeping that suite green). -
Step 2 (test first): Write
apps/api/test/sessions.test.ts. Seed an activity (callseed()or POST one). Cases:POST /api/sessions/startwithout token →401.POST /api/sessions/start{ activity_id, insole_type:'Kurk', pair_count:3 }→201, validatesWorkSession:status:'active',end_time:null,duration_seconds:null,source:'app',pair_count:3,activity_nameset,user_id= caller.POST /api/sessions/startwithpair_countomitted defaults to2.POST /api/sessions/startwith anactivity_idthat does not exist →404 { error: 'Activity not found' }.POST /api/sessions/startwithinsole_type:'Kurk'but the activity does NOT support'Kurk'→400 { error: 'Insole type not valid for activity' }(create an activity['3D'], start with'Kurk'). Add a test.- Stop lifecycle: start a session, then
POST /api/sessions/:id/stop→200,status:'completed',end_timeis a non-null ISO string,duration_secondsis an integer>= 0. Because start/stop happen within the same test tick, assertduration_seconds >= 0(not strictly positive) and thatend_time >= start_time. - Stop is owner-scoped: user A starts a session; user B (
tokenFor(app,'b@…')) callsPOST /api/sessions/:idOfA/stop→404(B must not learn A's session exists; treat not-owned == not-found). The session staysactivefor A. - Double-stop rejected: stop a session, stop it again →
409 { error: 'Session already closed' }. - Discard: start a session,
POST /api/sessions/:id/discard→200,status:'discarded'. Discarding a non-owned session →404. Discarding an already-closed session →409. :idnon-numeric or unknown →404.
-
Step 3: Implement
sessions.ts:start:requireUser;StartSessionInput.safeParse(body)→400; load the activity by id,404if missing; if!activity.insole_types.includes(input.insole_type)→400; insert{ userId, activityId, insoleType, pairCount, startTime: new Date(), status:'active', source:'app' }with.returning();c.json(toWorkSession(row, activity.name), 201).stop:requireUser; parse id; load the rowwhere(and(eq(id), eq(userId, user.id))); missing →404; ifrow.status !== 'active' || row.endTime !== null→409; computeend = new Date(),duration = Math.max(0, Math.round((end.getTime() - row.startTime.getTime()) / 1000));db.update(workSessions).set({ endTime: end, durationSeconds: duration, status: 'completed' }) .where(eq(workSessions.id, id)).returning(); join the activity name;c.json(...).discard:requireUser; same ownership load;409if notactive; setstatus:'discarded'(leaveend_time/durationnull — it was thrown away); return the row.- Use
import { and, eq } from 'drizzle-orm'. For the activity name, either a join or a second select onactivitiesbyactivityId. Mountapp.route('/', sessions).
-
Step 4: Green + typecheck, commit:
git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): work-session start/stop/discard (owner-scoped, server-authoritative duration)"
Backend Task 6: Sessions reads — history + active recovery
Files:
- Modify:
apps/api/src/routes/sessions.ts(addGET /api/sessions,GET /api/sessions/active) - Modify:
apps/api/test/sessions.test.ts
Routing order matters: register
GET /api/sessions/activeBEFORE any/:id-style route soactiveis not captured as an id. (The lifecycle routes are all under/api/sessions/:id/<verb>, so there is no direct conflict, but keepactiveexplicit and first among GETs.)
Interfaces: GET /api/sessions → WorkSessionList (caller's sessions, all statuses, newest first by
start_time, each with activity_name). GET /api/sessions/active → WorkSessionList (the caller's
status:'active' sessions only, newest first) for crash/recovery on app launch.
-
Step 1 (test first): Add tests:
GET /api/sessionswithout token →401.GET /api/sessions/activewithout token →401.- Ownership scoping: A starts+stops one session and starts a second (active); B starts one.
GET /api/sessionsas A returns exactly A's 2 sessions (and none of B's); validatesWorkSessionList; ordered newest-first bystart_time(assert the active/newer one is[0]). GET /api/sessions/activeas A returns exactly the 1 active session; after stopping it, returns[].- Each returned item has
activity_namepopulated (join correctness).
-
Step 2: Implement:
GET /api/sessions:requireUser; selectwork_sessionsjoined toactivitieswhere(eq(workSessions.userId, user.id)).orderBy(desc(workSessions.startTime)); map →WorkSession[].GET /api/sessions/active: same butand(eq(userId), eq(status, 'active')).- Use
import { desc } from 'drizzle-orm'. Prefer a DrizzleinnerJoin(activities, eq(...))selecting explicit columns +activities.nameso the serialiser has the name without a second query.
-
Step 3: Green + typecheck, commit:
git -C D:/Sven commit -am "feat(api): GET /api/sessions history + /api/sessions/active recovery"
Backend Task 7: CSV export
Files:
- Create:
apps/api/src/routes/export.ts - Modify:
apps/api/src/app.ts(mountexport) - Create:
apps/api/test/export.test.ts
Interfaces: GET /api/export → text/csv of the caller's completed sessions, ordered
start_time ASC. Header row (exact):
"Activity","Insole Type","Pair Count","Start","End","Duration (s)". Each data cell quoted with ",
embedded " doubled; rows joined with \n. Start/End are ISO-8601 UTC strings; Duration (s) is
duration_seconds. Response headers: Content-Type: text/csv; charset=utf-8,
Content-Disposition: attachment; filename="insole-production-report.csv".
-
Step 1 (test first): Write
apps/api/test/export.test.ts:GET /api/exportwithout token →401.- As user A: start+stop two sessions on a seeded activity (and start one active session that must NOT
appear).
GET /api/export→200;Content-Typeincludestext/csv;Content-Dispositioncontainsinsole-production-report.csv. Parse the body: first line equals the exact header above; there are exactly 2 data rows (active one excluded); each row has 6 quoted fields; theActivitycell equals the activity name;Duration (s)is the integer string. Ordered ascending by start. - Ownership: B's completed sessions never appear in A's export (start+stop one as B; A's export still has only its 2 rows).
- A cell-quoting test: create an activity whose name contains a
"(e.g.He"llo), stop a session on it, assert the CSV contains"He""llo".
-
Step 2: Implement
export.ts:requireUser; select completed sessions joined to activity namewhere(and(eq(userId, user.id), eq(status, 'completed'))).orderBy(asc(workSessions.startTime)).const quote = (v: unknown) => '"' + String(v ?? '').replace(/"/g, '""') + '"';- Header:
['Activity','Insole Type','Pair Count','Start','End','Duration (s)'].map(quote).join(','). - Each row:
[activityName, insoleType, pairCount, startTime.toISOString(), endTime?.toISOString() ?? '', durationSeconds ?? ''].map(quote).join(','). - Body =
[header, ...rows].join('\n'). Return viac.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="insole-production-report.csv"' })(verify thec.body(...)header-object signature against hono 4.12.25; otherwise build aResponsewithnew Response(csv, { headers })).
-
Step 3: Green + typecheck, commit:
git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET /api/export CSV of completed sessions (owner-scoped)"
Backend Task 8: CORS + trusted-origins consistency
Files:
- Modify:
apps/api/src/env.ts(addCORS_ORIGINS) - Modify:
apps/api/src/app.ts(applyhono/cors) - Modify:
apps/api/src/auth.ts(derivetrustedOriginsfrom the SAME env) — adding to the trustedOrigins array is allowed; do NOT change any other auth option - Modify:
apps/api/.env.exampleanddocker-compose.yml(documentCORS_ORIGINS) - Create:
apps/api/test/cors.test.ts
Interfaces: env.CORS_ORIGINS: string[] (parsed from a comma-separated env var, defaulting to the
local dev origins). CORS middleware allows those origins; better-auth trustedOrigins includes the
same list so a browser client passes CSRF/origin checks.
- Step 1: In
env.tsadd:
CORS_ORIGINS: (process.env.CORS_ORIGINS ?? 'http://localhost:8081,http://localhost:19006,http://localhost:3000')
.split(',').map((s) => s.trim()).filter(Boolean),
(8081 = Expo web/Metro default; 19006 = legacy Expo web; 3000 = the API's own origin.)
-
Step 2: In
auth.ts, changetrustedOriginsto merge the existing values withenv.CORS_ORIGINS(dedup). Do NOT altersecret,database,emailAndPassword, orplugins. Example:trustedOrigins: Array.from(new Set([env.BETTER_AUTH_URL, 'http://localhost:3000', ...env.CORS_ORIGINS])). -
Step 3 (test first): Write
apps/api/test/cors.test.ts:- A browser preflight:
OPTIONS /api/activitieswith headersOrigin: http://localhost:8081,Access-Control-Request-Method: GET,Access-Control-Request-Headers: authorization→ response hasAccess-Control-Allow-Origin: http://localhost:8081and the allow-headers list includesauthorization(case-insensitive check). - A real
GET /api/activities(no token) withOrigin: http://localhost:8081still returns401AND carriesAccess-Control-Allow-Origin(CORS headers present even on error responses). - An origin NOT in the list (
http://evil.example) does not get anAccess-Control-Allow-Origin: http://evil.exampleecho. (Assert the header is absent or not equal to the evil origin, per the installedhono/corsbehaviour — adapt the assertion to what the middleware actually does.)
- A browser preflight:
-
Step 4: In
app.ts, apply CORS before the routes:
import { cors } from 'hono/cors';
// inside createApp(), first:
app.use('*', cors({
origin: env.CORS_ORIGINS,
allowHeaders: ['Authorization', 'Content-Type'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
exposeHeaders: ['set-auth-token'],
}));
Bearer-only auth means
credentials: trueis NOT required. Verify theoriginoption accepts a string array in hono 4.12.25 (it does; otherwise pass a function that returns the origin when it is inenv.CORS_ORIGINS). The installed middleware is authoritative — shape the config + the test assertions to its real behaviour.
- Step 5: Update
.env.example(addCORS_ORIGINS=http://localhost:8081,http://localhost:3000) and add the same env todocker-compose.yml. Full suite green, typecheck clean, commit:git -C D:/Sven add apps/api docker-compose.yml && git -C D:/Sven commit -m "feat(api): CORS for browser clients, trustedOrigins consistency"
Backend Task 9: Backend wrap-up — full green + manual smoke
Files: none (verification only) — optionally a short apps/api/README.md note.
- Step 1: From
apps/api:corepack yarn test(ALL suites: the original 4 files + the newschema,seed,activities,sessions,export,cors),corepack yarn typecheck,npx oxlint(root config). All must pass. Record the real counts. - Step 2 (manual smoke, real commands):
rm -rf apps/api/datathen fromapps/api:corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start. Withcurl(or PowerShellInvoke-RestMethod): sign up, sign in (captureset-auth-token), thenGET /api/activities,POST /api/sessions/start,POST /api/sessions/:id/stop,GET /api/sessions,GET /api/export— confirm each works with the bearer header and401without. Do not commit the localdata/DB (it is gitignored). - Step 3: Commit any doc note only:
git -C D:/Sven commit -am "docs(api): Phase 1 backend smoke notes"(skip if nothing changed).
Mobile Task 1: Scaffold the fresh Expo app + workspace wiring
Files (create):
apps/mobile/package.json,apps/mobile/app.json,apps/mobile/tsconfig.json,apps/mobile/babel.config.js,apps/mobile/.env.example,apps/mobile/.gitignore,apps/mobile/index.ts(Expo Router entry),apps/mobile/src/app/_layout.tsx(placeholder),apps/mobile/metro.config.js(default, monorepo-aware),apps/mobile/jest.config.js,apps/mobile/jest.setup.ts.
Interfaces produced: the @solelog/mobile workspace, runnable with npx expo start /
--web, typechecking with tsc --noEmit, and a jest-expo harness that runs.
- Step 1: Create
apps/mobile/package.json. Use Expo Router's standard entry. Minimal deps ONLY:
{
"name": "@solelog/mobile",
"version": "0.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"web": "expo start --web",
"android": "expo start --android",
"ios": "expo start --ios",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@solelog/shared": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"expo": "*",
"expo-router": "*",
"expo-secure-store": "*",
"expo-status-bar": "*",
"react": "*",
"react-dom": "*",
"react-native": "*",
"react-native-web": "*"
},
"devDependencies": {
"@testing-library/react-native": "*",
"@types/jest": "*",
"@types/react": "*",
"jest": "*",
"jest-expo": "*",
"react-test-renderer": "*",
"typescript": "*"
}
}
Install correctly with Expo's resolver so versions match the SDK: from
apps/mobile, prefernpx create-expo-app@latest . --template blank-typescriptinto a temp dir to learn the exact pinned versions, OR runnpx expo install expo-router react-native-web react-dom expo-secure-store expo-status-barandnpx expo install --dev jest-expo, which writes SDK-correct versions. Then PIN the resolved exact versions (no^/*) per the repo convention, add@solelog/sharedand@tanstack/react-query, and runcorepack yarn installfrom the repo root so the workspace links. The*above are placeholders to be replaced by the resolved exact versions — do not ship*.
- Step 2:
apps/mobile/app.json— minimal Expo config with the router plugin and web bundler:
{
"expo": {
"name": "SoleLog",
"slug": "solelog",
"scheme": "solelog",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"web": { "bundler": "metro", "output": "single" },
"plugins": ["expo-router"]
}
}
- Step 3:
apps/mobile/tsconfig.jsonextendsexpo/tsconfig.base,strict: true, and a@/*→src/*path alias:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
- Step 4:
apps/mobile/babel.config.js:module.exports = (api) => { api.cache(true); return { presets: ['babel-preset-expo'] }; };.metro.config.js: default Expo config made monorepo-aware (watch the repo root, resolvenode_modulesfrom both app and root) so@solelog/sharedresolves:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;
-
Step 5:
apps/mobile/jest.config.js(preset: 'jest-expo',setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], atransformIgnorePatternsthat lets@solelog/shared, expo, react-native,@tanstacktransform).jest.setup.tsimports@testing-library/react-nativematchers if needed. Add a trivial smoke testapps/mobile/src/__tests__/smoke.test.ts(expect(1 + 1).toBe(2)) and runcorepack yarn workspace @solelog/mobile testto prove the harness runs. -
Step 6: Placeholder
src/app/_layout.tsxrendering a<Stack />(expo-router) wrapped in a React Query provider (defined in Mobile Task 2) — for now a bare<Stack />soexpo startboots..env.example:EXPO_PUBLIC_BASE_URL=http://localhost:3000plus a commented LAN example# EXPO_PUBLIC_BASE_URL=http://192.168.1.50:3000..gitignore:.expo/,node_modules/,dist/,*.log,.env. -
Step 7: Verify: from
apps/mobile,corepack yarn typecheckclean andcorepack yarn testgreen. (Do not block the plan on launching a simulator;expo start --webis exercised in Mobile Task 6.) Commit:git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): scaffold fresh @solelog/mobile Expo Router app + workspace wiring"
Mobile Task 2: Token storage + typed API client + React Query provider
Files (create):
apps/mobile/src/lib/tokenStore.ts,apps/mobile/src/lib/api.ts,apps/mobile/src/lib/queryClient.tsxapps/mobile/src/lib/__tests__/api.test.ts,apps/mobile/src/lib/__tests__/tokenStore.test.ts
Interfaces produced:
-
tokenStore:getToken(): Promise<string|null>,setToken(t: string): Promise<void>,clearToken(): Promise<void>— backed byexpo-secure-store(SecureStore.getItemAsyncetc.) on native; on web SecureStore falls back to localStorage via Expo's own web implementation in SDK 54 (no custom shim needed — verify; if SecureStore is unavailable on web in the installed SDK, guard withSecureStore.isAvailableAsync()and fall back tolocalStorageinsidetokenStore). -
api: a typed client.baseUrl = process.env.EXPO_PUBLIC_BASE_URL ?? 'http://localhost:3000'. Methods (each attachesAuthorization: Bearer <token>when a token is stored, setsContent-Type: application/jsonfor bodies, parses JSON, throwsApiError { status, message }on non-2xx):signUp(email, password),signIn(email, password)→ on success reads theset-auth-tokenresponse header andsetTokens it; returns the user.getActivities(insoleType?),createActivity(input),updateActivity(id, input),deleteActivity(id).startSession(input),stopSession(id),discardSession(id),getSessions(),getActiveSessions().exportUrl()→ returns the${baseUrl}/api/exportstring (the screen opens/shares it).- Return types use
@solelog/shared(Activity,WorkSession, etc.); validate responses with the zod schemas where cheap (at leastWorkSession.parseon session mutations) so contract drift fails loudly in dev.
-
Step 1 (test first):
tokenStore.test.ts— mockexpo-secure-store(jestjest.mock('expo-secure-store')) and assertsetToken/getToken/clearTokencall the right SecureStore methods with a stable key ('solelog-auth-token'). ImplementtokenStore.ts. Green. -
Step 2 (test first):
api.test.ts— mockglobal.fetch. Assert:signInPOSTs to${baseUrl}/api/auth/sign-in/emailwith the email/password body, reads theset-auth-tokenheader from the mocked response, and callstokenStore.setTokenwith it (mocktokenStore).getActivities()GETs${baseUrl}/api/activitieswithAuthorization: Bearer <stored token>when a token is present (mockgetToken→'tok'), and WITHOUT the header when no token.getActivities('3D')appends?insole_type=3D.startSession({activity_id:1,insole_type:'Kurk',pair_count:2})POSTs to/api/sessions/startand returns a parsedWorkSession(feed a valid fixture from the mock).- A non-2xx response throws
ApiErrorwith the rightstatus. Implementapi.ts. Green.
-
Step 3:
queryClient.tsxexports aQueryClient(defaults:staleTime5 min,retry1,refetchOnWindowFocus: false— matching the legacy app) and an<AppQueryProvider>wrappingQueryClientProvider. Wire it intosrc/app/_layout.tsx. (No new test needed; covered by the screen render smoke test in Mobile Task 6.) -
Step 4: Typecheck + test green, commit:
git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): secure token store + typed API client + query provider"
Mobile Task 3: Auth gate + login/sign-up screen
Files (create):
apps/mobile/src/lib/auth.tsx(anAuthProvider+useAuth()hook:token,isReady,signIn,signUp,signOut)apps/mobile/src/app/login.tsx(the login/sign-up screen, Dutch)- Modify:
apps/mobile/src/app/_layout.tsx(gate: while!isReadyrendernull/splash; if no token redirect to/login; else render the tabs) apps/mobile/src/app/(tabs)/_layout.tsx(3-tab navigator placeholder so routing exists)apps/mobile/src/lib/__tests__/auth.test.tsx
Interfaces produced: useAuth() context. On mount it calls tokenStore.getToken() to restore the
session (with a timeout escape hatch so a stuck read can't freeze launch — lesson from
docs/reference/legacy-lessons-and-gotchas.md §1), sets isReady. signIn/signUp delegate to
api, store the token, set token in state; signOut clears it.
-
Step 1 (test first):
auth.test.tsx— render a component usinguseAuth()inside<AuthProvider>withapi/tokenStoremocked. Assert: startsisReady:false, becomesisReady:truewithtokenfromgetToken; callingsignInsetstoken;signOutclears it. Implementauth.tsx. Green. -
Step 2: Build
login.tsx(RNStyleSheet, no extra libs): emailTextInput(keyboardType:'email-address',autoCapitalize:'none'), passwordTextInput(secureTextEntry), a primary buttonInloggen("Sign in") callingsignIn, and a small text affordanceAccount aanmaken("Create account") that toggles to sign-up (button becomesRegistreren). On error show the message (Dutch fallbackInloggen mislukt/Registreren mislukt). On success the auth state flips and the gate routes into the tabs. Use the blue palette fromdocs/reference/legacy-mobile-app.md§2 for consistency. -
Step 3:
_layout.tsxgate +(tabs)/_layout.tsx(expo-routerTabswith three screens in order —indextitledStopwatch,historytitledGeschiedenis,taskstitledInstellingen; tab tint#2563EB/#6B7280). Tabs may use simple text labels (NOlucide/icon libraries — keep deps minimal; an emoji or omitted icon is fine). Add temporary stub screens for the three tabs so navigation compiles. -
Step 4: Typecheck + test green, commit:
git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): auth gate + Dutch login/sign-up screen + 3-tab shell"
Mobile Task 4: Stopwatch screen ((tabs)/index.tsx)
Files (create/modify):
apps/mobile/src/lib/timer.ts(pure timing helpers — unit-testable, no React)apps/mobile/src/app/(tabs)/index.tsx(the Stopwatch screen)apps/mobile/src/lib/__tests__/timer.test.ts
Behaviour (from docs/reference/legacy-mobile-app.md §4), adapted to server-authoritative sessions:
-
Type zoolsegmented selector (Kurk/Berk/3D, defaultKurk); changing it clears the chosen activity.Type handelingdropdown listing activities filtered to the chosen zool (activity.insole_types.includes(insoleType)); placeholderKies een handeling...; empty-stateGeen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen.Aantal zolenstepper (default2, min1). All three selectors lock while a session is active. -
Activities fetched via React Query
['activities']→api.getActivities(). -
Server-authoritative lifecycle:
- Start (enabled only when an activity is chosen):
api.startSession({ activity_id, insole_type, pair_count }); store the returnedWorkSession(itsid,start_time) as the active session. - The on-screen timer is display-only, computed by wall-clock delta from the server
start_time(elapsed = now - new Date(session.start_time)), updated every second viasetInterval. Pause/resume only freezes the display (do NOT call the server; Phase 1 has no server pause). Usetimer.formatHMS(seconds). - Stop & Opslaan:
api.stopSession(activeSession.id); on success invalidate['sessions']+['activeSessions'], reset the timer (keep the selections, like legacy). - Annuleren double-press discard (3 s arm window,
Nogmaals tikken ter bevestigingwhen armed): second tap callsapi.discardSession(activeSession.id)and resets.
- Start (enabled only when an activity is chosen):
-
Recovery on launch:
useQuery(['activeSessions'], api.getActiveSessions); if it returns a session, adopt it as the active session and resume the display timer from itsstart_time(so a phone restart doesn't lose an open session). Dutch strings exactly per the reference §7 inventory. -
Step 1 (test first):
timer.test.tsfor pure helpers intimer.ts:formatHMS(0) === '00:00:00',formatHMS(65) === '00:01:05',formatHMS(3661) === '01:01:01',formatHMS(360000) === '100:00:00'(hours can exceed 99).elapsedSeconds(startISO, nowMs)returns whole seconds between an ISO start and anowepoch-ms, floored, never negative (clamp to 0 ifnow < start). Test a 65 000 ms gap →65; a negative gap →0. Implementtimer.ts. Green.
-
Step 2: Build
index.tsxusingtimer.ts, the API client, and React Query. State machine and Dutch strings per §4/§4.9 of the reference, but every start/stop/discard is a server call as above. No extra libraries (RNModalfor the picker sheet is fine; no animation lib required — a simple modal list is acceptable for Phase 1). -
Step 3: Typecheck +
timer.test.tsgreen, commit:git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): server-authoritative Stopwatch screen + timing helpers"
Mobile Task 5: Geschiedenis (history) + Instellingen (settings) screens
Files (create/modify):
apps/mobile/src/app/(tabs)/history.tsxapps/mobile/src/app/(tabs)/tasks.tsxapps/mobile/src/lib/format.ts(formatDuration,formatDate,formatTime,pluralInsoles) +apps/mobile/src/lib/__tests__/format.test.ts
History (docs/reference/legacy-mobile-app.md §5): header Geschiedenis + an Exporteer CSV action; list via React Query ['sessions'] → api.getSessions(). Each card: activity_name,
date/time line, badges for insole_type, pair count (inlegzool/inlegzolen singular/plural),
and formatDuration(duration_seconds) (Xh Ym / Ym Zs / Zs). Empty-state Nog geen opgeslagen sessies. The CSV action opens api.exportUrl() — on web, open in a new tab / trigger download
(window.open / an <a download>); on native, use Linking.openURL (RN core Linking, no extra dep).
Failure alert title Fout, body Kan de export-URL niet openen. Auth note: the legacy
/api/export open used a plain URL with no header; the new export is bearer-protected, so on native
prefer fetching with the token and sharing the text, or append the token — simplest Phase 1 path: fetch
the CSV via api (with the bearer header) and write/share it; on web, fetch with the header and
trigger a Blob download. Document this divergence in a code comment (a bare openURL to a protected
endpoint would 401).
Settings (docs/reference/legacy-mobile-app.md §6): header Instellingen + subtitle Beheer handelingen per zooltype. "Add new handling" card (Nieuwe handeling toevoegen, name placeholder
Naam van de stap, bijv. Leerrand, Van toepassing op three type toggles default all three,
Stap toevoegen button). List Huidige stappen ({n}) with edit (Opslaan/Annuleren)
and delete (confirm alert title Taak verwijderen, body per §6.5, buttons Annuleren /
Verwijderen). Wired to api.createActivity/updateActivity/deleteActivity, React Query key
['activities']. Delete divergence: the backend returns 409 when the activity is in use (Backend
Task 4) — surface that as an alert (Dutch Kan niet verwijderen: handeling is in gebruik.) instead
of assuming a cascade. Keep this screen minimal.
-
Step 1 (test first):
format.test.ts:formatDuration(45) === '45s',formatDuration(200) === '3m 20s',formatDuration(3900) === '1h 5m'.pluralInsoles(1) === '1 inlegzool',pluralInsoles(2) === '2 inlegzolen'.formatDate/formatTimereturn non-empty strings for a known ISO input (locale-tolerant: assert they are strings of length > 0, not exact locale formatting). Implementformat.ts. Green.
-
Step 2: Build
history.tsxandtasks.tsxper the references, usingformat.ts,api, and React Query. Dutch strings exact per the §7 inventory. -
Step 3: Typecheck +
format.test.tsgreen, commit:git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): Geschiedenis + Instellingen screens + format helpers"
Mobile Task 6: Component render smoke test + web/device run verification
Files (create):
-
apps/mobile/src/app/__tests__/login.render.test.tsx(component render smoke test) -
optionally
apps/mobile/src/app/(tabs)/__tests__/history.render.test.tsx -
Step 1 (test first): A jest-expo +
@testing-library/react-nativerender test mounting thelogin.tsxscreen (withapi/authmocked) and asserting the Dutch strings render: e.g.getByText('Inloggen')and the email/password inputs are present. (At least ONE component render smoke test is required; add the history one if time permits, mockingapi.getSessionsto return a fixture array and asserting anactivity_namerenders, plus the empty-state string for[].) -
Step 2: Make the render test(s) pass (fix providers/mocks as needed — wrap in
AppQueryProvider+AuthProvideras required).corepack yarn workspace @solelog/mobile testandcorepack yarn workspace @solelog/mobile typecheckboth green. -
Step 3 (manual run verification, real commands):
- Start the backend (
apps/api:corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start). - Web target: from
apps/mobile,EXPO_PUBLIC_BASE_URL=http://localhost:3000thennpx expo start --web; in the browser: sign up, see seeded activities in Instellingen and the Stopwatch handling picker, start → stop a session, see it in Geschiedenis, export CSV. Confirm CORS lets the browser call:3000from the Expo web origin. - Device target: set
EXPO_PUBLIC_BASE_URL=http://<PC-LAN-IP>:3000inapps/mobile/.env,npx expo start, open in Expo Go over the same LAN, repeat the round-trip. (Backend already binds all interfaces via@hono/node-server; ensure the host firewall allows:3000.) - Record outcomes in the commit message / session notes. Do not fabricate; if a step fails, debug it
(
superpowers:systematic-debugging) before claiming done.
- Start the backend (
-
Step 4: Commit:
git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "test(mobile): login/history render smoke tests + web/device run verified"
Phase 1 Definition of Done
- Backend: all original tests still pass plus new suites (
schema/seed,activities,sessions,export,cors) — every endpoint covered for: 401 without token, ownership scoping (user A cannot see/stop user B's session), start→stop lifecycle (server-computedduration_seconds), discard, and CSV output.tsc --noEmitclean;npx oxlintclean.drizzle-orm/drizzle-kitunchanged; auth tables untouched; one NEW migration (0001). - Mobile:
@solelog/mobileruns on Expo web and on a device via Expo Go; logs in against the backend and attaches the bearer token; the three Dutch screens work against the live API; jest-expo unit tests (api client, timer logic, format helpers, token store, auth) + at least one component render smoke test pass;tsc --noEmitclean. No restored Create plumbing; no unused libraries. - All work committed in small Conventional-Commit units as specified per task.