docs: extract port-worthy reference from legacy code before full cleanup

This commit is contained in:
Bas van Rossem
2026-06-17 14:35:00 +02:00
parent 3f2c5f0179
commit c72086550d
3 changed files with 1384 additions and 0 deletions

View File

@@ -0,0 +1,543 @@
# Legacy backend reference (`apps/web`)
> Captured from the inherited Create / Anything export before the legacy code is removed.
> Source of truth at capture time: `apps/web/src/app/api/*`, `apps/web/src/app/api/utils/sql.ts`,
> `apps/web/src/lib/auth.ts`, `apps/web/db/schema.sql`.
>
> This is a **port reference** for the new `apps/api` (Hono + better-auth + Drizzle + libsql/SQLite)
> backend. It records what the old Next.js 16 backend actually did, with real SQL/code quoted where
> the wording is load-bearing. Nothing here prescribes the new implementation; it documents the
> contract the mobile app (`apps/mobile`) was built against, so behaviour can be preserved.
---
## 1. Overview
The legacy backend was a **Next.js 16 App Router** app whose only real job was to serve the
`/api/*` HTTP surface that `apps/mobile` calls. The DB was **Neon serverless Postgres**, reached
**only** from `apps/web` via `DATABASE_URL`. Auth was **better-auth** (cookie sessions for web,
`Authorization: Bearer <token>` for mobile).
Two app tables: `production_tasks` and `time_logs`. Auth tables (better-auth user/session/account/
verification) were managed separately by the better-auth CLI and are **not** in the repo schema.
A worker picks an insole type (`Type zool`: Kurk / Berk / 3D), a handling/task, and a count
(`Aantal zolen`, default 2), runs a stopwatch, and on stop the session is POSTed to `/api/logs`.
History reads `GET /api/logs`; CSV comes from `GET /api/export`; settings manage tasks via
`/api/tasks*`.
---
## 2. Data model
Reverse-engineered schema (`apps/web/db/schema.sql` — there was no shipped migration). Postgres
dialect. Note the new backend is SQLite, so `text[]`, `GENERATED ALWAYS AS IDENTITY`, and
`timestamptz` will need equivalents (e.g. JSON/CSV text column for `insole_types`, integer PK
autoincrement, ISO-8601 text or epoch for timestamps).
### `production_tasks` — the handelingen (tasks) per zooltype
| Column | Type | Notes |
|---|---|---|
| `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK |
| `name` | `text NOT NULL` | task / handeling name |
| `insole_types` | `text[] NOT NULL DEFAULT ARRAY['Kurk','Berk','3D']::text[]` | subset of `Kurk` / `Berk` / `3D`; which zooltypes this task applies to |
| `created_at` | `timestamptz NOT NULL DEFAULT now()` | |
```sql
CREATE TABLE IF NOT EXISTS production_tasks (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
insole_types text[] NOT NULL DEFAULT ARRAY['Kurk', 'Berk', '3D']::text[],
created_at timestamptz NOT NULL DEFAULT now()
);
```
### `time_logs` — one finished stopwatch session
| Column | Type | Notes |
|---|---|---|
| `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK |
| `task_id` | `integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE` | FK → `production_tasks` |
| `start_time` | `timestamptz NOT NULL` | stopwatch start (ISO from client) |
| `end_time` | `timestamptz` | nullable; stopwatch stop |
| `duration_seconds` | `integer NOT NULL DEFAULT 0` | elapsed seconds (client-counted, not derived from start/end) |
| `pair_count` | `integer NOT NULL DEFAULT 2` | "number of insoles" for the session (`Aantal zolen`) |
| `insole_type` | `text` | nullable; `'Kurk' \| 'Berk' \| '3D'` |
| `notes` | `text` | nullable; never written by the mobile app |
| `created_at` | `timestamptz NOT NULL DEFAULT now()` | |
```sql
CREATE TABLE IF NOT EXISTS time_logs (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE,
start_time timestamptz NOT NULL,
end_time timestamptz,
duration_seconds integer NOT NULL DEFAULT 0,
pair_count integer NOT NULL DEFAULT 2,
insole_type text,
notes text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS time_logs_task_id_idx ON time_logs (task_id);
CREATE INDEX IF NOT EXISTS time_logs_start_time_idx ON time_logs (start_time DESC);
```
### Relationships & cascade behaviour
- `time_logs.task_id → production_tasks.id`, `ON DELETE CASCADE` in the schema.
- The legacy `DELETE /api/tasks/:id` route **does not rely on the cascade** — it deletes the logs
first, explicitly, then the task (see §3). The new backend should reproduce this *behaviour*
(deleting a task removes its logs) whether via FK cascade, an explicit delete, or both.
- `duration_seconds` is authoritative for elapsed time — it is the client's stopwatch counter, not
`end_time - start_time`. Pause time means the two can differ. Preserve this: the CSV "Total
Duration" formats `duration_seconds`, not the wall-clock delta.
### Default-value notes worth porting
- A task with no/empty `insole_types` defaults to all three: `['Kurk','Berk','3D']` — enforced both
in the DB default **and** in the route code (POST/PUT tasks).
- On `POST /api/logs`, missing `pair_count` defaults to `2`, missing `insole_type` defaults to
`'Kurk'`, missing `notes` defaults to `null` (route-level coalescing, see §3).
---
## 3. API surface
All routes are Next.js App Router handlers under `apps/web/src/app/api/`. They return
`Response.json(...)` (the export route returns `text/csv`). **None of the data routes check a
session** — tasks/logs/export are open; only `/api/session` and `/api/auth/token` require auth.
### `GET /api/tasks` — list tasks
- File: `api/tasks/route.ts`
- Request: none.
- Response: JSON array of full `production_tasks` rows (`SELECT *`), ordered by `name ASC`.
- SQL:
```sql
SELECT * FROM production_tasks ORDER BY name ASC
```
### `POST /api/tasks` — create task
- File: `api/tasks/route.ts`
- Request body: `{ name: string, insole_types?: string[] }`.
- Validation: `name` required → `400 { error: 'Name is required' }` if missing.
- Default: empty/non-array `insole_types` → `['Kurk','Berk','3D']`.
- Response: the inserted row (`RETURNING *`).
- SQL:
```sql
INSERT INTO production_tasks (name, insole_types)
VALUES (${name}, ${types})
RETURNING *
```
### `PUT /api/tasks/:id` — update task
- File: `api/tasks/[id]/route.ts`
- Request body: `{ name: string, insole_types?: string[] }`.
- Validation: `name` required → `400`. Not found (no row updated) → `404 { error: 'Task not found' }`.
- Default: same `insole_types` fallback as POST.
- Response: the updated row (`RETURNING *`).
- SQL:
```sql
UPDATE production_tasks
SET name = ${name}, insole_types = ${types}
WHERE id = ${id}
RETURNING *
```
### `DELETE /api/tasks/:id` — delete task (and its logs)
- File: `api/tasks/[id]/route.ts`
- Request: none.
- Response: `{ success: true }`.
- Deletes logs first, then the task (two statements):
```sql
DELETE FROM time_logs WHERE task_id = ${id}
DELETE FROM production_tasks WHERE id = ${id}
```
### `GET /api/logs` — history list
- File: `api/logs/route.ts`
- Request: none.
- Response: JSON array, joined to task name, **newest first** (`start_time DESC`).
- Fields returned: `id, task_name, task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes, created_at`.
- SQL:
```sql
SELECT
tl.id,
pt.name AS task_name,
tl.task_id,
tl.start_time,
tl.end_time,
tl.duration_seconds,
tl.pair_count,
tl.insole_type,
tl.notes,
tl.created_at
FROM time_logs tl
JOIN production_tasks pt ON tl.task_id = pt.id
ORDER BY tl.start_time DESC
```
### `POST /api/logs` — save a finished session
- File: `api/logs/route.ts`
- Request body (this is exactly what `apps/mobile` `(tabs)/index.tsx` sends on Stop & Save):
```json
{
"task_id": "<int>",
"start_time": "<ISO 8601>",
"end_time": "<ISO 8601>",
"duration_seconds": "<int, client stopwatch count>",
"pair_count": "<int, optional, default 2>",
"insole_type": "<'Kurk'|'Berk'|'3D', optional, default 'Kurk'>",
"notes": "<string|null, optional, default null>"
}
```
Mobile call site (`apps/mobile/src/app/(tabs)/index.tsx`) sends `task_id, start_time, end_time,
duration_seconds, pair_count, insole_type` — `notes` is never sent by the client.
- Validation: requires `task_id`, `start_time`, `end_time`, and `duration_seconds !== undefined`,
else `400 { error: 'Missing required fields' }`. (Note: `duration_seconds: 0` is valid — the check
is `=== undefined`, not falsy.)
- Defaults applied in the route: `pair_count ?? 2`, `insole_type ?? 'Kurk'`, `notes ?? null`.
- Response: the inserted row (`RETURNING *`).
- SQL:
```sql
INSERT INTO time_logs (task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes)
VALUES (${task_id}, ${start_time}, ${end_time}, ${duration_seconds}, ${pair_count ?? 2}, ${insole_type ?? 'Kurk'}, ${notes ?? null})
RETURNING *
```
### `GET /api/export` — CSV download
- File: `api/export/route.ts`
- Detailed in §4 below.
### `GET /api/session` — current session (auth)
- File: `api/session/route.ts`
- Reads the better-auth session from request headers; `401 { error: 'Unauthorized' }` if none.
- Response: `{ user: session.user, session: session.session }`.
- Code:
```ts
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ user: session.user, session: session.session });
```
### Auth routes (`/api/auth/*`) — platform-managed
These carry `⚠ ANYTHING PLATFORM — DO NOT REWRITE` headers; their contracts are consumed by the
mobile `AuthWebView`. Summarised for porting; see §5 for the better-auth config.
- **`GET|POST /api/auth/[...all]`** — better-auth catch-all via `toNextJsHandler(auth)`. Wires every
better-auth endpoint (`/sign-up/email`, `/sign-in/email`, `/get-session`, …). Do not hand-roll
these paths.
- **`GET /api/auth/token`** — mobile token exchange. `401` if no session; else returns the exact
shape the mobile `AuthWebView.tsx` parses:
```json
{ "jwt": "<session.token>", "user": { "id": "...", "email": "...", "name": "..." } }
```
- **`GET /api/auth/expo-web-success`** — Expo-web postMessage bridge. Returns an **HTML page** (not
JSON) whose inline script posts to `window.parent`:
- success: `{ type: 'AUTH_SUCCESS', jwt: <session.token>, user: { id, email, name } }`
- failure: `{ type: 'AUTH_ERROR', error: 'Unauthorized' }`
The mobile iframe (`apps/mobile/src/utils/auth/AuthWebView.tsx`) loads
`${proxyURL}/account/<mode>?callbackUrl=/api/auth/expo-web-success` and listens for exactly that
message shape.
- **`GET /api/__create/check-social-secrets?provider=google|apple`** — dev-only helper. `404`
unless `NEXT_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`; `400` on unknown provider; else
`{ provider, missing: string[] }` listing which OAuth env vars are unset. Builder-preview only,
almost certainly not worth porting.
### Error handling pattern (all data routes)
Every route wraps its body in `try/catch`, `console.error(error)`, and returns a `500` with a
route-specific message (`'Failed to fetch tasks'`, `'Failed to create task'`,
`'Failed to update task'`, `'Failed to delete task'`, `'Failed to fetch logs'`,
`'Failed to save log'`, `'Failed to export data'`).
---
## 4. CSV export format (`GET /api/export`)
Returns `text/csv; charset=utf-8` with
`Content-Disposition: attachment; filename="insole-production-report.csv"`.
Source query — same join as `/api/logs` but ordered **`start_time ASC`** (oldest first, opposite of
the history list):
```sql
SELECT
tl.id,
pt.name as task_name,
tl.start_time,
tl.end_time,
tl.duration_seconds,
tl.pair_count,
tl.insole_type,
tl.notes
FROM time_logs tl
JOIN production_tasks pt ON tl.task_id = pt.id
ORDER BY tl.start_time ASC
```
### Columns (in order)
| # | Header | Source / formatting |
|---|---|---|
| 1 | `ID` | `log.id` |
| 2 | `Task` | `log.task_name` |
| 3 | `Insole Type` | `log.insole_type ?? 'Kurk'` |
| 4 | `No. of Insoles` | `log.pair_count ?? 2` |
| 5 | `Date` | `start_time` → `toLocaleDateString('nl-BE', { day:'2-digit', month:'2-digit', year:'numeric' })` |
| 6 | `Total Duration` | `duration_seconds` formatted `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 `end_time` is null |
Formatting details to preserve:
- **Locale `nl-BE`** for both date and time. Date is `dd/mm/yyyy`, time is 24-hour `HH:MM:SS`.
(Server-side `toLocale*` uses the server's timezone — a known fragility worth noting when porting;
the new backend should decide explicitly which timezone to format in.)
- **Duration** is computed from `duration_seconds`, **not** from `end_time - start_time`:
```ts
const totalSeconds = log.duration_seconds || 0;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const totalFormatted =
`${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(seconds).padStart(2,'0')}`;
```
- **Quoting:** every cell (and every header) is wrapped with a `quote()` that double-quotes the
value and escapes embedded `"` by doubling it — standard CSV escaping:
```ts
const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
```
- **Line endings:** rows joined with `\n` (LF, not CRLF). Header row first, then data rows.
- No UTF-8 BOM is emitted (relevant if Excel mangles accented characters — consider adding one in the
port if needed).
---
## 5. better-auth configuration (`apps/web/src/lib/auth.ts`)
⚠ Platform-managed file. The new backend uses better-auth too, so these specifics are the most
directly portable. Each piece below is documented as load-bearing by the file's own header comment.
### DB connection
Postgres via a `@neondatabase/serverless` `Pool` with the WebSocket constructor set to `ws`:
```ts
neonConfig.webSocketConstructor = ws;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// ...
export const auth = betterAuth({ database: pool, /* ... */ });
```
In the SQLite port this becomes the libsql/Drizzle adapter instead of a Neon `Pool`.
### Email + password, with argon2 compatibility
```ts
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
password: { verify: verifyCompatiblePassword },
}
```
`verifyCompatiblePassword` falls back to **argon2** verification when a stored hash starts with
`$argon2`, otherwise uses better-auth's default `verifyPassword`. This exists so users created under
an older argon2-based scheme still authenticate:
```ts
async function verifyCompatiblePassword({ hash, password }) {
if (hash.startsWith('$argon2')) return argon2Verify({ hash, password });
return verifyPassword({ hash, password });
}
```
(Only relevant if migrating existing user rows; a greenfield user table can use better-auth's
default scrypt hashing and drop this shim.)
### `hooks.before` — name backfill on signup (load-bearing)
better-auth's `/sign-up/email` schema requires `name`. The mobile signup form collects only
email+password, so a middleware backfills `name` from the email local-part. Removing this broke
**every** signup with `[body.name]` validation errors (per the header comment):
```ts
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email') return;
const body = ctx.body as { email?: unknown; name?: unknown } | undefined;
if (!body || typeof body.email !== 'string') return;
if (typeof body.name === 'string' && body.name.trim().length > 0) return;
const derived = body.email.split('@')[0];
body.name = derived && derived.length > 0 ? derived : 'User';
}),
}
```
**Port this** if the new signup UI likewise omits a name field.
### `bearer()` plugin — mobile token auth (load-bearing)
```ts
plugins: [bearer()],
```
Enables `Authorization: Bearer <session-token>` so the mobile WebView (which can't carry cookies)
authenticates API calls with the token from `/api/auth/token`. Required for mobile.
### Cookies — `sameSite:'none'` for iframes (load-bearing)
Required for the Create preview / mobile iframe; do not change without understanding the iframe
constraint:
```ts
advanced: {
cookiePrefix: 'better-auth',
defaultCookieAttributes: {
sameSite: 'none', // Required for iframes
secure: true,
httpOnly: true,
path: '/',
},
cookies: {
sessionToken: {
attributes: { sameSite: 'none', secure: true },
},
},
}
```
Note: `secure: true` + `sameSite:'none'` means cookies only flow over HTTPS. For local-only dev the
new backend may need to relax this; weigh against the iframe requirement.
### Session cookie cache
```ts
session: {
cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7 }, // 7 days
}
```
### `user.additionalFields`
Adds an optional `image` string field to the user:
```ts
user: { additionalFields: { image: { type: 'string', required: false } } }
```
### `trustedOrigins` — CSRF allow-list (load-bearing)
Built from env at startup, filtering out falsy entries. Every URL the app may be served under must
be present or better-auth rejects the request as "Invalid origin":
```ts
const trustedOrigins = [
process.env.BETTER_AUTH_URL,
process.env.EXPO_PUBLIC_PROXY_BASE_URL,
process.env.NEXT_PUBLIC_CREATE_BASE_URL,
process.env.NEXT_PUBLIC_CREATE_HOST ? `https://${process.env.NEXT_PUBLIC_CREATE_HOST}` : null,
].filter((v): v is string => Boolean(v));
```
### `socialProviders` — self-activating Google / Apple
Providers register only when their OAuth env vars are present (platform injects them when enabled in
project settings). A provider with missing credentials is simply not registered, so its sign-in
button never hits a half-configured backend. Apple additionally wires `appBundleIdentifier` (for
native "Sign in with Apple") when `APPLE_APP_BUNDLE_IDENTIFIER` is set:
```ts
const socialProviders = {
...(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET ? { google: { clientId, clientSecret } } : {}),
...(APPLE_CLIENT_ID && APPLE_CLIENT_SECRET ? {
apple: { clientId, clientSecret, ...(APPLE_APP_BUNDLE_IDENTIFIER ? { appBundleIdentifier } : {}) },
} : {}),
};
```
(Probably not needed for a local SoleLog rebuild unless social login is a requirement.)
### Session type export
```ts
export type Session = typeof auth.$Infer.Session;
```
### Auth tables
The better-auth user/session/account/verification tables are **not** in `db/schema.sql`. The legacy
comment says to generate them with `npx @better-auth/cli migrate`. The new backend uses Drizzle, so
these come from better-auth's Drizzle schema generation instead.
---
## 6. Parameterised tagged-template SQL pattern (`apps/web/src/app/api/utils/sql.ts`)
The DB layer is a single default-exported Neon tagged template. **All interpolations in
`` sql`...` `` are bind parameters, not string concatenation** — i.e. it is parameterised and safe
against injection. Routes import it as `import sql from '@/app/api/utils/sql'`.
```ts
import { neon, NeonQueryFunction } from '@neondatabase/serverless';
type SqlQueryFunction = NeonQueryFunction<false, false> & {
query: NeonQueryFunction<false, false>;
};
const NullishQueryFunction = (() => {
throw new Error(
'No database connection string was provided to `neon()`. Perhaps process.env.DATABASE_URL has not been set'
);
}) as any as SqlQueryFunction;
NullishQueryFunction.transaction = (() => {
throw new Error(/* same message */);
}) as any as NeonQueryFunction<false, false>['transaction'];
NullishQueryFunction.query = NullishQueryFunction;
const sql = (
process.env.DATABASE_URL ? neon(process.env.DATABASE_URL) : NullishQueryFunction
) as SqlQueryFunction;
sql.query = sql;
export default sql;
```
Key behaviours to preserve in the port:
- **Lazy / forgiving init:** if `DATABASE_URL` is unset, `sql` is a stub that throws a clear,
specific error *only when actually invoked* — importing the module never crashes at load time.
Both `sql()` and `sql.transaction()` throw the same explanatory message.
- **`sql.query = sql`:** the same function is exposed under `.query`, so call sites can use either
`` sql`...` `` (tagged template) or `sql.query(...)`. Both forms are bind-parameterised.
- **Array binding:** Postgres `text[]` columns are written by binding a JS `string[]` directly
(e.g. `VALUES (${name}, ${types})` where `types: string[]`). The Neon driver maps the array to a
Postgres array param. The SQLite/Drizzle port has no native array type — `insole_types` must be
stored as JSON/CSV text (or a join table) and serialised at the boundary.
- **Row access:** single-row inserts/updates use array destructuring on the result —
`const [task] = await sql\`... RETURNING *\``; a missing row yields `undefined` (the basis for the
`404` in `PUT /api/tasks/:id`).
---
## 7. Mobile consumer contract (for parity verification)
What `apps/mobile` actually calls, so the port can be verified against real callers:
- `GET /api/tasks` — Stopwatch (`(tabs)/index.tsx`) and Settings (`(tabs)/tasks.tsx`); Stopwatch
filters by `t.insole_types.includes(insoleType)`.
- `POST /api/tasks` body `{ name, insole_types }` — Settings add.
- `PUT /api/tasks/:id` body `{ name, insole_types }` — Settings edit (id is a number).
- `DELETE /api/tasks/:id` — Settings delete (also invalidates the `logs` query client-side).
- `GET /api/logs` — History (`(tabs)/history.tsx`).
- `POST /api/logs` body `{ task_id, start_time, end_time, duration_seconds, pair_count, insole_type }`
— Stopwatch Stop & Save (`notes` omitted).
- `GET /api/export` — History CSV download/share.
- `GET /api/auth/token`, `GET /api/auth/expo-web-success`, `/api/auth/[...all]` — auth plumbing.
All client calls go through the monkey-patched global `fetch` that rewrites `/api/...` onto
`EXPO_PUBLIC_BASE_URL` and attaches the Bearer JWT (see mobile `src/__create/fetch.ts`).

