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