feat: planning structure + task 1.2 stack rounding-out

Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
This commit is contained in:
2026-05-02 17:34:34 +02:00
parent c3c83b53f6
commit 26e059fc20
34 changed files with 4868 additions and 127 deletions
@@ -0,0 +1,105 @@
# Task 1.4 — Runtime config endpoint
**Phase:** 1 — Foundation
**Status:** ⬜ Not started
**Depends on:** 1.2
**Wiki refs:** `docs/wiki/entities/react-spa.md` §Auth pattern; `.planning/ROADMAP.md` design rule 7
## Goal
Establish the runtime-config pattern: a `/config.json` file fetched at app boot, validated with zod, made available via a typed React context. Per-environment values (Directus URL, processor WS URL, Google Maps key, feature flags) live there — not in build-time `import.meta.env`. One built image, many deploy environments.
## Deliverables
- `src/config/schema.ts` — zod schema:
```ts
export const RuntimeConfigSchema = z.object({
directusUrl: z.string().url(), // public-facing Directus URL (e.g. https://stage.trmtracking.org/api)
liveWsUrl: z.string().url(), // public-facing Processor WS URL (wss://stage.trmtracking.org/ws-live)
businessWsUrl: z.string().url(), // public-facing Directus WS URL (wss://stage.trmtracking.org/ws-business)
googleMapsKey: z.string().optional(), // BYOK; if absent, Google tile sources are hidden in the UI
env: z.enum(['dev', 'stage', 'prod']), // for log labels and feature flag conditionals
});
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
```
- `src/config/load.ts`:
- `async function loadConfig(): Promise<RuntimeConfig>` — fetches `/config.json`, parses with zod, throws on validation failure.
- On dev, the file is served directly from `public/config.json` by Vite. On stage/prod, the proxy serves it (Traefik file-middleware or a tiny nginx rule).
- `src/config/context.tsx`:
- `RuntimeConfigProvider` — fetches config in a `useEffect` on mount, holds it in state, passes via context.
- `useRuntimeConfig()` hook — throws if used outside the provider, returns the typed config.
- While loading, render a minimal "Loading…" placeholder (one centred div, no shadcn dependency).
- On error, render an error state with the validation issues — clear enough that a misconfigured `config.json` doesn't masquerade as a generic crash.
- `public/config.json` — checked-in file with dev defaults:
```json
{
"directusUrl": "/api",
"liveWsUrl": "/ws-live",
"businessWsUrl": "/ws-business",
"env": "dev"
}
```
Note: relative URLs work because the SPA always runs same-origin (Vite dev proxy or Traefik in stage/prod). Document this in the file's accompanying README.
- `src/main.tsx` updated to wrap `<App />` in `<RuntimeConfigProvider>`.
- `README.md` updated with a "Runtime config" section explaining: where the file lives in dev vs stage/prod, the schema, how to add a new field, the dev defaults.
## Specification
### Why runtime config not build-time
Build-time means one bundle per environment. That breaks reproducibility (dev image ≠ stage image ≠ prod image), prevents promoting a known-good build to prod ("ship the stage build to prod after stage soak"), and forces a rebuild for any env-only change (add a feature flag → rebuild → reupload). Runtime config lets one image serve all environments — the proxy or stack overrides the file.
The Directus-side runtime config (`DIRECTUS_PUBLIC_URL`, etc.) and Processor-side (`DIRECTUS_BASE_URL`, etc.) follow the same pattern. SPA consistency.
### How the file is overridden in stage/prod
Two reasonable options:
**Option A: Static file in the SPA's nginx serve config.** The Dockerfile (1.9) bakes a default `config.json` into the image. Override happens at deploy time: a Traefik file-middleware (or a sibling nginx rule) intercepts `/config.json` and serves a different file from a host volume. Requires zero rebuilds.
**Option B: Server-rendered config.** Directus could serve `/config.json` via a custom endpoint that reads env vars. Rebuildable through Directus.
**Pick Option A.** Simpler, zero new code, deploy-time override is clean. Document the override pattern in `trm/deploy/README.md` (1.10).
### What goes in config vs what doesn't
In config:
- URLs the SPA calls (Directus, Processor WS, business WS).
- Optional API keys the operator brings (Google Maps).
- Environment label for log/feature use.
NOT in config:
- Anything that varies per-user (the user's role, their subscriptions).
- Anything operational secret (no JWT signing keys, no DB passwords).
- Anything that changes during the session (that's state, not config).
### Failure modes
- **`config.json` missing (404):** error state with "Runtime config not found at /config.json. Did the deployment skip its config volume?" Don't fall back to defaults silently — that hides misconfiguration.
- **`config.json` invalid (zod fails):** error state listing the validation issues. Useful for catching typos in stage configs.
- **Network failure on first load:** retry once after 500ms, then show error. Don't infinite-retry — the user can reload the page if the network heals.
### Why a context provider rather than a Zustand store
Auth state needs Zustand (read in many places, granular subscribers). Runtime config is read once and never changes during the session — context is exactly right for that. Don't over-engineer.
## Acceptance criteria
- [ ] `pnpm dev` shows a brief "Loading…" then renders the placeholder content.
- [ ] Manually editing `public/config.json` to invalid shape and reloading shows the error state with the validation issues, not a blank screen.
- [ ] Manually editing `public/config.json` to invalidate URL format (e.g. `directusUrl: "not-a-url"`) shows the same error state.
- [ ] `useRuntimeConfig()` is callable from any descendant of `<RuntimeConfigProvider>` and returns typed config.
- [ ] Calling `useRuntimeConfig()` outside the provider throws a clear error (not "cannot read property X of undefined").
- [ ] The default `public/config.json` validates against the schema.
## Risks / open questions
- **Same-origin assumption.** The relative URLs (`/api`, `/ws-live`) only work if everything is same-origin. If a deploy environment ever wants to point the SPA at a different-origin Directus (e.g. for a partner integration), the config schema accepts absolute URLs — but cookie auth breaks at that point. Document the constraint.
- **Field projection in zod.** If we ever add a sensitive field (a per-tenant analytics key), it would be visible to anyone hitting `/config.json` directly. Don't put anything sensitive there. The schema's `RuntimeConfigSchema.optional()` discipline helps but doesn't enforce.
- **Hot reload of config in dev.** Editing `public/config.json` requires a hard reload (Vite watches the file but the React Provider only fetches on mount). Acceptable for dev.
## Done
(Filled in when the task lands.)