View File

@@ -0,0 +1,290 @@
# Legacy lessons & gotchas (Create/Anything export)
Hard-won knowledge mined from the **legacy** code (`apps/mobile`, `apps/web`) and
`.yarn/patches/` before the full clean. The legacy apps are being removed, but the
*reasons* behind these workarounds are worth keeping for the rebuild (`apps/api`,
`packages/shared`, and any future mobile client).
Everything below is reconstructed from real source — each lesson cites the file path
where it lived. None of this code is part of the new stack; it is reference only.
---
## 1. Android font / freeze fixes & platform workarounds
### Font loading must never block render on Android
- **`apps/mobile/src/app/(tabs)/index.tsx`** (Stopwatch screen)
- Uses `const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold })`.
- Gate is `if (!fontsLoaded && !fontError) return null;` — i.e. it returns `null`
only while fonts are *still loading*. If font loading **errors**, it renders
anyway. The inline comments call this out explicitly: *"if fonts fail to load on
Android we still render (no freeze)"* and *"Wait for fonts — but if font loading
errored, render anyway (prevents Android freeze)"*.
- When `fontError` is truthy it falls back to `fontFamily: undefined` (system font)
instead of the named Inter family: `const regular = fontError ? undefined : 'Inter_400Regular'`.
- **Lesson for rebuild:** never block the whole tree on `fontsLoaded` alone; always
treat `fontError` as "render with system font". A hard wait on fonts is the classic
way to get a permanently blank/frozen screen on Android.
### Splash screen has a hard timeout so a stuck session check can't freeze launch
- **`apps/mobile/src/app/_layout.tsx`**
- `SplashScreen.preventAutoHideAsync()` is called, then the root is gated on
`useAuth().initiate()` + `isReady`. To stop a hung session restore from freezing on
the splash forever, there is a `SPLASH_TIMEOUT_MS = 10_000` fallback: after 10s it
sets `timedOut` and hides the splash / renders regardless of `isReady`.
- **Lesson:** any "load persisted session before first render" gate needs a timeout
escape hatch.
### Crash reporter is silenced in dev to avoid the RN red-box/freeze loop
- **`apps/mobile/index.tsx`** (native entry)
- In `__DEV__` it replaces `react-native/Libraries/Core/ExceptionsManager.handleException`
with a no-op, and calls `LogBox.ignoreAllLogs()` + `LogBox.uninstall()` (also gated on
`EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`). It also installs an empty
`AppRegistry.setWrapperComponentProvider`. This keeps the Create in-builder preview
from getting stuck behind RN's error overlay.
### New Architecture is on
- **`apps/mobile/app.json`**: `"newArchEnabled": true`. iOS uses
`expo-build-properties` with `useFrameworks: "static"`. Worth noting for any native
module compatibility decisions in the rebuild.
---
## 2. expo-secure-store / SecureStore quirks (token storage)
The richest source of SecureStore wisdom is **`apps/mobile/src/utils/auth/store.ts`**
(a platform-managed `⚠ DO NOT REWRITE` file). Token is the better-auth JWT, stored under
key `` `${EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt` ``.
Explicit `secureStoreOptions` are passed on **every** SecureStore call, and each option
exists for a concrete failure it prevents:
- **`keychainService: 'anything-auth'`** — pinned to a stable, hard-coded service name.
Without it, SecureStore derives the service name from the bundle, which can **drift
between Classic and EAS builds**, so reads silently miss writes made by a previous
build (token "disappears" after a rebuild).
- **`keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`** — lets the token be read
on every cold launch after the device has been unlocked once since boot. The default
(`WHEN_UNLOCKED`) refuses access during the first-unlock window — described as *"the
most common TestFlight failure mode"* (user opens app from a notification before
unlocking → auth read fails → appears signed out).
- **`requireAuthentication: false`** — keeps SecureStore on its non-biometric code path,
so it never reads `NSFaceIDUsageDescription` or builds a biometry access-control object.
Both can throw an `NSException` and trip **iOS 26's unhandled async-void TurboModule
rethrow**, crashing the app.
Other token-storage lessons:
- **Keychain writes are wrapped in `.catch(() => {})`** (in `setAuth`). A failed Keychain
write must NOT throw — throwing propagates into the unhandled-rejection / TurboModule
rethrow path and crashes on iOS 26.x. On write failure the app stays *in-memory authed*
for the session and re-auths via the WebView next launch.
- **Web has no Keychain** — `expo-secure-store` is aliased to a `localStorage` shim on web
(`apps/mobile/polyfills/web/secureStore.web.ts`). It namespaces keys with
`_create_secure_store_<key>`, warns (but does not fail) when a value exceeds a 2048-byte
limit, `isAvailableAsync()` probes localStorage, and `canUseBiometricAuthentication()`
returns `false`. **Lesson:** if the rebuild stores tokens on a web client, plan a
non-Keychain fallback — localStorage is not secure storage.
- **Where the token gets read for requests:** `apps/mobile/src/__create/fetch.ts`
(`SecureStore.getItemAsync(authKey)` → `JSON.parse` → `Bearer <auth.jwt>`), with a
`.catch(() => null)` so a read failure just sends an unauthenticated request rather than
crashing.
---
## 3. The global `fetch` monkey-patch (`src/__create/fetch.ts`)
Installed by **`apps/mobile/src/__create/polyfills.ts`**, which does
`global.fetch = updatedFetch` (the default export of `fetch.ts`). App code everywhere just
calls `fetch('/api/...')` and relies on this rewrite.
What `fetchToWeb` (in `apps/mobile/src/__create/fetch.ts`) actually does, in order:
1. **Bails out unchanged** if `EXPO_PUBLIC_BASE_URL` (first-party) or
`EXPO_PUBLIC_PROXY_BASE_URL` (second-party) is unset — falls back to `expo/fetch`.
2. **Extracts the URL** from whatever form the call used — string, `Request`, or `URL`
(via a `getURLFromArgs` helper, because the `URL` type isn't always in the fetch TS
signature).
3. **Passes through static/file URLs untouched** via the *original* native `fetch`
(not `expo/fetch`): `file://`, `data:`, and static assets matched by extension
(`.wasm/.png/.jpg/.svg/.woff2/.ttf/...`). These must not get auth headers or rewriting.
4. **Leaves external (third-party) URLs alone** — headers/JWT are only ever added to
first-party requests. "First-party" = URL starts with `/` **or** with
`EXPO_PUBLIC_BASE_URL`.
5. **Rewrites first-party URLs onto a base.** A path starting with `/` is prefixed:
`/_create/...` paths use the **second-party** base (`EXPO_PUBLIC_PROXY_BASE_URL`),
everything else uses the **first-party** base (`EXPO_PUBLIC_BASE_URL`). Non-string
inputs (`Request`/`URL`) are *not* rewritten — they fall through to `expo/fetch`.
6. **Injects Create routing headers** on first-party calls:
`x-createxyz-project-group-id` (= `EXPO_PUBLIC_PROJECT_GROUP_ID`), plus `host`,
`x-forwarded-host`, and `x-createxyz-host` (all = `EXPO_PUBLIC_HOST`). Only set when the
env value is truthy.
7. **Attaches auth**: reads the JWT from SecureStore (key `<PROJECT_GROUP_ID>-jwt`,
JSON-parsed), and if present sets `Authorization: Bearer <auth.jwt>`. Read errors are
swallowed (`→ null`), so the request still goes out unauthenticated.
8. Dispatches the rewritten request through **`expo/fetch`** (Expo's fetch, for
streaming/RN compatibility), not the global one (which it has replaced).
**Lessons for rebuild:** the new mobile client should centralise (a) base-URL rewriting
and (b) auth-header attachment in one place. The legacy approach monkey-patches the global
`fetch`, which is invisible/magic to app code — a typed API client (the `packages/shared`
zod contracts make this easy) is a cleaner replacement. Note the alternative `authFetch`
in `apps/mobile/src/utils/auth/getSession.ts` does the Bearer-header part explicitly and
is the documented "use this instead of bare fetch" helper.
---
## 4. Web sandbox / iframe plumbing
The mobile app's **web** target runs inside the Create builder as an `<iframe>` and talks
to the parent window over `postMessage`. This is purely a Create-preview affordance; the
rebuild does not need any of it unless it re-targets the Create platform.
- **Entry split by extension** (`apps/mobile/metro.config.js` resolves these):
- Native: `index.tsx` → `entrypoint.ts` → `App.tsx` (`App.tsx` just wraps
`expo-router`'s qualified-entry `App` with a `ScreenViewTracker`).
- Web: `index.web.tsx` → `App.web.tsx`.
- **`apps/mobile/App.web.tsx`** — the iframe/sandbox contract:
- **Healthcheck handshake** (`useHandshakeParent`): replies to
`sandbox:mobile:healthcheck` with `sandbox:mobile:healthcheck:response {healthy:true}`,
and also posts the healthy response immediately on mount in case the healthcheck arrived
before the listener was attached.
- **Navigation sync** (both directions): listens for `sandbox:navigation {pathname}` from
the parent and `router.push`es it; posts `sandbox:mobile:navigation {pathname}` back on
every `usePathname()` change; posts `sandbox:mobile:ready` once on mount.
- **Error forwarding** (`GlobalErrorReporter` + `postErrorToParent`): global `error` and
`unhandledrejection` listeners post `sandbox:error:detected {message,name,stack}` to the
parent — but **filter out runtime/network errors** (a big `RUNTIME_ERROR_PATTERNS`
regex list: `fetch failed`, `ECONNREFUSED`, `502/503/504`, `timeout`, etc.) so only
*code* errors (TypeError/ReferenceError/SyntaxError/`MODULE_RESOLVE_FAILED`) are
surfaced to the builder. `event.preventDefault()` is called to suppress the default
overlay.
- **Faked safe-area insets on web**: `SafeAreaProvider initialMetrics` hard-codes
`{ top: 64, bottom: 34, ... }` and frame size from `window.innerWidth/Height`, so the
web preview mimics a phone notch.
- **`apps/mobile/src/__create/ErrorBoundary.tsx`** — a React error boundary that also posts
`sandbox:error:detected` on `componentDidCatch` and `sandbox:error:resolved` when the user
taps "Try again". All `postMessage` calls are guarded by `window.parent !== window`.
- **`apps/mobile/index.web.tsx`** — screenshot + Skia + font plumbing:
- Responds to `sandbox:web:screenshot:request` by rasterising `#root` with
`html-to-image` `toPng` and posting back `sandbox:web:screenshot:response {dataUrl}`
(or `...:error`).
- Before screenshotting it **inlines Google Fonts** (`inlineGoogleFonts`: fetches each
`fonts.googleapis.com` stylesheet, absolutises `url(...)` refs, injects a `<style>`,
then awaits `document.fonts.ready`) and waits for all `<img>`s (`crossOrigin =
'anonymous'`) — otherwise the screenshot renders with missing fonts/images.
- Loads Skia for web (`LoadSkiaWeb({ locateFile: f => \`/${f}\` })`) and renders the root
in `.then`/`.catch` so the app still mounts if Skia fails to load.
**Lesson:** all of this is Create-builder coupling. For the rebuild it is dead weight unless
you keep publishing to `*.created.app`. The one genuinely reusable idea is the
*code-vs-runtime* error classification in `App.web.tsx` (don't spam your error channel with
transient network failures).
---
## 5. What each `.yarn/patches/*` file patches, and why
> All are pinned by `patch:` deps + root `resolutions`/`overrides`. Bumping any of these
> packages discards the patch (per project `CLAUDE.md`). One line each:
- **`react-native+0.81.4.patch`** — re-adds a working `Slider` getter to RN's `index.js`
(forwarding to `@react-native-community/slider`) and removes RN core's "Slider has been
removed" invariant that otherwise throws on access.
- **`@expo+cli+54.0.1.patch`** — in the Expo Go manifest middleware, generates the manifest
`id` with `crypto.randomUUID({ disableEntropyCache: true })` to avoid duplicate/cached
UUIDs across rapid manifest requests.
- **`@expo+metro-runtime+6.1.2.patch`** — short-circuits the dev **error overlay**:
`LogBoxInspectorContainer` and `ErrorToast` both `return null` immediately, so RN's
red-box error UI never shows inside the Create preview.
- **`@react-native-community+netinfo+11.4.1.patch`** — makes `nativeInterface.ts` tolerate a
missing native module: removes the hard `throw` when `RNCNetInfo` is null and returns `{}`
instead, so NetInfo doesn't crash environments (web/preview) where the native module isn't
present.
- **`expo-router+6.0.11.patch`** — large patch. (a) Restyles the web `native-tabs` CSS into
a floating, blurred bottom tab bar + adds an Apple-Settings-style "More" overflow screen
when there are >5 tabs (incl. extracting `webIcon*`/`webLabel*` options for web rendering).
(b) Adds an `isAnythingApp` branch (iOS + not Expo Go) that forces `getInitialURL` to `/`
and skips the `Linking` 'url' subscription, so the Anything native shell always boots at
root instead of via deep link.
- **`expo-store-review+9.0.8.patch`** — adds `prePromptReview` / `resetReviewState` /
`hasUserRated` to the JS API and a native iOS `StoreReviewModule` (a custom "Thanks for
using Anything!" pre-prompt alert that gates the real `AppStore.requestReview`, tracking
`anything_has_rated` in `UserDefaults`); also guards `ExpoStoreReview.native.js` so it only
`requireNativeModule`s when the module is actually present (else `{}`). NB: the patch file
also accidentally committed `.orig`/`.rej` artifacts.
- **`react-native-purchases+9.6.1.patch`** — patches `isExpoGo()` to return `false` when
`!__DEV__`, and to return `true` when `globalThis.expo.modules.AnythingLauncherModule`
exists — so RevenueCat detects the Anything launcher/Expo-Go correctly instead of trying to
hit native StoreKit where it can't.
- **`react-native-purchases-ui+9.6.1.patch`** — same `isExpoGo()` fix as above, applied
across the package's `commonjs`/`module`/`src` `environment` files (and adds the
`AnythingLauncherModule` type to the `globalThis.expo.modules` declaration).
- **`react-native-web-refresh-control+1.1.2.patch`** — replaces the deprecated/removed
`findNodeHandle(containerRef.current)` with `containerRef.current?.firstChild` to read
`scrollTop` in `RefreshControl.web.js`, fixing pull-to-refresh detection on web.
- **`sonner-native+0.21.0.patch`** — on web, moves the toast `Positioner`'s
`pointerEvents: 'box-none'` from a `View` prop into the `style` array (RNW expects
`pointerEvents` in style), so toasts don't block clicks behind them on web.
---
## 6. Other gotchas worth remembering
- **Web support is Metro module aliasing, not separate code** —
`apps/mobile/metro.config.js` swaps ~25 native modules for web shims in `polyfills/web/`
via `resolver.resolveRequest` keyed on `platform === 'web'` (e.g. `expo-secure-store`,
`react-native-webview`, `react-native-safe-area-context`, `expo-haptics`,
`react-native-maps`, several `react-native-web/dist/exports/*`). **Adding a native dep that
is imported on web requires adding a web alias here or the web build breaks.**
- **Dev-only native aliases stop Expo Go from black-screening** — `DEV_ONLY_NATIVE_ALIASES`
swaps `react-native-purchases` for a stub *outside production*. The inline comment explains
why: the real module's browser-mode shims pull in DOM-only code that throws on Hermes;
expo-router then silently swallows the load error and warns *"Route is missing the required
default export"*, leaving the app on a black/splash screen. EAS production builds keep the
real module. **Lesson:** a native module that throws at import time on Hermes manifests as a
confusing "missing default export" route error, not as the real error.
- **`anything-menu` is compiled out in production** — `metro.config.js` aliases
`./src/__create/anything-menu` to `polyfills/shared/empty-component.tsx` when
`EXPO_PUBLIC_CREATE_ENV === 'PRODUCTION'`. The in-app dev menu (`anything-menu.tsx`) talks
to a native `AnythingLauncherModule` and should never ship to end users.
- **Expo Google Fonts wildcard alias** — any `@expo-google-fonts/*` import (except
`@expo-google-fonts/dev`) is redirected to `@expo-google-fonts/dev` by the resolver, so the
template can reference arbitrary font families without bundling each one.
- **Custom Metro resolver swallows unresolved-module errors in dev** —
`apps/mobile/__create/handle-resolve-request-error.js` catches resolution failures and, in
dev only (not Android, not production), writes a deterministic "throwing" virtual module so
the error surfaces in-app at runtime instead of killing the bundler. It only rewrites the
virtual file when content changed, *to avoid a Metro rebuild loop from mtime bumps*. On
Android / production it rethrows.
- **Metro reporter forwards bundling errors to a remote logging endpoint** — both
`metro.config.js` (`reportErrorToRemote` on `error`/`bundling_error`/`cache_read_error`/
`hmr_client_error`/`transformer_load_failed`) and
`apps/mobile/__create/report-error-to-remote.js` post to `EXPO_PUBLIC_LOGS_ENDPOINT` with a
`Bearer EXPO_PUBLIC_CREATE_TEMP_API_KEY`. This is Create telemetry; the rebuild should not
carry the temp API key.
- **Metro cache is wiped on production builds** — `config.transformer.getTransformOptions`
deletes the `caches/` dir when `options.dev === false`. The repo also ships a populated
`apps/mobile/caches/.metro-cache/` (build artifacts — safe to drop in the clean).
- **Analytics/Sentry/TestFlight logging are all gated the same way** — `isActive()` =
`!__DEV__ && EXPO_PUBLIC_CREATE_ENV !== 'DEVELOPMENT'` (see
`apps/mobile/src/__create/analytics.ts`). The anonymous per-install visitor id lives in
**AsyncStorage**, not the Keychain — explicitly *"not a secret"*. Analytics calls are fully
try/caught so they can never crash or block the host app.
- **better-auth dual mode** — web uses cookie sessions; mobile uses
`Authorization: Bearer <jwt>` validated by better-auth's `bearer()` plugin
(`apps/mobile/src/utils/auth/getSession.ts`, `apps/web/src/lib/auth.ts`). The JWT comes
from `/api/auth/token` (native WebView) or an `AUTH_SUCCESS` postMessage from
`/api/auth/expo-web-success` (web iframe) — see `apps/mobile/src/utils/auth/AuthWebView.tsx`.
**The new `apps/api` already uses better-auth, so this dual-mode pattern is directly
relevant.**
- **Origin check normalises with `new URL(raw).origin`** — `AuthWebView.tsx` learned (the
hard way, per its comment) that a stray trailing slash in `EXPO_PUBLIC_PROXY_BASE_URL`
silently dropped *every* postMessage; it now compares normalised origins and `console.warn`s
on a drop instead of swallowing it. Good defensive pattern for any postMessage receiver.
- **RevenueCat offerings load with retries** — `apps/mobile/src/utils/iap/useInAppPurchase.ts`
retries `getOfferings()` 3× with a 1.5s delay because a freshly-configured RevenueCat client
often returns no `current` offering on the first call.
- **The `/api/logs` route was missing from the legacy `apps/web` export** — the mobile History
tab (`GET /api/logs`) and Stop & Save (`POST /api/logs`) 404'd against the local backend
(only `/api/export` existed). Documented in the repo `CLAUDE.md`; the rebuild's `apps/api`
owns this contract now.

