docs: extract port-worthy reference from legacy code before full cleanup
This commit is contained in:
543
docs/reference/legacy-backend.md
Normal file
543
docs/reference/legacy-backend.md
Normal 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`).
|
||||
290
docs/reference/legacy-lessons-and-gotchas.md
Normal file
290
docs/reference/legacy-lessons-and-gotchas.md
Normal 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.
|
||||
551
docs/reference/legacy-mobile-app.md
Normal file
551
docs/reference/legacy-mobile-app.md
Normal 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: 12–16`, 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.
|
||||
```
|
||||
Reference in New Issue
Block a user