1081 lines
60 KiB
Markdown
1081 lines
60 KiB
Markdown
# Phase 1 — Worker Timing Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development`
|
|
> (recommended) or `superpowers:executing-plans` to implement this plan task-by-task, and
|
|
> `superpowers:test-driven-development` for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
> Implement strict TDD: write the test, watch it fail for the right reason, then write the code to make
|
|
> it pass. **Never weaken, skip, or delete a test to make it pass.** Run REAL commands; never fabricate output.
|
|
|
|
**Goal:** Deliver "Worker timing" end-to-end. The backend (`apps/api`) gains domain tables (activities,
|
|
work-sessions), a user-scoped REST surface for managing activities and starting/stopping/discarding
|
|
server-authoritative work sessions, a history list, an "active session" recovery endpoint, and a CSV
|
|
export — all behind the existing better-auth bearer session. A fresh, lean Expo Router app
|
|
(`apps/mobile`, package `@solelog/mobile`) is added that logs in, attaches the bearer token to every
|
|
call, and reproduces the three Dutch screens (Stopwatch / Geschiedenis / Instellingen) against this
|
|
backend. *Done when:* a worker can pick an activity, start/stop a server-side session, see history, and
|
|
export CSV; all backend endpoints are user-scoped and covered by vitest; the mobile app runs on Expo
|
|
web and on a device via Expo Go, with jest-expo unit tests and a clean `tsc --noEmit`.
|
|
|
|
**Architecture:** The backend remains the single owner of auth + DB (Decision A from the roadmap). The
|
|
mobile app is a pure client: it holds no business logic beyond UI state and the live elapsed-timer
|
|
display; **start/stop/discard are server calls**, so an open session survives a phone restart and can be
|
|
recovered. Request/response shapes are zod schemas in `packages/shared`, imported by both `apps/api`
|
|
(validation) and `apps/mobile` (typed client). All new domain routes resolve the user from the
|
|
better-auth session (the exact `auth.api.getSession({ headers })` pattern already used by
|
|
`src/routes/me.ts`) and scope every query to that `user_id`; no token → `401`.
|
|
|
|
**Tech Stack (already installed — these versions are authoritative; do not bump):**
|
|
|
|
| Package | Installed version | Notes |
|
|
|---|---|---|
|
|
| `hono` | 4.12.25 | router + `hono/cors` middleware |
|
|
| `@hono/node-server` | 1.19.14 | server entry |
|
|
| `better-auth` | 1.6.18 | bearer plugin; `set-auth-token` response header on sign-in |
|
|
| `drizzle-orm` | 0.36.4 | **pinned — do NOT bump (tracked as SL-9)** |
|
|
| `drizzle-kit` | 0.30.6 | **pinned — do NOT bump (tracked as SL-9)** |
|
|
| `@libsql/client` | 0.14.0 | SQLite driver (no native build) |
|
|
| `zod` | 3.25.76 | contracts |
|
|
| `vitest` | 3.2.6 | backend tests |
|
|
| `typescript` | 5.9.3 | `tsc --noEmit` |
|
|
| `tsx` | 4.22.4 | run/seed scripts |
|
|
|
|
Mobile (to be added, latest within each major at install time, pinned exact after install):
|
|
`expo` (SDK 54 line), `expo-router`, `react`, `react-native`, `react-dom`, `react-native-web`,
|
|
`expo-secure-store`, `expo-status-bar`, `@tanstack/react-query`, `@solelog/shared` (workspace:*), and
|
|
dev deps `jest-expo`, `jest`, `@testing-library/react-native`, `react-test-renderer`, `typescript`,
|
|
`@types/react`, `@types/jest`.
|
|
|
|
## Global Constraints
|
|
|
|
- **Package manager:** Yarn 4.12.0 (Berry), `nodeLinker: node-modules`. Run from the repo root via
|
|
corepack: `corepack yarn install`, `corepack yarn …`. Workspaces are `apps/*` + `packages/*`
|
|
(already configured in root `package.json`). The mobile app simply lives at `apps/mobile`, so it is
|
|
picked up automatically.
|
|
- **Run commands** — backend from `apps/api` (`corepack yarn test`, `corepack yarn typecheck`),
|
|
mobile from `apps/mobile`. Git always as `git -C D:/Sven …`.
|
|
- **Do NOT modify the better-auth files** (`src/auth.ts`, the `user`/`session`/`account`/`verification`
|
|
tables in `src/db/schema.ts`) beyond *adding* new domain tables/relations to `schema.ts` and *reusing*
|
|
`auth`/`auth.api.getSession`. Do NOT regenerate the auth tables.
|
|
- **Do NOT bump `drizzle-orm` / `drizzle-kit`** (pinned; SL-9). Use the installed API only.
|
|
- **Keep `apps/api` green:** the existing 4 test files / 5 tests must stay passing. Run the full suite
|
|
after every backend task.
|
|
- **Strict TDD, no test weakening.** If installed-library behaviour differs from any sample code in this
|
|
plan, the **installed library is authoritative** — adapt the code and make the *real* test pass; never
|
|
loosen an assertion to paper over a real bug.
|
|
- **Mobile is greenfield and minimal.** Do NOT restore the deleted Create/Anything export, its
|
|
`__create` plumbing, the web-sandbox iframe layer, analytics/Sentry, patched deps, or any unused
|
|
library (ads/IAP/maps/3D/audio/sensors/lucide/NativeWind). Add a dependency only when a task needs it.
|
|
- **Out of scope (do NOT build):** workbenches / QR scanning (Phase 4), the admin web panel (Phase 3),
|
|
admin user-management / roles beyond per-user scoping (Phase 2), offline-first, push notifications.
|
|
- **Commit style:** Conventional Commits, one commit per task (or per step where a task says so).
|
|
Commit only after that task's tests are green.
|
|
|
|
## SQLite storage & cross-cutting decisions (resolved here, referenced by tasks)
|
|
|
|
1. **`insole_types` array storage.** SQLite has no array type. Store it with Drizzle
|
|
`text('insole_types', { mode: 'json' }).$type<InsoleType[]>()`. drizzle-orm 0.36.4 serialises the JS
|
|
array to a JSON string on write and `JSON.parse`s on read, so route code sees a real `string[]`. The
|
|
zod contract validates it as `z.array(InsoleType)`. **Never** filter inside SQL on this column; the
|
|
`?insole_type=` filter on `GET /api/activities` is applied **in JS** after fetching the user-visible
|
|
rows (the dataset is tiny — a handful of activities). Document this in a code comment.
|
|
2. **Timestamps.** Reuse the better-auth convention already in `schema.ts`:
|
|
`integer({ mode: 'timestamp_ms' })` (epoch-ms; Drizzle maps to/from `Date`). `start_time` is set at
|
|
`start`; `end_time` is `null` while active. The API contract serialises timestamps as **ISO-8601
|
|
strings** (`Date.toISOString()`), so the mobile client and CSV are timezone-explicit (UTC). The
|
|
wire/JSON shape is always ISO strings; the DB stores epoch-ms.
|
|
3. **`duration_seconds`.** Server-authoritative and **computed on stop** as
|
|
`Math.round((end_time - start_time) / 1000)` (whole seconds). This differs deliberately from the
|
|
legacy client-tick count (see `docs/reference/legacy-lessons-and-gotchas.md` §6 — tick counting
|
|
under-counts when backgrounded). The mobile timer is display-only; the server number is the source of
|
|
truth. `notes` and a future pause model are out of scope for Phase 1 (pause does not change the
|
|
server session; it only freezes the on-screen display).
|
|
4. **CSV format.** Match `docs/reference/legacy-backend.md` §4 as closely as the new (user-scoped,
|
|
completed-only) data allows: `text/csv; charset=utf-8`,
|
|
`Content-Disposition: attachment; filename="insole-production-report.csv"`, every cell quoted with
|
|
`"` doubling, rows joined with `\n`, ordered `start_time ASC`. Columns are reduced to the set this
|
|
ticket specifies (activity name, insole type, pair count, start, end, duration_seconds) — see Backend
|
|
Task 7 for the exact header row. Format dates/times explicitly in UTC ISO to avoid the legacy
|
|
server-timezone fragility.
|
|
5. **CORS.** Add `hono/cors` so Expo web (a browser origin, e.g. `http://localhost:8081`) can call the
|
|
API cross-origin with `Authorization: Bearer …`. Because auth is **bearer-token only** for the
|
|
mobile/web client (no cookies), CORS does not need `credentials: true`; allow the `Authorization` and
|
|
`Content-Type` request headers and expose `set-auth-token`. The allow-list is env-driven and kept
|
|
**consistent with better-auth `trustedOrigins`** by reading the same env var (see Backend Task 8).
|
|
6. **`EXPO_PUBLIC_BASE_URL`.** The mobile client's typed API client reads
|
|
`process.env.EXPO_PUBLIC_BASE_URL` and defaults to `http://localhost:3000`. For device testing over
|
|
LAN, set it to the PC's LAN IP (e.g. `http://192.168.1.50:3000`) in `apps/mobile/.env`. The
|
|
`EXPO_PUBLIC_` prefix makes Expo inline it into the bundle. Document both values in `.env.example`.
|
|
|
|
## Domain data model (added to `apps/api/src/db/schema.ts`)
|
|
|
|
```
|
|
activities
|
|
id integer PK autoincrement
|
|
name text NOT NULL
|
|
insole_types text(json) NOT NULL -- InsoleType[] subset of 'Kurk'|'Berk'|'3D'
|
|
created_at integer timestamp_ms DEFAULT now NOT NULL
|
|
|
|
work_sessions
|
|
id integer PK autoincrement
|
|
user_id text NOT NULL FK -> user.id ON DELETE CASCADE
|
|
activity_id integer NOT NULL FK -> activities.id
|
|
insole_type text NOT NULL -- 'Kurk'|'Berk'|'3D'
|
|
pair_count integer NOT NULL DEFAULT 2
|
|
start_time integer timestamp_ms NOT NULL
|
|
end_time integer timestamp_ms NULL -- null = active
|
|
duration_seconds integer NULL -- null until stopped
|
|
status text NOT NULL DEFAULT 'active' -- 'active'|'completed'|'discarded'
|
|
source text NOT NULL DEFAULT 'app' -- 'app'|'manual'
|
|
notes text NULL
|
|
created_at integer timestamp_ms DEFAULT now NOT NULL
|
|
```
|
|
|
|
Indices: `work_sessions(user_id)`, `work_sessions(activity_id)`, `work_sessions(user_id, status)`.
|
|
|
|
---
|
|
|
|
# Backend Task 1: Domain contracts in `packages/shared`
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared/src/index.ts`
|
|
- Create: `packages/shared/test/contracts.test.ts`
|
|
- Modify: `packages/shared/package.json` (add `vitest` devDep + `test` script) and create
|
|
`packages/shared/vitest.config.ts`
|
|
|
|
**Interfaces produced** (all exported as `const` zod schema + inferred `type` of the same name, the
|
|
existing convention in this file):
|
|
|
|
```ts
|
|
// enums
|
|
InsoleType = z.enum(['Kurk', 'Berk', '3D'])
|
|
SessionStatus = z.enum(['active', 'completed', 'discarded'])
|
|
SessionSource = z.enum(['app', 'manual'])
|
|
|
|
// Activity (response shape; timestamps are ISO strings on the wire)
|
|
Activity = z.object({
|
|
id: z.number().int(),
|
|
name: z.string(),
|
|
insole_types: z.array(InsoleType),
|
|
created_at: z.string(), // ISO
|
|
})
|
|
|
|
// Activity write payloads
|
|
CreateActivityInput = z.object({
|
|
name: z.string().trim().min(1),
|
|
insole_types: z.array(InsoleType).min(1),
|
|
})
|
|
UpdateActivityInput = CreateActivityInput // same shape
|
|
|
|
// WorkSession (response shape)
|
|
WorkSession = z.object({
|
|
id: z.number().int(),
|
|
user_id: z.string(),
|
|
activity_id: z.number().int(),
|
|
activity_name: z.string(), // joined from activities.name
|
|
insole_type: InsoleType,
|
|
pair_count: z.number().int(),
|
|
start_time: z.string(), // ISO
|
|
end_time: z.string().nullable(),// ISO or null
|
|
duration_seconds: z.number().int().nullable(),
|
|
status: SessionStatus,
|
|
source: SessionSource,
|
|
notes: z.string().nullable(),
|
|
created_at: z.string(),
|
|
})
|
|
|
|
// Session write payloads
|
|
StartSessionInput = z.object({
|
|
activity_id: z.number().int(),
|
|
insole_type: InsoleType,
|
|
pair_count: z.number().int().positive().default(2),
|
|
})
|
|
|
|
// list responses
|
|
ActivityList = z.array(Activity)
|
|
WorkSessionList = z.array(WorkSession)
|
|
```
|
|
|
|
Keep the existing `HealthResponse`, `PublicUser`, `MeResponse` exports untouched.
|
|
|
|
- [ ] **Step 1 (test first):** Create `packages/shared/vitest.config.ts`:
|
|
|
|
```ts
|
|
import { defineConfig } from 'vitest/config';
|
|
export default defineConfig({ test: { environment: 'node' } });
|
|
```
|
|
|
|
Add to `packages/shared/package.json`: `"scripts": { "test": "vitest run" }` and
|
|
`"devDependencies": { "vitest": "^3.0.0" }`. Run `corepack yarn install` from the repo root.
|
|
|
|
- [ ] **Step 2 (test first):** Write `packages/shared/test/contracts.test.ts` asserting:
|
|
- `InsoleType.parse('Kurk')` succeeds; `InsoleType.safeParse('Leer').success === false`.
|
|
- `CreateActivityInput.parse({ name: ' Leerrand ', insole_types: ['Kurk'] })` returns
|
|
`name: 'Leerrand'` (trim) and the types array.
|
|
- `CreateActivityInput.safeParse({ name: '', insole_types: ['Kurk'] }).success === false` (empty name).
|
|
- `CreateActivityInput.safeParse({ name: 'X', insole_types: [] }).success === false` (≥1 type).
|
|
- `StartSessionInput.parse({ activity_id: 1, insole_type: 'Berk' }).pair_count === 2` (default applied).
|
|
- `StartSessionInput.safeParse({ activity_id: 1, insole_type: 'Berk', pair_count: 0 }).success === false`.
|
|
- `WorkSession.parse(<a fully-populated active fixture with end_time:null, duration_seconds:null>)`
|
|
succeeds and `WorkSession.safeParse(<same but status:'paused'>).success === false`.
|
|
|
|
Run `corepack yarn workspace @solelog/shared test` — it must fail (schemas don't exist yet).
|
|
|
|
- [ ] **Step 3:** Implement the schemas/types in `packages/shared/src/index.ts`. Re-run the test until
|
|
green. Run the existing `apps/api` suite (`corepack yarn workspace @solelog/api test`) to confirm the
|
|
new exports didn't break the imports in `health.ts`/`me.ts` (they shouldn't — only additions).
|
|
|
|
- [ ] **Step 4:** `corepack yarn workspace @solelog/api typecheck` (must stay clean), then commit:
|
|
`git -C D:/Sven add packages/shared && git -C D:/Sven commit -m "feat(shared): Phase 1 activity & work-session zod contracts"`
|
|
|
|
---
|
|
|
|
# Backend Task 2: Domain tables + migration + seed data
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/db/schema.ts` (ADD `activities`, `work_sessions` tables + relations; do NOT touch
|
|
the auth tables above them)
|
|
- Create: `apps/api/src/db/seed.ts`
|
|
- Modify: `apps/api/package.json` (add `"db:seed": "tsx src/db/seed.ts"` script)
|
|
- Generated: `apps/api/drizzle/0001_*.sql` (+ updated `drizzle/meta/*`) via `drizzle-kit generate`
|
|
- Create: `apps/api/test/schema.test.ts`
|
|
|
|
**Interfaces produced:** `activities`, `workSessions` Drizzle tables (+ `activitiesRelations`,
|
|
`workSessionsRelations`) exported from `db/schema.ts`, consumed by all route tasks and the seed.
|
|
|
|
- [ ] **Step 1:** Append to `apps/api/src/db/schema.ts` (after the existing auth tables/relations):
|
|
|
|
```ts
|
|
import type { InsoleType } from '@solelog/shared';
|
|
// (add `sqliteTable, text, integer, index` are already imported at the top)
|
|
|
|
export const activities = sqliteTable('activities', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
name: text('name').notNull(),
|
|
// SQLite has no array type: store InsoleType[] as a JSON string. Drizzle
|
|
// (mode:'json') serialises on write and JSON.parses on read. NEVER filter on
|
|
// this column in SQL — the ?insole_type= filter is applied in JS (tiny dataset).
|
|
insoleTypes: text('insole_types', { mode: 'json' }).$type<InsoleType[]>().notNull(),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
.notNull(),
|
|
});
|
|
|
|
export const workSessions = sqliteTable(
|
|
'work_sessions',
|
|
{
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
userId: text('user_id')
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: 'cascade' }),
|
|
activityId: integer('activity_id')
|
|
.notNull()
|
|
.references(() => activities.id),
|
|
insoleType: text('insole_type').$type<InsoleType>().notNull(),
|
|
pairCount: integer('pair_count').default(2).notNull(),
|
|
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
|
|
endTime: integer('end_time', { mode: 'timestamp_ms' }),
|
|
durationSeconds: integer('duration_seconds'),
|
|
status: text('status').$type<'active' | 'completed' | 'discarded'>().default('active').notNull(),
|
|
source: text('source').$type<'app' | 'manual'>().default('app').notNull(),
|
|
notes: text('notes'),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
.notNull(),
|
|
},
|
|
(table) => ({
|
|
workSessionsUserIdIdx: index('work_sessions_user_id_idx').on(table.userId),
|
|
workSessionsActivityIdIdx: index('work_sessions_activity_id_idx').on(table.activityId),
|
|
workSessionsUserStatusIdx: index('work_sessions_user_status_idx').on(table.userId, table.status),
|
|
})
|
|
);
|
|
|
|
export const activitiesRelations = relations(activities, ({ many }) => ({
|
|
sessions: many(workSessions),
|
|
}));
|
|
export const workSessionsRelations = relations(workSessions, ({ one }) => ({
|
|
user: one(user, { fields: [workSessions.userId], references: [user.id] }),
|
|
activity: one(activities, { fields: [workSessions.activityId], references: [activities.id] }),
|
|
}));
|
|
```
|
|
|
|
> If `verbatimModuleSyntax`/`import type` for `InsoleType` causes a value/type clash, keep it as a
|
|
> `type` import (it is only used in `$type<>()`). If the installed drizzle-kit emits the JSON column or
|
|
> the autoincrement PK differently from this sample, **the generated SQL is authoritative** — keep the
|
|
> schema, regenerate, and adjust the schema only if generation errors.
|
|
|
|
- [ ] **Step 2:** Generate the migration: from `apps/api`, `corepack yarn db:generate`
|
|
(`drizzle-kit generate`). This must produce a NEW `drizzle/0001_*.sql` containing `CREATE TABLE
|
|
activities` and `CREATE TABLE work_sessions` (and indices) and a `0001` journal entry — it must NOT
|
|
rewrite `0000`. Inspect the generated SQL and confirm it does not alter the auth tables.
|
|
|
|
- [ ] **Step 3 (test first):** Write `apps/api/test/schema.test.ts`:
|
|
- imports `db` and `{ activities, workSessions }` from schema;
|
|
- inserts an activity with `insoleTypes: ['Kurk', 'Berk']`, reads it back, and asserts the value is a
|
|
real array `['Kurk','Berk']` (proves JSON round-trips), and `createdAt instanceof Date`;
|
|
- inserts a `work_sessions` row referencing a created user + the activity with `endTime: null`,
|
|
reads it back, asserts `status === 'active'`, `pairCount === 2` (default), `endTime === null`,
|
|
`durationSeconds === null`.
|
|
- To have a `user.id` to reference, create a user first via the better-auth sign-up route through
|
|
`createApp()` (as the auth test does), then `db.select().from(user)` to grab the id — OR insert a
|
|
user row directly with `db.insert(user).values({...})`. Prefer the sign-up route (closer to reality).
|
|
|
|
Run `corepack yarn workspace @solelog/api test` — `schema.test.ts` fails (tables not migrated in the
|
|
test DB). NOTE: `test/setup.ts` runs `runMigrations()` which applies `./drizzle`, so once `0001` exists
|
|
it is applied automatically to the fresh `.tmp/test.db`. The failure before generating is the FK/table
|
|
missing; after Step 2 + implementing the schema it goes green.
|
|
|
|
- [ ] **Step 4:** Create the seed script `apps/api/src/db/seed.ts` — idempotent (insert only if the
|
|
`activities` table is empty), realistic activities per `docs/reference/legacy-mobile-app.md`
|
|
(`Leerrand` example + plausible insole-production steps). Use this exact seed set:
|
|
|
|
```ts
|
|
// apps/api/src/db/seed.ts
|
|
import { db } from './client';
|
|
import { activities } from './schema';
|
|
|
|
const SEED_ACTIVITIES: { name: string; insole_types: ('Kurk' | 'Berk' | '3D')[] }[] = [
|
|
{ name: 'Uitsnijden', insole_types: ['Kurk', 'Berk', '3D'] },
|
|
{ name: 'Leerrand', insole_types: ['Kurk', 'Berk'] },
|
|
{ name: 'Slijpen', insole_types: ['Kurk', 'Berk', '3D'] },
|
|
{ name: 'Lijmen', insole_types: ['Kurk', 'Berk'] },
|
|
{ name: 'Bekleden', insole_types: ['Kurk', 'Berk', '3D'] },
|
|
{ name: 'Frezen', insole_types: ['3D'] },
|
|
{ name: 'Afwerken', insole_types: ['Kurk', 'Berk', '3D'] },
|
|
];
|
|
|
|
export async function seed(): Promise<void> {
|
|
const existing = await db.select().from(activities).limit(1);
|
|
if (existing.length > 0) {
|
|
console.log('activities already seeded — skipping');
|
|
return;
|
|
}
|
|
await db.insert(activities).values(SEED_ACTIVITIES.map((a) => ({ name: a.name, insoleTypes: a.insole_types })));
|
|
console.log(`seeded ${SEED_ACTIVITIES.length} activities`);
|
|
}
|
|
|
|
import { pathToFileURL } from 'node:url';
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
seed().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
|
}
|
|
```
|
|
|
|
Add `"db:seed": "tsx src/db/seed.ts"` to `apps/api/package.json` scripts. (Export `seed` so route
|
|
tests can also seed programmatically.)
|
|
|
|
- [ ] **Step 5 (test first):** add a `seed` test to `schema.test.ts` (or a new `seed.test.ts`):
|
|
import `{ seed }`, call it, assert `db.select().from(activities)` returns 7 rows with array
|
|
`insole_types`; call `seed()` again and assert it is still 7 (idempotent). Make it green.
|
|
|
|
- [ ] **Step 6:** Full suite green (`corepack yarn workspace @solelog/api test` — now 6 files), typecheck
|
|
clean, then commit:
|
|
`git -C D:/Sven add apps/api packages && git -C D:/Sven commit -m "feat(api): activities & work_sessions tables, migration, seed"`
|
|
|
|
---
|
|
|
|
# Backend Task 3: Auth helper + Activities routes (GET/POST)
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/routes/_auth.ts` (a tiny shared helper)
|
|
- Create: `apps/api/src/routes/activities.ts`
|
|
- Modify: `apps/api/src/app.ts` (mount `activities`)
|
|
- Create: `apps/api/test/activities.test.ts`
|
|
|
|
**Interfaces produced:** `requireUser(c)` helper returning the authenticated user or throwing a `401`
|
|
JSON response (used by every domain route); a Hono `activities` router exposing `GET /api/activities`
|
|
and `POST /api/activities`. Consumes `CreateActivityInput`, `Activity`, `ActivityList` from
|
|
`@solelog/shared` and the `activities` table.
|
|
|
|
- [ ] **Step 1:** Create `apps/api/src/routes/_auth.ts`:
|
|
|
|
```ts
|
|
import type { Context } from 'hono';
|
|
import { HTTPException } from 'hono/http-exception';
|
|
import { auth } from '../auth';
|
|
|
|
export type AuthedUser = { id: string; email: string; name: string };
|
|
|
|
// Resolves the better-auth session from the request (bearer token or cookie),
|
|
// exactly like src/routes/me.ts. Throws a 401 JSON HTTPException when absent —
|
|
// callers wrap their body in try/catch or let Hono's onError surface it.
|
|
export async function requireUser(c: Context): Promise<AuthedUser> {
|
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
if (!session) {
|
|
throw new HTTPException(401, { res: c.json({ error: 'Unauthorized' }, 401) });
|
|
}
|
|
return { id: session.user.id, email: session.user.email, name: session.user.name };
|
|
}
|
|
```
|
|
|
|
> Verify `hono/http-exception` and the `{ res }` option exist in hono 4.12.25. If the installed API
|
|
> differs, fall back to returning a `401` directly from each route (the `me.ts` pattern) instead of
|
|
> throwing — the **installed library is authoritative**. Whichever form is used, the observable contract
|
|
> is: no/invalid token → HTTP 401 `{ "error": "Unauthorized" }`.
|
|
|
|
- [ ] **Step 2 (helpers):** Add a serialiser in `activities.ts` mapping a DB row → the `Activity` wire
|
|
shape (`created_at: row.createdAt.toISOString()`, `insole_types: row.insoleTypes`). Activities are a
|
|
**shared catalogue** (not per-user) in Phase 1 — they are managed in Instellingen and used by all
|
|
workers — so `GET/POST/PUT/DELETE /api/activities` require a valid session (401 without) but are NOT
|
|
filtered by `user_id`. (Per-activity ownership is not in the data model; only `work_sessions` carry
|
|
`user_id`.) The 401-without-token requirement still applies to every activities route.
|
|
|
|
- [ ] **Step 3 (test first):** Write `apps/api/test/activities.test.ts`. Add a shared test helper inline
|
|
(or in `test/helpers.ts`, see Task 5) that signs a user up + in and returns the bearer token:
|
|
|
|
```ts
|
|
async function tokenFor(app, email) {
|
|
const json = { 'content-type': 'application/json' };
|
|
await app.request('/api/auth/sign-up/email', { method: 'POST', headers: json,
|
|
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123', name: email.split('@')[0] }) });
|
|
const signin = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: json,
|
|
body: JSON.stringify({ email, password: 'sterk-wachtwoord-123' }) });
|
|
return signin.headers.get('set-auth-token');
|
|
}
|
|
const authHeaders = (t) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });
|
|
```
|
|
|
|
Tests:
|
|
- `GET /api/activities` **without** a token → `401`.
|
|
- `POST /api/activities` without a token → `401`.
|
|
- With a token, `POST /api/activities` `{ name: 'Leerrand', insole_types: ['Kurk','Berk'] }` → `201`
|
|
(or `200`), body validates against `Activity`, `insole_types` is `['Kurk','Berk']`.
|
|
- `POST` with `{ name: '', insole_types: ['Kurk'] }` → `400`.
|
|
- `POST` with `{ name: 'X', insole_types: [] }` → `400`.
|
|
- `GET /api/activities` with a token returns an array including the created activity, validates against
|
|
`ActivityList`.
|
|
- `GET /api/activities?insole_type=3D` returns only activities whose `insole_types` include `3D`
|
|
(create one `['3D']` and one `['Kurk']`, assert filtering happens in JS).
|
|
|
|
Run — fails (route not mounted).
|
|
|
|
- [ ] **Step 4:** Implement `activities.ts`:
|
|
- `GET /api/activities`: `requireUser(c)`; read optional `c.req.query('insole_type')`, validate it with
|
|
`InsoleType.safeParse` (ignore if invalid/absent); `db.select().from(activities).orderBy(activities.name)`;
|
|
if filter present, `rows.filter(r => r.insole_types.includes(filter))` **in JS**; map to `Activity[]`;
|
|
`c.json(list)`.
|
|
- `POST /api/activities`: `requireUser(c)`; parse body with `CreateActivityInput.safeParse`; on failure
|
|
`c.json({ error: 'Invalid input' }, 400)`; insert `{ name, insoleTypes: insole_types }` with
|
|
`.returning()`; map and `c.json(activity, 201)`.
|
|
|
|
Mount in `app.ts`: `app.route('/', activities);` (after `me`). Re-run until green.
|
|
|
|
- [ ] **Step 5:** Full suite + typecheck green, commit:
|
|
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET/POST /api/activities (user-authed)"`
|
|
|
|
---
|
|
|
|
# Backend Task 4: Activities routes (PUT/DELETE)
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/routes/activities.ts` (add `PUT`/`DELETE /api/activities/:id`)
|
|
- Modify: `apps/api/test/activities.test.ts`
|
|
|
|
**Interfaces:** `PUT /api/activities/:id` (consumes `UpdateActivityInput`, returns `Activity`),
|
|
`DELETE /api/activities/:id` (returns `{ success: true }`).
|
|
|
|
- [ ] **Step 1 (test first):** Add tests:
|
|
- `PUT` without token → `401`; `DELETE` without token → `401`.
|
|
- `PUT /api/activities/:id` with `{ name: 'Slijpen', insole_types: ['3D'] }` updates and returns the
|
|
row (validates `Activity`, name + types changed).
|
|
- `PUT` a non-existent id (e.g. `999999`) → `404 { error: 'Activity not found' }`.
|
|
- `PUT` with empty name → `400`.
|
|
- `DELETE /api/activities/:id` → `200 { success: true }`; a subsequent `GET` no longer lists it.
|
|
- Decide and TEST the cascade choice: deleting an activity that has `work_sessions` — Phase 1 keeps it
|
|
simple and **blocks** deletion when sessions reference it: return `409 { error: 'Activity in use' }`
|
|
if any `work_sessions.activity_id = :id` exists. Add a test: start a session against an activity,
|
|
then `DELETE` it → `409`; the activity still lists. (This avoids destroying a worker's history,
|
|
unlike the legacy cascade. Document the divergence in a comment.)
|
|
|
|
- [ ] **Step 2:** Implement:
|
|
- `PUT`: `requireUser`; `UpdateActivityInput.safeParse` → `400`; `db.update(activities).set({ name,
|
|
insoleTypes }).where(eq(activities.id, id)).returning()`; empty result → `404`; else map + `c.json`.
|
|
Parse `id` with `Number(c.req.param('id'))`; non-numeric → `404`.
|
|
- `DELETE`: `requireUser`; check `db.select().from(workSessions).where(eq(workSessions.activityId,
|
|
id)).limit(1)`; if found → `409`; else `db.delete(activities).where(eq(activities.id, id))`;
|
|
`c.json({ success: true })`.
|
|
|
|
- [ ] **Step 3:** Green + typecheck, commit:
|
|
`git -C D:/Sven commit -am "feat(api): PUT/DELETE /api/activities with in-use guard"`
|
|
|
|
---
|
|
|
|
# Backend Task 5: Sessions lifecycle — start / stop / discard
|
|
|
|
**Files:**
|
|
- Create: `apps/api/test/helpers.ts` (extract `tokenFor`, `authHeaders` for reuse)
|
|
- Create: `apps/api/src/routes/sessions.ts`
|
|
- Modify: `apps/api/src/app.ts` (mount `sessions`)
|
|
- Create: `apps/api/test/sessions.test.ts`
|
|
|
|
**Interfaces produced:** a `sessions` Hono router with
|
|
`POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `POST /api/sessions/:id/discard`.
|
|
Consumes `StartSessionInput`, returns `WorkSession`. A `toWorkSession(row, activityName)` serialiser
|
|
(ISO timestamps, `null`-safe `end_time`/`duration_seconds`).
|
|
|
|
- [ ] **Step 1:** Create `apps/api/test/helpers.ts` exporting `tokenFor(app, email)` and
|
|
`authHeaders(token)` (move them out of `activities.test.ts` and import them there too, keeping that
|
|
suite green).
|
|
|
|
- [ ] **Step 2 (test first):** Write `apps/api/test/sessions.test.ts`. Seed an activity (call `seed()` or
|
|
POST one). Cases:
|
|
- `POST /api/sessions/start` without token → `401`.
|
|
- `POST /api/sessions/start` `{ activity_id, insole_type:'Kurk', pair_count:3 }` → `201`, validates
|
|
`WorkSession`: `status:'active'`, `end_time:null`, `duration_seconds:null`, `source:'app'`,
|
|
`pair_count:3`, `activity_name` set, `user_id` = caller.
|
|
- `POST /api/sessions/start` with `pair_count` omitted defaults to `2`.
|
|
- `POST /api/sessions/start` with an `activity_id` that does not exist → `404
|
|
{ error: 'Activity not found' }`.
|
|
- `POST /api/sessions/start` with `insole_type:'Kurk'` but the activity does NOT support `'Kurk'` →
|
|
`400 { error: 'Insole type not valid for activity' }` (create an activity `['3D']`, start with
|
|
`'Kurk'`). Add a test.
|
|
- **Stop lifecycle:** start a session, then `POST /api/sessions/:id/stop` → `200`,
|
|
`status:'completed'`, `end_time` is a non-null ISO string, `duration_seconds` is an integer `>= 0`.
|
|
Because start/stop happen within the same test tick, assert `duration_seconds >= 0` (not strictly
|
|
positive) and that `end_time >= start_time`.
|
|
- **Stop is owner-scoped:** user A starts a session; user B (`tokenFor(app,'b@…')`) calls
|
|
`POST /api/sessions/:idOfA/stop` → `404` (B must not learn A's session exists; treat
|
|
not-owned == not-found). The session stays `active` for A.
|
|
- **Double-stop rejected:** stop a session, stop it again → `409 { error: 'Session already closed' }`.
|
|
- **Discard:** start a session, `POST /api/sessions/:id/discard` → `200`, `status:'discarded'`.
|
|
Discarding a non-owned session → `404`. Discarding an already-closed session → `409`.
|
|
- `:id` non-numeric or unknown → `404`.
|
|
|
|
- [ ] **Step 3:** Implement `sessions.ts`:
|
|
- `start`: `requireUser`; `StartSessionInput.safeParse(body)` → `400`; load the activity by id, `404`
|
|
if missing; if `!activity.insole_types.includes(input.insole_type)` → `400`; insert
|
|
`{ userId, activityId, insoleType, pairCount, startTime: new Date(), status:'active',
|
|
source:'app' }` with `.returning()`; `c.json(toWorkSession(row, activity.name), 201)`.
|
|
- `stop`: `requireUser`; parse id; load the row `where(and(eq(id), eq(userId, user.id)))`; missing →
|
|
`404`; if `row.status !== 'active' || row.endTime !== null` → `409`; compute `end = new Date()`,
|
|
`duration = Math.max(0, Math.round((end.getTime() - row.startTime.getTime()) / 1000))`;
|
|
`db.update(workSessions).set({ endTime: end, durationSeconds: duration, status: 'completed' })
|
|
.where(eq(workSessions.id, id)).returning()`; join the activity name; `c.json(...)`.
|
|
- `discard`: `requireUser`; same ownership load; `409` if not `active`; set `status:'discarded'`
|
|
(leave `end_time`/`duration` null — it was thrown away); return the row.
|
|
- Use `import { and, eq } from 'drizzle-orm'`. For the activity name, either a join or a second select
|
|
on `activities` by `activityId`. Mount `app.route('/', sessions)`.
|
|
|
|
- [ ] **Step 4:** Green + typecheck, commit:
|
|
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): work-session start/stop/discard (owner-scoped, server-authoritative duration)"`
|
|
|
|
---
|
|
|
|
# Backend Task 6: Sessions reads — history + active recovery
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/routes/sessions.ts` (add `GET /api/sessions`, `GET /api/sessions/active`)
|
|
- Modify: `apps/api/test/sessions.test.ts`
|
|
|
|
> **Routing order matters:** register `GET /api/sessions/active` BEFORE any `/:id`-style route so
|
|
> `active` is not captured as an id. (The lifecycle routes are all under `/api/sessions/:id/<verb>`, so
|
|
> there is no direct conflict, but keep `active` explicit and first among GETs.)
|
|
|
|
**Interfaces:** `GET /api/sessions` → `WorkSessionList` (caller's sessions, all statuses, newest first by
|
|
`start_time`, each with `activity_name`). `GET /api/sessions/active` → `WorkSessionList` (the caller's
|
|
`status:'active'` sessions only, newest first) for crash/recovery on app launch.
|
|
|
|
- [ ] **Step 1 (test first):** Add tests:
|
|
- `GET /api/sessions` without token → `401`. `GET /api/sessions/active` without token → `401`.
|
|
- **Ownership scoping:** A starts+stops one session and starts a second (active); B starts one. `GET
|
|
/api/sessions` as A returns exactly A's 2 sessions (and none of B's); validates `WorkSessionList`;
|
|
ordered newest-first by `start_time` (assert the active/newer one is `[0]`).
|
|
- `GET /api/sessions/active` as A returns exactly the 1 active session; after stopping it, returns `[]`.
|
|
- Each returned item has `activity_name` populated (join correctness).
|
|
|
|
- [ ] **Step 2:** Implement:
|
|
- `GET /api/sessions`: `requireUser`; select `work_sessions` joined to `activities`
|
|
`where(eq(workSessions.userId, user.id)).orderBy(desc(workSessions.startTime))`; map → `WorkSession[]`.
|
|
- `GET /api/sessions/active`: same but `and(eq(userId), eq(status, 'active'))`.
|
|
- Use `import { desc } from 'drizzle-orm'`. Prefer a Drizzle `innerJoin(activities, eq(...))` selecting
|
|
explicit columns + `activities.name` so the serialiser has the name without a second query.
|
|
|
|
- [ ] **Step 3:** Green + typecheck, commit:
|
|
`git -C D:/Sven commit -am "feat(api): GET /api/sessions history + /api/sessions/active recovery"`
|
|
|
|
---
|
|
|
|
# Backend Task 7: CSV export
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/routes/export.ts`
|
|
- Modify: `apps/api/src/app.ts` (mount `export`)
|
|
- Create: `apps/api/test/export.test.ts`
|
|
|
|
**Interfaces:** `GET /api/export` → `text/csv` of the **caller's completed** sessions, ordered
|
|
`start_time ASC`. Header row (exact):
|
|
`"Activity","Insole Type","Pair Count","Start","End","Duration (s)"`. Each data cell quoted with `"`,
|
|
embedded `"` doubled; rows joined with `\n`. `Start`/`End` are ISO-8601 UTC strings; `Duration (s)` is
|
|
`duration_seconds`. Response headers: `Content-Type: text/csv; charset=utf-8`,
|
|
`Content-Disposition: attachment; filename="insole-production-report.csv"`.
|
|
|
|
- [ ] **Step 1 (test first):** Write `apps/api/test/export.test.ts`:
|
|
- `GET /api/export` without token → `401`.
|
|
- As user A: start+stop two sessions on a seeded activity (and start one active session that must NOT
|
|
appear). `GET /api/export` → `200`; `Content-Type` includes `text/csv`; `Content-Disposition`
|
|
contains `insole-production-report.csv`. Parse the body: first line equals the exact header above;
|
|
there are exactly 2 data rows (active one excluded); each row has 6 quoted fields; the `Activity`
|
|
cell equals the activity name; `Duration (s)` is the integer string. Ordered ascending by start.
|
|
- **Ownership:** B's completed sessions never appear in A's export (start+stop one as B; A's export
|
|
still has only its 2 rows).
|
|
- A cell-quoting test: create an activity whose name contains a `"` (e.g. `He"llo`), stop a session on
|
|
it, assert the CSV contains `"He""llo"`.
|
|
|
|
- [ ] **Step 2:** Implement `export.ts`:
|
|
- `requireUser`; select completed sessions joined to activity name
|
|
`where(and(eq(userId, user.id), eq(status, 'completed'))).orderBy(asc(workSessions.startTime))`.
|
|
- `const quote = (v: unknown) => '"' + String(v ?? '').replace(/"/g, '""') + '"';`
|
|
- Header: `['Activity','Insole Type','Pair Count','Start','End','Duration (s)'].map(quote).join(',')`.
|
|
- Each row: `[activityName, insoleType, pairCount, startTime.toISOString(),
|
|
endTime?.toISOString() ?? '', durationSeconds ?? ''].map(quote).join(',')`.
|
|
- Body = `[header, ...rows].join('\n')`. Return via `c.body(csv, 200, { 'Content-Type': 'text/csv;
|
|
charset=utf-8', 'Content-Disposition': 'attachment; filename="insole-production-report.csv"' })`
|
|
(verify the `c.body(...)` header-object signature against hono 4.12.25; otherwise build a `Response`
|
|
with `new Response(csv, { headers })`).
|
|
|
|
- [ ] **Step 3:** Green + typecheck, commit:
|
|
`git -C D:/Sven add apps/api && git -C D:/Sven commit -m "feat(api): GET /api/export CSV of completed sessions (owner-scoped)"`
|
|
|
|
---
|
|
|
|
# Backend Task 8: CORS + trusted-origins consistency
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/env.ts` (add `CORS_ORIGINS`)
|
|
- Modify: `apps/api/src/app.ts` (apply `hono/cors`)
|
|
- Modify: `apps/api/src/auth.ts` (derive `trustedOrigins` from the SAME env) — *adding to the
|
|
trustedOrigins array is allowed; do NOT change any other auth option*
|
|
- Modify: `apps/api/.env.example` and `docker-compose.yml` (document `CORS_ORIGINS`)
|
|
- Create: `apps/api/test/cors.test.ts`
|
|
|
|
**Interfaces:** `env.CORS_ORIGINS: string[]` (parsed from a comma-separated env var, defaulting to the
|
|
local dev origins). CORS middleware allows those origins; better-auth `trustedOrigins` includes the
|
|
same list so a browser client passes CSRF/origin checks.
|
|
|
|
- [ ] **Step 1:** In `env.ts` add:
|
|
|
|
```ts
|
|
CORS_ORIGINS: (process.env.CORS_ORIGINS ?? 'http://localhost:8081,http://localhost:19006,http://localhost:3000')
|
|
.split(',').map((s) => s.trim()).filter(Boolean),
|
|
```
|
|
|
|
(`8081` = Expo web/Metro default; `19006` = legacy Expo web; `3000` = the API's own origin.)
|
|
|
|
- [ ] **Step 2:** In `auth.ts`, change `trustedOrigins` to merge the existing values with
|
|
`env.CORS_ORIGINS` (dedup). Do NOT alter `secret`, `database`, `emailAndPassword`, or `plugins`.
|
|
Example: `trustedOrigins: Array.from(new Set([env.BETTER_AUTH_URL, 'http://localhost:3000',
|
|
...env.CORS_ORIGINS]))`.
|
|
|
|
- [ ] **Step 3 (test first):** Write `apps/api/test/cors.test.ts`:
|
|
- A browser **preflight**: `OPTIONS /api/activities` with headers `Origin: http://localhost:8081`,
|
|
`Access-Control-Request-Method: GET`, `Access-Control-Request-Headers: authorization` →
|
|
response has `Access-Control-Allow-Origin: http://localhost:8081` and the allow-headers list
|
|
includes `authorization` (case-insensitive check).
|
|
- A real `GET /api/activities` (no token) with `Origin: http://localhost:8081` still returns `401`
|
|
AND carries `Access-Control-Allow-Origin` (CORS headers present even on error responses).
|
|
- An origin NOT in the list (`http://evil.example`) does not get an `Access-Control-Allow-Origin:
|
|
http://evil.example` echo. (Assert the header is absent or not equal to the evil origin, per the
|
|
installed `hono/cors` behaviour — adapt the assertion to what the middleware actually does.)
|
|
|
|
- [ ] **Step 4:** In `app.ts`, apply CORS **before** the routes:
|
|
|
|
```ts
|
|
import { cors } from 'hono/cors';
|
|
// inside createApp(), first:
|
|
app.use('*', cors({
|
|
origin: env.CORS_ORIGINS,
|
|
allowHeaders: ['Authorization', 'Content-Type'],
|
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
exposeHeaders: ['set-auth-token'],
|
|
}));
|
|
```
|
|
|
|
> Bearer-only auth means `credentials: true` is NOT required. Verify the `origin` option accepts a
|
|
> string array in hono 4.12.25 (it does; otherwise pass a function that returns the origin when it is in
|
|
> `env.CORS_ORIGINS`). The **installed middleware is authoritative** — shape the config + the test
|
|
> assertions to its real behaviour.
|
|
|
|
- [ ] **Step 5:** Update `.env.example` (add `CORS_ORIGINS=http://localhost:8081,http://localhost:3000`)
|
|
and add the same env to `docker-compose.yml`. Full suite green, typecheck clean, commit:
|
|
`git -C D:/Sven add apps/api docker-compose.yml && git -C D:/Sven commit -m "feat(api): CORS for browser clients, trustedOrigins consistency"`
|
|
|
|
---
|
|
|
|
# Backend Task 9: Backend wrap-up — full green + manual smoke
|
|
|
|
**Files:** none (verification only) — optionally a short `apps/api/README.md` note.
|
|
|
|
- [ ] **Step 1:** From `apps/api`: `corepack yarn test` (ALL suites: the original 4 files + the new
|
|
`schema`, `seed`, `activities`, `sessions`, `export`, `cors`), `corepack yarn typecheck`,
|
|
`npx oxlint` (root config). All must pass. Record the real counts.
|
|
- [ ] **Step 2 (manual smoke, real commands):** `rm -rf apps/api/data` then from `apps/api`:
|
|
`corepack yarn db:migrate && corepack yarn db:seed && corepack yarn start`. With `curl` (or
|
|
PowerShell `Invoke-RestMethod`): sign up, sign in (capture `set-auth-token`), then `GET
|
|
/api/activities`, `POST /api/sessions/start`, `POST /api/sessions/:id/stop`, `GET /api/sessions`,
|
|
`GET /api/export` — confirm each works with the bearer header and `401` without. Do not commit the
|
|
local `data/` DB (it is gitignored).
|
|
- [ ] **Step 3:** Commit any doc note only:
|
|
`git -C D:/Sven commit -am "docs(api): Phase 1 backend smoke notes"` (skip if nothing changed).
|
|
|
|
---
|
|
|
|
# Mobile Task 1: Scaffold the fresh Expo app + workspace wiring
|
|
|
|
**Files (create):**
|
|
- `apps/mobile/package.json`, `apps/mobile/app.json`, `apps/mobile/tsconfig.json`,
|
|
`apps/mobile/babel.config.js`, `apps/mobile/.env.example`, `apps/mobile/.gitignore`,
|
|
`apps/mobile/index.ts` (Expo Router entry), `apps/mobile/src/app/_layout.tsx` (placeholder),
|
|
`apps/mobile/metro.config.js` (default, monorepo-aware), `apps/mobile/jest.config.js`,
|
|
`apps/mobile/jest.setup.ts`.
|
|
|
|
**Interfaces produced:** the `@solelog/mobile` workspace, runnable with `npx expo start` /
|
|
`--web`, typechecking with `tsc --noEmit`, and a jest-expo harness that runs.
|
|
|
|
- [ ] **Step 1:** Create `apps/mobile/package.json`. Use Expo Router's standard entry. Minimal deps ONLY:
|
|
|
|
```json
|
|
{
|
|
"name": "@solelog/mobile",
|
|
"version": "0.0.0",
|
|
"private": true,
|
|
"main": "expo-router/entry",
|
|
"scripts": {
|
|
"start": "expo start",
|
|
"web": "expo start --web",
|
|
"android": "expo start --android",
|
|
"ios": "expo start --ios",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "jest"
|
|
},
|
|
"dependencies": {
|
|
"@solelog/shared": "workspace:*",
|
|
"@tanstack/react-query": "^5.0.0",
|
|
"expo": "*",
|
|
"expo-router": "*",
|
|
"expo-secure-store": "*",
|
|
"expo-status-bar": "*",
|
|
"react": "*",
|
|
"react-dom": "*",
|
|
"react-native": "*",
|
|
"react-native-web": "*"
|
|
},
|
|
"devDependencies": {
|
|
"@testing-library/react-native": "*",
|
|
"@types/jest": "*",
|
|
"@types/react": "*",
|
|
"jest": "*",
|
|
"jest-expo": "*",
|
|
"react-test-renderer": "*",
|
|
"typescript": "*"
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Install correctly with Expo's resolver** so versions match the SDK: from `apps/mobile`, prefer
|
|
> `npx create-expo-app@latest . --template blank-typescript` into a temp dir to learn the exact pinned
|
|
> versions, OR run `npx expo install expo-router react-native-web react-dom expo-secure-store
|
|
> expo-status-bar` and `npx expo install --dev jest-expo`, which writes SDK-correct versions. Then PIN
|
|
> the resolved exact versions (no `^`/`*`) per the repo convention, add `@solelog/shared` and
|
|
> `@tanstack/react-query`, and run `corepack yarn install` from the repo root so the workspace links.
|
|
> The `*` above are placeholders to be replaced by the resolved exact versions — **do not ship `*`**.
|
|
|
|
- [ ] **Step 2:** `apps/mobile/app.json` — minimal Expo config with the router plugin and web bundler:
|
|
|
|
```json
|
|
{
|
|
"expo": {
|
|
"name": "SoleLog",
|
|
"slug": "solelog",
|
|
"scheme": "solelog",
|
|
"version": "1.0.0",
|
|
"orientation": "portrait",
|
|
"userInterfaceStyle": "light",
|
|
"newArchEnabled": true,
|
|
"web": { "bundler": "metro", "output": "single" },
|
|
"plugins": ["expo-router"]
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3:** `apps/mobile/tsconfig.json` extends `expo/tsconfig.base`, `strict: true`, and a
|
|
`@/*` → `src/*` path alias:
|
|
|
|
```json
|
|
{
|
|
"extends": "expo/tsconfig.base",
|
|
"compilerOptions": {
|
|
"strict": true,
|
|
"paths": { "@/*": ["./src/*"] }
|
|
},
|
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4:** `apps/mobile/babel.config.js`: `module.exports = (api) => { api.cache(true); return {
|
|
presets: ['babel-preset-expo'] }; };`. `metro.config.js`: default Expo config made monorepo-aware
|
|
(watch the repo root, resolve `node_modules` from both app and root) so `@solelog/shared` resolves:
|
|
|
|
```js
|
|
const { getDefaultConfig } = require('expo/metro-config');
|
|
const path = require('path');
|
|
const projectRoot = __dirname;
|
|
const workspaceRoot = path.resolve(projectRoot, '../..');
|
|
const config = getDefaultConfig(projectRoot);
|
|
config.watchFolders = [workspaceRoot];
|
|
config.resolver.nodeModulesPaths = [
|
|
path.resolve(projectRoot, 'node_modules'),
|
|
path.resolve(workspaceRoot, 'node_modules'),
|
|
];
|
|
module.exports = config;
|
|
```
|
|
|
|
- [ ] **Step 5:** `apps/mobile/jest.config.js` (`preset: 'jest-expo'`,
|
|
`setupFilesAfterEnv: ['<rootDir>/jest.setup.ts']`, a `transformIgnorePatterns` that lets
|
|
`@solelog/shared`, expo, react-native, `@tanstack` transform). `jest.setup.ts` imports
|
|
`@testing-library/react-native` matchers if needed. Add a trivial smoke test
|
|
`apps/mobile/src/__tests__/smoke.test.ts` (`expect(1 + 1).toBe(2)`) and run `corepack yarn workspace
|
|
@solelog/mobile test` to prove the harness runs.
|
|
|
|
- [ ] **Step 6:** Placeholder `src/app/_layout.tsx` rendering a `<Stack />` (expo-router) wrapped in a
|
|
React Query provider (defined in Mobile Task 2) — for now a bare `<Stack />` so `expo start` boots.
|
|
`.env.example`: `EXPO_PUBLIC_BASE_URL=http://localhost:3000` plus a commented LAN example
|
|
`# EXPO_PUBLIC_BASE_URL=http://192.168.1.50:3000`. `.gitignore`: `.expo/`, `node_modules/`, `dist/`,
|
|
`*.log`, `.env`.
|
|
|
|
- [ ] **Step 7:** Verify: from `apps/mobile`, `corepack yarn typecheck` clean and `corepack yarn test`
|
|
green. (Do not block the plan on launching a simulator; `expo start --web` is exercised in Mobile
|
|
Task 6.) Commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): scaffold fresh @solelog/mobile Expo Router app + workspace wiring"`
|
|
|
|
---
|
|
|
|
# Mobile Task 2: Token storage + typed API client + React Query provider
|
|
|
|
**Files (create):**
|
|
- `apps/mobile/src/lib/tokenStore.ts`, `apps/mobile/src/lib/api.ts`,
|
|
`apps/mobile/src/lib/queryClient.tsx`
|
|
- `apps/mobile/src/lib/__tests__/api.test.ts`, `apps/mobile/src/lib/__tests__/tokenStore.test.ts`
|
|
|
|
**Interfaces produced:**
|
|
- `tokenStore`: `getToken(): Promise<string|null>`, `setToken(t: string): Promise<void>`,
|
|
`clearToken(): Promise<void>` — backed by `expo-secure-store` (`SecureStore.getItemAsync` etc.) on
|
|
native; on web SecureStore falls back to localStorage via Expo's own web implementation in SDK 54
|
|
(no custom shim needed — verify; if SecureStore is unavailable on web in the installed SDK, guard with
|
|
`SecureStore.isAvailableAsync()` and fall back to `localStorage` inside `tokenStore`).
|
|
- `api`: a typed client. `baseUrl = process.env.EXPO_PUBLIC_BASE_URL ?? 'http://localhost:3000'`.
|
|
Methods (each attaches `Authorization: Bearer <token>` when a token is stored, sets
|
|
`Content-Type: application/json` for bodies, parses JSON, throws `ApiError { status, message }` on
|
|
non-2xx):
|
|
- `signUp(email, password)`, `signIn(email, password)` → on success reads the **`set-auth-token`**
|
|
response header and `setToken`s it; returns the user.
|
|
- `getActivities(insoleType?)`, `createActivity(input)`, `updateActivity(id, input)`,
|
|
`deleteActivity(id)`.
|
|
- `startSession(input)`, `stopSession(id)`, `discardSession(id)`, `getSessions()`,
|
|
`getActiveSessions()`.
|
|
- `exportUrl()` → returns the `${baseUrl}/api/export` string (the screen opens/shares it).
|
|
- Return types use `@solelog/shared` (`Activity`, `WorkSession`, etc.); validate responses with the
|
|
zod schemas where cheap (at least `WorkSession.parse` on session mutations) so contract drift fails
|
|
loudly in dev.
|
|
|
|
- [ ] **Step 1 (test first):** `tokenStore.test.ts` — mock `expo-secure-store` (jest
|
|
`jest.mock('expo-secure-store')`) and assert `setToken`/`getToken`/`clearToken` call the right
|
|
SecureStore methods with a stable key (`'solelog-auth-token'`). Implement `tokenStore.ts`. Green.
|
|
|
|
- [ ] **Step 2 (test first):** `api.test.ts` — mock `global.fetch`. Assert:
|
|
- `signIn` POSTs to `${baseUrl}/api/auth/sign-in/email` with the email/password body, reads the
|
|
`set-auth-token` header from the mocked response, and calls `tokenStore.setToken` with it
|
|
(mock `tokenStore`).
|
|
- `getActivities()` GETs `${baseUrl}/api/activities` with `Authorization: Bearer <stored token>` when a
|
|
token is present (mock `getToken` → `'tok'`), and WITHOUT the header when no token.
|
|
- `getActivities('3D')` appends `?insole_type=3D`.
|
|
- `startSession({activity_id:1,insole_type:'Kurk',pair_count:2})` POSTs to `/api/sessions/start` and
|
|
returns a parsed `WorkSession` (feed a valid fixture from the mock).
|
|
- A non-2xx response throws `ApiError` with the right `status`.
|
|
Implement `api.ts`. Green.
|
|
|
|
- [ ] **Step 3:** `queryClient.tsx` exports a `QueryClient` (defaults: `staleTime` 5 min, `retry` 1,
|
|
`refetchOnWindowFocus: false` — matching the legacy app) and an `<AppQueryProvider>` wrapping
|
|
`QueryClientProvider`. Wire it into `src/app/_layout.tsx`. (No new test needed; covered by the screen
|
|
render smoke test in Mobile Task 6.)
|
|
|
|
- [ ] **Step 4:** Typecheck + test green, commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): secure token store + typed API client + query provider"`
|
|
|
|
---
|
|
|
|
# Mobile Task 3: Auth gate + login/sign-up screen
|
|
|
|
**Files (create):**
|
|
- `apps/mobile/src/lib/auth.tsx` (an `AuthProvider` + `useAuth()` hook: `token`, `isReady`, `signIn`,
|
|
`signUp`, `signOut`)
|
|
- `apps/mobile/src/app/login.tsx` (the login/sign-up screen, Dutch)
|
|
- Modify: `apps/mobile/src/app/_layout.tsx` (gate: while `!isReady` render `null`/splash; if no token
|
|
redirect to `/login`; else render the tabs)
|
|
- `apps/mobile/src/app/(tabs)/_layout.tsx` (3-tab navigator placeholder so routing exists)
|
|
- `apps/mobile/src/lib/__tests__/auth.test.tsx`
|
|
|
|
**Interfaces produced:** `useAuth()` context. On mount it calls `tokenStore.getToken()` to restore the
|
|
session (with a timeout escape hatch so a stuck read can't freeze launch — lesson from
|
|
`docs/reference/legacy-lessons-and-gotchas.md` §1), sets `isReady`. `signIn`/`signUp` delegate to
|
|
`api`, store the token, set `token` in state; `signOut` clears it.
|
|
|
|
- [ ] **Step 1 (test first):** `auth.test.tsx` — render a component using `useAuth()` inside
|
|
`<AuthProvider>` with `api`/`tokenStore` mocked. Assert: starts `isReady:false`, becomes `isReady:true`
|
|
with `token` from `getToken`; calling `signIn` sets `token`; `signOut` clears it. Implement
|
|
`auth.tsx`. Green.
|
|
|
|
- [ ] **Step 2:** Build `login.tsx` (RN `StyleSheet`, no extra libs): email `TextInput`
|
|
(`keyboardType:'email-address'`, `autoCapitalize:'none'`), password `TextInput` (`secureTextEntry`),
|
|
a primary button **`Inloggen`** ("Sign in") calling `signIn`, and a small text affordance
|
|
**`Account aanmaken`** ("Create account") that toggles to sign-up (button becomes **`Registreren`**).
|
|
On error show the message (Dutch fallback **`Inloggen mislukt`** / **`Registreren mislukt`**). On
|
|
success the auth state flips and the gate routes into the tabs. Use the blue palette from
|
|
`docs/reference/legacy-mobile-app.md` §2 for consistency.
|
|
|
|
- [ ] **Step 3:** `_layout.tsx` gate + `(tabs)/_layout.tsx` (expo-router `Tabs` with three screens in
|
|
order — `index` titled **`Stopwatch`**, `history` titled **`Geschiedenis`**, `tasks` titled
|
|
**`Instellingen`**; tab tint `#2563EB`/`#6B7280`). Tabs may use simple text labels (NO `lucide`/icon
|
|
libraries — keep deps minimal; an emoji or omitted icon is fine). Add temporary stub screens for the
|
|
three tabs so navigation compiles.
|
|
|
|
- [ ] **Step 4:** Typecheck + test green, commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): auth gate + Dutch login/sign-up screen + 3-tab shell"`
|
|
|
|
---
|
|
|
|
# Mobile Task 4: Stopwatch screen (`(tabs)/index.tsx`)
|
|
|
|
**Files (create/modify):**
|
|
- `apps/mobile/src/lib/timer.ts` (pure timing helpers — unit-testable, no React)
|
|
- `apps/mobile/src/app/(tabs)/index.tsx` (the Stopwatch screen)
|
|
- `apps/mobile/src/lib/__tests__/timer.test.ts`
|
|
|
|
**Behaviour (from `docs/reference/legacy-mobile-app.md` §4), adapted to server-authoritative sessions:**
|
|
- `Type zool` segmented selector (`Kurk`/`Berk`/`3D`, default `Kurk`); changing it clears the chosen
|
|
activity. `Type handeling` dropdown listing activities filtered to the chosen zool
|
|
(`activity.insole_types.includes(insoleType)`); placeholder **`Kies een handeling...`**; empty-state
|
|
**`Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen.`** `Aantal zolen`
|
|
stepper (default `2`, min `1`). All three selectors lock while a session is active.
|
|
- Activities fetched via React Query `['activities']` → `api.getActivities()`.
|
|
- **Server-authoritative lifecycle:**
|
|
- Start (enabled only when an activity is chosen): `api.startSession({ activity_id, insole_type,
|
|
pair_count })`; store the returned `WorkSession` (its `id`, `start_time`) as the active session.
|
|
- The on-screen timer is **display-only**, computed by wall-clock delta from the server `start_time`
|
|
(`elapsed = now - new Date(session.start_time)`), updated every second via `setInterval`. Pause/resume
|
|
only freezes the *display* (do NOT call the server; Phase 1 has no server pause). Use
|
|
`timer.formatHMS(seconds)`.
|
|
- **Stop & Opslaan**: `api.stopSession(activeSession.id)`; on success invalidate `['sessions']` +
|
|
`['activeSessions']`, reset the timer (keep the selections, like legacy).
|
|
- **Annuleren** double-press discard (3 s arm window, **`Nogmaals tikken ter bevestiging`** when
|
|
armed): second tap calls `api.discardSession(activeSession.id)` and resets.
|
|
- **Recovery on launch:** `useQuery(['activeSessions'], api.getActiveSessions)`; if it returns a session,
|
|
adopt it as the active session and resume the display timer from its `start_time` (so a phone restart
|
|
doesn't lose an open session). Dutch strings exactly per the reference §7 inventory.
|
|
|
|
- [ ] **Step 1 (test first):** `timer.test.ts` for pure helpers in `timer.ts`:
|
|
- `formatHMS(0) === '00:00:00'`, `formatHMS(65) === '00:01:05'`, `formatHMS(3661) === '01:01:01'`,
|
|
`formatHMS(360000) === '100:00:00'` (hours can exceed 99).
|
|
- `elapsedSeconds(startISO, nowMs)` returns whole seconds between an ISO start and a `now` epoch-ms,
|
|
floored, never negative (clamp to 0 if `now < start`). Test a 65 000 ms gap → `65`; a negative gap
|
|
→ `0`. Implement `timer.ts`. Green.
|
|
|
|
- [ ] **Step 2:** Build `index.tsx` using `timer.ts`, the API client, and React Query. State machine and
|
|
Dutch strings per §4/§4.9 of the reference, but every start/stop/discard is a server call as above. No
|
|
extra libraries (RN `Modal` for the picker sheet is fine; no animation lib required — a simple modal
|
|
list is acceptable for Phase 1).
|
|
|
|
- [ ] **Step 3:** Typecheck + `timer.test.ts` green, commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): server-authoritative Stopwatch screen + timing helpers"`
|
|
|
|
---
|
|
|
|
# Mobile Task 5: Geschiedenis (history) + Instellingen (settings) screens
|
|
|
|
**Files (create/modify):**
|
|
- `apps/mobile/src/app/(tabs)/history.tsx`
|
|
- `apps/mobile/src/app/(tabs)/tasks.tsx`
|
|
- `apps/mobile/src/lib/format.ts` (`formatDuration`, `formatDate`, `formatTime`, `pluralInsoles`) +
|
|
`apps/mobile/src/lib/__tests__/format.test.ts`
|
|
|
|
**History (`docs/reference/legacy-mobile-app.md` §5):** header **`Geschiedenis`** + an **`Exporteer
|
|
CSV`** action; list via React Query `['sessions']` → `api.getSessions()`. Each card: `activity_name`,
|
|
date/time line, badges for `insole_type`, pair count (**`inlegzool`**/**`inlegzolen`** singular/plural),
|
|
and `formatDuration(duration_seconds)` (`Xh Ym` / `Ym Zs` / `Zs`). Empty-state **`Nog geen opgeslagen
|
|
sessies.`** The CSV action opens `api.exportUrl()` — on web, open in a new tab / trigger download
|
|
(`window.open` / an `<a download>`); on native, use `Linking.openURL` (RN core `Linking`, no extra dep).
|
|
Failure alert title **`Fout`**, body **`Kan de export-URL niet openen`**. **Auth note:** the legacy
|
|
`/api/export` open used a plain URL with no header; the new export is bearer-protected, so on **native**
|
|
prefer fetching with the token and sharing the text, or append the token — simplest Phase 1 path: fetch
|
|
the CSV via `api` (with the bearer header) and write/share it; on **web**, fetch with the header and
|
|
trigger a Blob download. Document this divergence in a code comment (a bare `openURL` to a protected
|
|
endpoint would 401).
|
|
|
|
**Settings (`docs/reference/legacy-mobile-app.md` §6):** header **`Instellingen`** + subtitle **`Beheer
|
|
handelingen per zooltype`**. "Add new handling" card (**`Nieuwe handeling toevoegen`**, name placeholder
|
|
**`Naam van de stap, bijv. Leerrand`**, **`Van toepassing op`** three type toggles default all three,
|
|
**`Stap toevoegen`** button). List **`Huidige stappen ({n})`** with edit (**`Opslaan`**/**`Annuleren`**)
|
|
and delete (confirm alert title **`Taak verwijderen`**, body per §6.5, buttons **`Annuleren`** /
|
|
**`Verwijderen`**). Wired to `api.createActivity`/`updateActivity`/`deleteActivity`, React Query key
|
|
`['activities']`. **Delete divergence:** the backend returns `409` when the activity is in use (Backend
|
|
Task 4) — surface that as an alert (Dutch **`Kan niet verwijderen: handeling is in gebruik.`**) instead
|
|
of assuming a cascade. Keep this screen minimal.
|
|
|
|
- [ ] **Step 1 (test first):** `format.test.ts`:
|
|
- `formatDuration(45) === '45s'`, `formatDuration(200) === '3m 20s'`, `formatDuration(3900) === '1h 5m'`.
|
|
- `pluralInsoles(1) === '1 inlegzool'`, `pluralInsoles(2) === '2 inlegzolen'`.
|
|
- `formatDate`/`formatTime` return non-empty strings for a known ISO input (locale-tolerant: assert
|
|
they are strings of length > 0, not exact locale formatting). Implement `format.ts`. Green.
|
|
|
|
- [ ] **Step 2:** Build `history.tsx` and `tasks.tsx` per the references, using `format.ts`, `api`, and
|
|
React Query. Dutch strings exact per the §7 inventory.
|
|
|
|
- [ ] **Step 3:** Typecheck + `format.test.ts` green, commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "feat(mobile): Geschiedenis + Instellingen screens + format helpers"`
|
|
|
|
---
|
|
|
|
# Mobile Task 6: Component render smoke test + web/device run verification
|
|
|
|
**Files (create):**
|
|
- `apps/mobile/src/app/__tests__/login.render.test.tsx` (component render smoke test)
|
|
- optionally `apps/mobile/src/app/(tabs)/__tests__/history.render.test.tsx`
|
|
|
|
- [ ] **Step 1 (test first):** A jest-expo + `@testing-library/react-native` render test mounting the
|
|
`login.tsx` screen (with `api`/`auth` mocked) and asserting the Dutch strings render: e.g.
|
|
`getByText('Inloggen')` and the email/password inputs are present. (At least ONE component render smoke
|
|
test is required; add the history one if time permits, mocking `api.getSessions` to return a fixture
|
|
array and asserting an `activity_name` renders, plus the empty-state string for `[]`.)
|
|
|
|
- [ ] **Step 2:** Make the render test(s) pass (fix providers/mocks as needed — wrap in
|
|
`AppQueryProvider` + `AuthProvider` as required). `corepack yarn workspace @solelog/mobile test` and
|
|
`corepack yarn workspace @solelog/mobile typecheck` both green.
|
|
|
|
- [ ] **Step 3 (manual run verification, real commands):**
|
|
- Start the backend (`apps/api`: `corepack yarn db:migrate && corepack yarn db:seed && corepack yarn
|
|
start`).
|
|
- **Web target:** from `apps/mobile`, `EXPO_PUBLIC_BASE_URL=http://localhost:3000` then
|
|
`npx expo start --web`; in the browser: sign up, see seeded activities in Instellingen and the
|
|
Stopwatch handling picker, start → stop a session, see it in Geschiedenis, export CSV. Confirm CORS
|
|
lets the browser call `:3000` from the Expo web origin.
|
|
- **Device target:** set `EXPO_PUBLIC_BASE_URL=http://<PC-LAN-IP>:3000` in `apps/mobile/.env`,
|
|
`npx expo start`, open in Expo Go over the same LAN, repeat the round-trip. (Backend already binds
|
|
all interfaces via `@hono/node-server`; ensure the host firewall allows `:3000`.)
|
|
- Record outcomes in the commit message / session notes. Do not fabricate; if a step fails, debug it
|
|
(`superpowers:systematic-debugging`) before claiming done.
|
|
|
|
- [ ] **Step 4:** Commit:
|
|
`git -C D:/Sven add apps/mobile && git -C D:/Sven commit -m "test(mobile): login/history render smoke tests + web/device run verified"`
|
|
|
|
---
|
|
|
|
# Phase 1 Definition of Done
|
|
|
|
- Backend: all original tests still pass **plus** new suites (`schema`/`seed`, `activities`, `sessions`,
|
|
`export`, `cors`) — every endpoint covered for: 401 without token, ownership scoping (user A cannot
|
|
see/stop user B's session), start→stop lifecycle (server-computed `duration_seconds`), discard, and CSV
|
|
output. `tsc --noEmit` clean; `npx oxlint` clean. `drizzle-orm`/`drizzle-kit` unchanged; auth tables
|
|
untouched; one NEW migration (`0001`).
|
|
- Mobile: `@solelog/mobile` runs on Expo web and on a device via Expo Go; logs in against the backend and
|
|
attaches the bearer token; the three Dutch screens work against the live API; jest-expo unit tests
|
|
(api client, timer logic, format helpers, token store, auth) + at least one component render smoke test
|
|
pass; `tsc --noEmit` clean. No restored Create plumbing; no unused libraries.
|
|
- All work committed in small Conventional-Commit units as specified per task.
|