View File

@@ -0,0 +1,551 @@
# Legacy mobile app — reference for the Phase 1 greenfield rebuild
> Source: the inherited Create/Anything export under `apps/mobile/` (LEGACY, being removed).
> This document captures everything the Phase 1 greenfield rebuild of the **worker app**
> needs to reproduce: screens, flows, every Dutch UI string, the stopwatch state machine,
> selection + count behaviour, History/CSV export, Settings management, navigation, and styling.
> It is a behavioural spec, **not** an instruction to reuse the legacy code.
App name: **SoleLog** — a time-tracking app for insole (orthotics / inlegzolen) production.
A worker picks an **insole type** (`Type zool`: Kurk / Berk / 3D), a **handling/task**
(`Type handeling`), and a **count** (`Aantal zolen`, default `2`), then runs a stopwatch
(start / pause / stop & save / double-press discard). The UI is entirely in **Dutch**.
Files this doc is built from (all under `apps/mobile/src/`):
| File | Role |
|---|---|
| `app/_layout.tsx` | Root layout: auth gate, splash, React Query provider, Stack |
| `app/index.tsx` | Empty redirect stub (`export default () => null`) |
| `app/(tabs)/_layout.tsx` | Bottom tab navigator (3 tabs) |
| `app/(tabs)/index.tsx` | **Stopwatch** screen (the main worker flow) |
| `app/(tabs)/history.tsx` | **Geschiedenis** (History) screen + CSV export |
| `app/(tabs)/tasks.tsx` | **Instellingen** (Settings) — manage handelingen per zooltype |
| `app/+not-found.tsx` | Platform 404 screen (Create-managed; not worker-facing) |
| `__create/fetch.ts` | Monkey-patched global `fetch` (URL rewrite, headers, JWT) |
---
## 1. Navigation structure
Expo Router, file-based.
- **Root** `app/_layout.tsx`: a `Stack` with `initialRouteName="(tabs)"`, `headerShown: false`.
Gated on `useAuth().initiate()` + `isReady` (loads the persisted session from SecureStore
before first render; returns `null` until ready, with a 10 s splash timeout fallback).
Wraps everything in `ErrorBoundary``QueryClientProvider``GestureHandlerRootView`.
Renders `<AuthModal />` alongside the `Stack`.
- React Query defaults: `staleTime` 5 min, `gcTime` 30 min, `retry: 1`,
`refetchOnWindowFocus: false`.
- **`app/(tabs)/_layout.tsx`**: a bottom **`Tabs`** navigator with `headerShown: false` and
**three tabs**, in this order:
| Order | Route file | Tab title (Dutch) | Icon (`lucide-react-native`) |
|---|---|---|---|
| 1 | `index.tsx` | **Stopwatch** | `Timer` |
| 2 | `history.tsx` | **Geschiedenis** | `History` |
| 3 | `tasks.tsx` | **Instellingen** | `Settings` |
Tab bar styling: white background, 1px top border `#E5E7EB`, `paddingTop: 4`;
active tint `#2563EB`, inactive tint `#6B7280`; label `fontSize: 12, fontWeight: '500'`;
icons `size={24}`.
The greenfield rebuild should keep this 3-tab shell. Note the tab **route** is `index` but
its **title** is `Stopwatch` — the first/landing tab is the stopwatch.
---
## 2. Shared visual language / styling
Screens use inline React Native `StyleSheet`/style objects (NativeWind exists in the template
but the worker screens do not use it). Notable, reusable tokens observed across all three tabs:
- **Font**: Inter, loaded via `@expo-google-fonts/inter``Inter_400Regular` (`regular`) and
`Inter_600SemiBold` (`semibold`). Each screen guards on `useFonts`:
`if (!fontsLoaded && !fontError) return null;` — **but if font loading errors it renders
anyway** (explicit comment: "prevents Android freeze"). On the Stopwatch screen the font
family falls back to `undefined` when `fontError` is set.
- **Palette**:
- Primary blue `#2563EB`; light-blue surfaces `#EFF6FF` / `#F0F7FF`; blue border `#BFDBFE`.
- Text: near-black `#111827`, dark grey `#374151`, mid grey `#6B7280`, muted `#9CA3AF`,
disabled `#D1D5DB`.
- Borders/surfaces: `#E5E7EB` (borders), `#F9FAFB` / `#F3F4F6` (light fills), white `#ffffff`.
- Danger red `#DC2626` (Stop button, delete); danger surface `#FEF2F2`.
- Paused / amber: text `#D97706`, dot `#F59E0B`, border `#FDE68A`, surface `#FFFBEB`.
- Success green: `#16A34A` text, `#DCFCE7` surface (Settings "Opslaan").
- **Per-zooltype colour set** (`tasks.tsx`, `TYPE_COLORS`) — used for toggles and badges:
| Type | bg | border | text |
|---|---|---|---|
| `Kurk` | `#FEF9C3` | `#FDE047` | `#854D0E` |
| `Berk` | `#DCFCE7` | `#86EFAC` | `#166534` |
| `3D` | `#EDE9FE` | `#C4B5FD` | `#5B21B6` |
- **Shapes**: heavy rounding — section cards `borderRadius: 1216`, pills `borderRadius: 999`,
primary buttons `borderRadius: 16`, the stopwatch display `borderRadius: 24`.
- **Safe areas**: each screen reads `useSafeAreaInsets()` and applies `paddingTop: insets.top`.
---
## 3. The three insole types (`Type zool`)
Defined identically in both the Stopwatch and Settings screens:
```ts
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const; // Stopwatch
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const; // Settings
```
| Value (verbatim) | English meaning |
|---|---|
| `Kurk` | Cork insole |
| `Berk` | Birch (birchwood) insole |
| `3D` | 3D-printed insole |
These are the only valid insole types and are **hard-coded** (not fetched). The default
selected type on the Stopwatch is `'Kurk'`.
---
## 4. Stopwatch screen (`app/(tabs)/index.tsx`) — the core worker flow
Vertically scrollable, white background. Five stacked sections, then a bottom sheet.
### 4.1 Local state
```ts
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [elapsedTime, setElapsedTime] = useState(0); // seconds
const [showPicker, setShowPicker] = useState(false); // bottom sheet
const [discardPending, setDiscardPending] = useState(false); // double-press arm
const [insoleCount, setInsoleCount] = useState(2); // numeric count (default 2)
const [insoleCountText, setInsoleCountText] = useState('2'); // text mirror for the input
```
Refs: `timerRef` (the 1 s interval), `discardTimerRef` (3 s discard-confirm window),
`slideAnim` (Animated value for the sheet, starts off-screen at `SHEET_HEIGHT`).
`SHEET_HEIGHT = Dimensions.get('window').height * 0.75`.
### 4.2 Data
- `tasks` via React Query key `['tasks']``GET {BASE_URL}/api/tasks`.
- `saveLogMutation``POST {BASE_URL}/api/logs`; on success invalidates `['logs']`.
Derived values:
```ts
const selectedTask = tasks.find(t => t.id === activeTaskId);
const canStart = !!activeTaskId; // a handling MUST be picked to start
const filteredTasks = tasks.filter(t =>
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
); // handlings shown depend on chosen zooltype
```
### 4.3 Section 1 — `Type zool` (insole type selector)
- Label (uppercase, letter-spaced, grey): **`Type zool`**.
- Three equal-width segmented buttons, one per `INSOLE_TYPES` value (`Kurk`, `Berk`, `3D`).
- Selected: blue border `#2563EB`, light-blue fill `#EFF6FF`, blue text. Unselected: grey.
- **Disabled while the stopwatch is running** (`disabled={isRunning}`, text greys to `#9CA3AF`).
- Tapping a type when not running: `setInsoleType(type)` **and resets** `setActiveTaskId(null)`
— i.e. **changing the zooltype clears the chosen handling** (because the handling list is
filtered by type).
### 4.4 Section 2 — `Type handeling` (handling/task picker)
- Label: **`Type handeling`**.
- A single full-width dropdown row showing the chosen task name, or the placeholder
**`Kies een handeling...`** ("Choose a handling…") when none is selected (`activeTaskId` null).
Trailing `ChevronDown` icon.
- Disabled while running. When tapped (and not running) it calls `openPicker()` → opens the
bottom-sheet modal (see 4.8).
### 4.5 Section 3 — `Aantal zolen` (count of insoles)
- Label: **`Aantal zolen`** ("Number of insoles").
- A combined stepper: a `` button (width 64), a centred numeric `TextInput`
(`keyboardType="number-pad"`), and a `+` button (width 64), inside one rounded bordered row.
- **Default value `2`.**
- `` is disabled when `insoleCount <= 1` or running; `+` disabled when running; the field is
`editable={!isRunning}`.
- Behaviour:
```ts
const handleInsoleCountChange = (text) => {
setInsoleCountText(text);
const parsed = parseInt(text, 10);
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed); // only accept >0
};
const adjustInsoleCount = (delta) => {
const next = Math.max(1, insoleCount + delta); // floor of 1
setInsoleCount(next); setInsoleCountText(String(next));
};
```
The text mirror lets the user type freely; the committed numeric `insoleCount` only updates
for a valid positive integer. **Minimum is 1.** This value is sent as `pair_count` on save.
### 4.6 Section 4 — Stopwatch display (tap target)
- A large rounded card showing the elapsed time formatted **`HH:MM:SS`** (each part zero-padded),
font size 64. `formatTime(seconds)` does `hrs/mins/secs` with `padStart(2,'0')`.
- The card itself is a tap target with overlaid status pill:
- **Not running, can start** (a handling chosen): time greyed `#9CA3AF`; pill (blue dot +
blue text) reads **`Tik om te starten`** ("Tap to start"). Tapping the card starts.
- **Running, not paused**: time black; pill reads **`Tik om te pauzeren`** ("Tap to pause").
Tapping pauses.
- **Running, paused**: time amber `#D97706`, card border amber `#FDE68A`; pill (amber)
reads **`Gepauzeerd — tik om te hervatten`** ("Paused — tap to resume"). Tapping resumes.
- **Not running, no handling chosen** (`!canStart`): no pill, time greyed, tap is a no-op
(`activeOpacity` 1).
### 4.7 Section 5 — Action buttons (`Knoppen`)
- **When not running**: a single full-width primary button with a `Play` icon, label
**`Start Stopwatch`**. Enabled only when `canStart` (a handling is selected); otherwise greyed
`#E5E7EB` and disabled.
- **When running**: two stacked buttons:
- Red button, `Square` icon, label **`Stop & Opslaan`** ("Stop & Save") → `handleStop()`.
- Below it, the discard button (the **double-press discard**):
- Idle label: **`Annuleren`** ("Cancel"), light grey fill `#F3F4F6`, grey text.
- Armed label: **`Nogmaals tikken ter bevestiging`** ("Tap again to confirm"),
dark fill `#374151`, white text.
### 4.8 Bottom-sheet handling picker (`Modal`)
- Transparent `Modal`, `animationType="none"`, `statusBarTranslucent`. Backdrop is a
`Pressable` (`rgba(0,0,0,0.45)`) that closes the sheet (explicit comment: uses `Pressable`
not nested `TouchableWithoutFeedback` as an Android fix). The sheet is an `Animated.View`
sliding via `translateY: slideAnim` (open → 0 over 300 ms; close → `SHEET_HEIGHT` over 250 ms).
- A drag-handle bar at top.
- **Header**: title **`Type handeling`**, subtitle **`Kies een handeling`** ("Choose a handling").
- **List**: the **filtered** tasks (only those whose `insole_types` includes the current
`insoleType`). Each row shows `task.name`; the selected row is highlighted blue with a trailing
`Check` icon. Tapping a row sets `activeTaskId` and closes the sheet.
- **Empty state** (no handlings for the chosen type): centered grey text —
**`Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via Instellingen.`**
("No handlings available for {type} insoles. Add them via Settings.") — the `{insoleType}`
is the live selected value (e.g. "Berk").
### 4.9 Stopwatch state machine
States are the cross product of `isRunning` × `isPaused` (plus a transient `discardPending`).
```
┌───────────────────────────────────────────────┐
│ IDLE / STOPPED │
│ isRunning=false, isPaused=false, elapsed=0 │
│ (zooltype + handling + count are editable) │
└───────────────────────────────────────────────┘
│ Start (only if canStart === a handling is chosen)
┌───────────────────────────────────────────────┐
┌──────▶ │ RUNNING │ ──────┐
│ │ isRunning=true, isPaused=false │ │
│ │ +1s every second via setInterval │ │
│ Resume │ (selectors locked: zool/handling/count read-only) │ Stop&Save
│ └───────────────────────────────────────────────┘ │
│ │ Pause │
│ ▼ │
│ ┌───────────────────────────────────────────────┐ │
└─────── │ PAUSED │ │
│ isRunning=true, isPaused=true │ │
│ interval cleared (time frozen, amber styling) │ │
└───────────────────────────────────────────────┘ │
│ Cancel ×2 (within 3s) ── DISCARD ──────┐ │
▼ ▼ ▼
back to IDLE (elapsed reset, nothing saved) POST /api/logs → IDLE
```
Transitions, from the handlers:
- **Start** (`handleStart`): guard `if (!activeTaskId) return;` then
`isRunning=true; isPaused=false; startTime=new Date()`.
- **Tick**: `useEffect` on `[isRunning, isPaused]` — when running **and not paused**, sets a 1 s
`setInterval` that does `setElapsedTime(prev => prev + 1)`; otherwise clears it. Cleared on
unmount. (Time is counted purely by interval ticks, **not** by wall-clock diff — so background
throttling could under-count; the rebuild may prefer wall-clock delta.)
- **Pause** (`handlePause`): `isPaused=true`. **Resume** (`handleResume`): `isPaused=false`.
- **Stop & Save** (`handleStop`): guard `if (!activeTaskId || !startTime) return;` then
`isRunning=false; isPaused=false`, compute `endTime=new Date()`, fire `saveLogMutation` (see
4.10), then reset `startTime=null; elapsedTime=0; discardPending=false` and clear the discard
timer.
- **Discard / double-press cancel** (`handleDiscard`):
- First tap: `discardPending=true` and start a 3 s timer that re-clears `discardPending`
(so the confirm window auto-expires after 3 seconds).
- Second tap **within 3 s**: clear the timer and fully reset
(`isRunning=false; isPaused=false; startTime=null; elapsedTime=0; discardPending=false`) —
**nothing is saved**.
This is the "double-press discard": tap `Annuleren` once to arm
(button changes to **`Nogmaals tikken ter bevestiging`**), tap again to actually discard.
After a stop or discard, all three selectors (zooltype, handling, count) become editable again.
Note: after Stop & Save, `activeTaskId`, `insoleType`, and `insoleCount` are **not** reset, so
the next session keeps the previous selections (only the timer resets).
### 4.10 Save payload (`POST /api/logs`)
```ts
saveLogMutation.mutate({
task_id: activeTaskId,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
duration_seconds: elapsedTime,
pair_count: insoleCount,
insole_type: insoleType,
});
```
`duration_seconds` is the accumulated tick count; `pair_count` is the `Aantal zolen` value;
`insole_type` is one of `Kurk` / `Berk` / `3D`. (No `notes` are sent from mobile.)
---
## 5. History screen (`app/(tabs)/history.tsx`) — `Geschiedenis`
### 5.1 Layout
- **Header row**: title **`Geschiedenis`** ("History") on the left; on the right a pill button
with a `Download` icon and label **`Exporteer CSV`** ("Export CSV").
- **Body**: a scrollable list of session cards, fetched via React Query key `['logs']` →
`GET {BASE_URL}/api/logs`.
- **Empty state** (no logs, not loading): centered grey text **`Nog geen opgeslagen sessies.`**
("No saved sessions yet.").
### 5.2 Each log card
Per `log`, a bordered white card showing:
- **Title**: `log.task_name` (the handling name; joined from `production_tasks.name` server-side).
- **Date/time line**: a `Calendar` icon + `{formatDate(log.start_time)} • {formatTime(log.start_time)}`.
- `formatDate` → `toLocaleDateString(undefined, { month:'short', day:'numeric', year:'numeric' })`.
- `formatTime` → `toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' })`.
(Locale-default; `undefined` means the device locale.)
- **Right-side badges** (rendered conditionally):
- `log.insole_type` → a grey pill showing the type verbatim (`Kurk`/`Berk`/`3D`).
- `log.pair_count != null` → a blue pill with a `Layers` icon and
`{pair_count} {pair_count === 1 ? 'inlegzool' : 'inlegzolen'}` — i.e. Dutch singular/plural:
**`inlegzool`** (1 insole) / **`inlegzolen`** (>1 insoles).
- Always: a grey pill with a `Clock` icon showing `formatDuration(log.duration_seconds)`:
```ts
if (hrs > 0) return `${hrs}h ${mins}m`; // e.g. "1h 5m"
if (mins > 0) return `${mins}m ${secs}s`; // e.g. "3m 20s"
return `${secs}s`; // e.g. "45s"
```
### 5.3 CSV export
```ts
const handleExport = async () => {
const exportUrl = `${BASE_URL}/api/export`;
if (await Linking.canOpenURL(exportUrl)) await Linking.openURL(exportUrl);
else Alert.alert('Fout', 'Kan de export-URL niet openen');
};
```
- Tapping **`Exporteer CSV`** opens `GET {BASE_URL}/api/export` in the OS browser/handler
(the device downloads the CSV; the app does not parse it).
- Failure alert: title **`Fout`** ("Error"), body **`Kan de export-URL niet openen`**
("Cannot open the export URL").
- The CSV is produced server-side (`apps/web/api/export`); for parity the rebuilt backend's CSV
should match its shape. Observed columns (English headers, `nl-BE` formatting, quoted, `\n`
joined, ordered by `start_time ASC`):
`ID, Task, Insole Type, No. of Insoles, Date, Total Duration, Start Time, End Time`
- `Insole Type` defaults to `Kurk` if null; `No. of Insoles` defaults to `2` if null.
- `Date` = `nl-BE` `dd-mm-yyyy`; `Start/End Time` = `nl-BE` `HH:MM:SS`;
`Total Duration` = `HH:MM:SS` derived from `duration_seconds`.
- Filename: `insole-production-report.csv`; `Content-Type: text/csv; charset=utf-8`.
---
## 6. Settings screen (`app/(tabs)/tasks.tsx`) — `Instellingen` (handelingen per zooltype)
This screen manages the **handelingen** (handlings/tasks), each tagged with the zooltypes it
applies to. Wrapped in `KeyboardAvoidingView` (iOS `padding`).
### 6.1 Header
- Title **`Instellingen`** ("Settings").
- Subtitle **`Beheer handelingen per zooltype`** ("Manage handlings per insole type").
### 6.2 "Add new handling" card
- Section label (uppercase): **`Nieuwe handeling toevoegen`** ("Add new handling").
- **Name input** with placeholder **`Naam van de stap, bijv. Leerrand`**
("Name of the step, e.g. Leerrand"). (`Leerrand` ≈ "leather edge/rim" — an example step name.)
- A sub-label **`Van toepassing op`** ("Applies to") above three **`TypeToggle`** pills
(`Kurk` / `Berk` / `3D`), each coloured per `TYPE_COLORS`, showing a `Check` when selected.
**Default selection for a new handling is all three types** (`['Kurk', 'Berk', '3D']`).
- **Add button** with a `Plus` icon, label **`Stap toevoegen`** ("Add step"). Disabled (greyed)
unless the name is non-empty (trimmed) **and** at least one type is selected; shows an
`ActivityIndicator` while the mutation is pending.
### 6.3 Handling list
- Section label: **`Huidige stappen ({tasks.length})`** ("Current steps (N)").
- Loading: a blue `ActivityIndicator`.
- Empty: **`Nog geen stappen. Voeg er een toe hierboven.`**
("No steps yet. Add one above.").
- Each handling is a card. **Display mode**:
- `task.name` on the left; on the right two square icon buttons:
- blue `Pencil` button → enters edit mode for that row.
- red `Trash2` button → triggers delete (see 6.5).
- Below: a row of coloured **`TypeBadge`** pills, one per type in `task.insole_types`.
- **Edit mode** (`editingId === task.id`): the card border turns blue and shows:
- an auto-focused name `TextInput` (pre-filled with the current name);
- the **`Van toepassing op`** label + three `TypeToggle` pills (pre-filled from the task's
types; if `insole_types` isn't an array it defaults to all three);
- two buttons: green **`Opslaan`** ("Save", `Check` icon) and grey **`Annuleren`**
("Cancel", `X` icon). Save is disabled while pending or if no types are selected.
### 6.4 Mutations / API
All keyed off React Query `['tasks']`; success invalidates `['tasks']`.
- **Add** (`addTaskMutation`) → `POST {BASE_URL}/api/tasks` body `{ name, insole_types }`.
On success: clears the name and resets type selection to all three.
- **Update** (`updateTaskMutation`) → `PUT {BASE_URL}/api/tasks/{id}` body `{ name, insole_types }`.
On success: exits edit mode.
- **Delete** (`deleteTaskMutation`) → `DELETE {BASE_URL}/api/tasks/{id}`. On success:
invalidates **both** `['tasks']` and `['logs']` (because deleting a task cascades to its logs —
see the server DELETE which removes `time_logs WHERE task_id` first).
Validation: add/update require a non-empty trimmed name and ≥1 selected type;
`name.trim()` is what's sent.
### 6.5 Delete confirmation
```ts
Alert.alert(
'Taak verwijderen',
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Verwijderen', style: 'destructive', onPress: () => deleteTaskMutation.mutate(task.id) },
]
);
```
- Title **`Taak verwijderen`** ("Delete task").
- Body **`"{name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`**
("Delete "{name}"? All time registrations for this task will also be deleted.").
- Buttons **`Annuleren`** (cancel) and **`Verwijderen`** (delete, destructive).
---
## 7. Complete Dutch UI string inventory (verbatim → English)
| Screen | Dutch string (verbatim) | English meaning |
|---|---|---|
| Tabs | `Stopwatch` | Stopwatch (timer tab) |
| Tabs | `Geschiedenis` | History |
| Tabs | `Instellingen` | Settings |
| Stopwatch | `Type zool` | Insole type (section label) |
| Stopwatch | `Kurk` | Cork |
| Stopwatch | `Berk` | Birch |
| Stopwatch | `3D` | 3D (3D-printed) |
| Stopwatch | `Type handeling` | Handling/operation type (label + sheet title) |
| Stopwatch | `Kies een handeling...` | Choose a handling… (dropdown placeholder) |
| Stopwatch | `Kies een handeling` | Choose a handling (sheet subtitle) |
| Stopwatch | `Aantal zolen` | Number of insoles (count label; default 2) |
| Stopwatch | `Tik om te starten` | Tap to start |
| Stopwatch | `Tik om te pauzeren` | Tap to pause |
| Stopwatch | `Gepauzeerd — tik om te hervatten` | Paused — tap to resume |
| Stopwatch | `Start Stopwatch` | Start stopwatch (button) |
| Stopwatch | `Stop & Opslaan` | Stop & Save (button) |
| Stopwatch | `Annuleren` | Cancel (discard button, idle) |
| Stopwatch | `Nogmaals tikken ter bevestiging` | Tap again to confirm (discard armed) |
| Stopwatch | `Geen handelingen beschikbaar voor {type} zolen. Voeg ze toe via Instellingen.` | No handlings available for {type} insoles. Add them via Settings. |
| History | `Geschiedenis` | History (header) |
| History | `Exporteer CSV` | Export CSV (button) |
| History | `Nog geen opgeslagen sessies.` | No saved sessions yet. |
| History | `inlegzool` / `inlegzolen` | insole / insoles (singular/plural in count pill) |
| History | `Fout` | Error (export-failure alert title) |
| History | `Kan de export-URL niet openen` | Cannot open the export URL |
| Settings | `Instellingen` | Settings (header) |
| Settings | `Beheer handelingen per zooltype` | Manage handlings per insole type |
| Settings | `Nieuwe handeling toevoegen` | Add new handling |
| Settings | `Naam van de stap, bijv. Leerrand` | Name of the step, e.g. Leather edge (placeholder) |
| Settings | `Van toepassing op` | Applies to |
| Settings | `Stap toevoegen` | Add step (button) |
| Settings | `Huidige stappen ({n})` | Current steps (N) |
| Settings | `Nog geen stappen. Voeg er een toe hierboven.` | No steps yet. Add one above. |
| Settings | `Opslaan` | Save (edit confirm) |
| Settings | `Annuleren` | Cancel (edit / alert) |
| Settings | `Taak verwijderen` | Delete task (alert title) |
| Settings | `"{name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.` | Delete "{name}"? All time registrations for this task will also be deleted. |
| Settings | `Verwijderen` | Delete (alert destructive button) |
> Terminology nuance: the UI uses **handeling** (label) and **stap** (button/list) somewhat
> interchangeably for the same entity — the production_task. "Handeling" ≈ operation/handling,
> "stap" ≈ step. The data model calls it `production_tasks`. The count noun shown in History is
> **inlegzool / inlegzolen** ("insole/insoles"), distinct from the label "zolen" in
> `Aantal zolen`.
---
## 8. Backend contract the worker app depends on
The mobile client calls `fetch('/api/...')` (relative); `__create/fetch.ts` rewrites first-party
URLs onto `EXPO_PUBLIC_BASE_URL`, injects Create project/host headers, and attaches a
`Bearer <jwt>` from SecureStore. The greenfield backend (apps/api, Hono) must expose equivalent
endpoints. Contract as exercised by the screens (and matched by the legacy `apps/web` routes):
| Endpoint | Method | Request body | Response | Used by |
|---|---|---|---|---|
| `/api/tasks` | GET | — | `production_tasks[]` (`id`, `name`, `insole_types: string[]`), ordered `name ASC` | Stopwatch, Settings |
| `/api/tasks` | POST | `{ name, insole_types[] }` | created task | Settings (add) |
| `/api/tasks/:id` | PUT | `{ name, insole_types[] }` | updated task (404 if missing) | Settings (edit) |
| `/api/tasks/:id` | DELETE | — | `{ success: true }` — **also deletes that task's `time_logs`** | Settings (delete) |
| `/api/logs` | GET | — | `[{ id, task_name, task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes, created_at }]`, ordered `start_time DESC` | History |
| `/api/logs` | POST | `{ task_id, start_time, end_time, duration_seconds, pair_count, insole_type }` | created log | Stopwatch (Stop & Save) |
| `/api/export` | GET | — | CSV attachment (see §5.3) | History (CSV export) |
Server-side rules observed in the legacy `apps/web` routes (worth preserving):
- `insole_types` defaults to `['Kurk','Berk','3D']` when missing/empty on POST/PUT.
- `POST /api/logs` requires `task_id`, `start_time`, `end_time`, and a defined `duration_seconds`;
defaults `pair_count → 2`, `insole_type → 'Kurk'`, `notes → null`.
- `task_name` in log responses comes from `JOIN production_tasks pt ON tl.task_id = pt.id`.
- History orders `start_time DESC` (newest first); CSV export orders `start_time ASC`.
Data model (two tables):
- **`production_tasks`** — `id`, `name`, `insole_types text[]` (subset of `Kurk`/`Berk`/`3D`).
- **`time_logs`** — `id`, `task_id` (FK), `start_time`, `end_time`, `duration_seconds`,
`pair_count`, `insole_type`, `notes`, `created_at`.
---
## 9. Behavioural details easy to miss in the rebuild
- **Landing tab is the Stopwatch** (`(tabs)/index`), titled `Stopwatch`.
- **A handling must be selected to start** (`canStart = !!activeTaskId`); zooltype + count alone
are not enough.
- **Changing the zooltype clears the selected handling** (so you can never run a handling that
doesn't apply to the chosen type). The handling picker only lists handlings whose
`insole_types` include the current zooltype; types with no matching handling show the
"Geen handelingen beschikbaar…" empty state pointing the worker to Instellingen.
- **All three selectors lock while running** (zool buttons, handling dropdown, count stepper +
field). They unlock on stop/discard.
- **Count default is 2**, minimum 1, free-typed via the text field but only committed when a
valid positive integer is typed; sent as `pair_count`.
- **Discard is a deliberate two-tap action** with a 3-second arm window; a single tap never
discards. `Stop & Opslaan` always saves immediately (no confirm).
- **Time is counted by 1-second interval ticks**, not wall-clock difference; `start_time`/
`end_time` are real timestamps but `duration_seconds` is the tick count. Consider wall-clock
delta in the rebuild to survive backgrounding.
- **After a save, selections persist** (only the timer resets) — convenient for repeated
identical sessions.
- **Deleting a handling cascades to its logs**, and the client invalidates the History query so
it refreshes.
- **Font-load resilience**: render even if Inter fails to load (Android freeze workaround).
- **Bottom sheet uses `Pressable` backdrop** (documented Android fix) and an `Animated`
translateY at 75% screen height.
```