- Dockerfile: three-stage (deps / build / runtime). deps stage runs pnpm fetch with BuildKit cache mount; build stage runs vite build to produce dist/; runtime stage is nginx:1.27-alpine serving the bundle. HEALTHCHECK via wget against localhost. - nginx.conf: gzip on text assets; /assets/ long-cache (hashed filenames immutable); /config.json no-cache (volume-mountable override in stage/prod); /index.html no-cache; SPA routing fallback via try_files ... /index.html. - .dockerignore: keeps the context small (node_modules, dist, env, .git, .gitea, .planning, *.md except README, .claude, .vscode). - .gitea/workflows/build.yml: matches trm/processor shape with format:check added between lint and test. Path filter excludes .planning and pure-markdown changes. Steps: checkout, Node 22, pnpm@latest-9, install --frozen-lockfile, typecheck, lint, format:check, test, buildx, registry login, build & push trm/spa:main, Portainer webhook. Deviations from spec: - Push :main tag only (not :main + per-commit SHA). Matches the other repos; SHA-pinning happens via *_TAG env vars in trm/deploy. SHA tagging is a cross-repo refactor for later. - Pin pnpm@latest-9 (matching existing repos), not pnpm@latest from the spec. Reproducibility win for CI. Smoke: typecheck/lint/format:check/build all green locally. Local docker build not run (Docker unavailable on this machine); CI is the gate. Required for first deploy (1.10 covers the rest): - REGISTRY_USERNAME / REGISTRY_PASSWORD / PORTAINER_WEBHOOK_URL secrets in the Gitea repo settings. - SPA service block in trm/deploy/compose.yaml.
Phase 1 — Foundation
The deployable shell. Scaffold + stack + Directus cookie auth + protected routes + Gitea CI + compose deploy block. After Phase 1 the SPA is infrastructurally complete: an operator can hit the public URL, log in, see a placeholder home page, log out. Live map and operator features are layered on in subsequent phases.
Outcome statement
When Phase 1 is done:
- A user with a valid Directus credential can browse to
https://stage.trmtracking.org(or whatever subdomain the proxy is configured for), be redirected to/login, submit credentials, get a session, and land on a placeholder home page. - Same-domain reverse proxy serves the SPA bundle, Directus REST, Directus business-plane WS, and (when Phase 1.5 of processor lands) the live-position WS — all under one origin so the auth cookie flows naturally to all three.
- Refresh-on-401 is automatic via Directus's
/auth/refresh. The access token never toucheslocalStorage. - Route guards check
user.rolein shape, even though everyone's effectively admin until directus Phase 4. - Logout clears in-memory state + signs out via Directus + redirects to
/login. pnpm typecheck,pnpm lint,pnpm testare green; Gitea CI builds and pushes a Docker image; Portainer redeploys the stack fromtrm/deploy'scompose.yaml.
Sequencing
1.1 Scaffold (done)
└─→ 1.2 Stack rounding-out
├─→ 1.3 Vite dev proxy
│ └─→ 1.4 Runtime config endpoint
│ └─→ 1.5 Directus auth client
│ ├─→ 1.6 Login page
│ │ └─→ 1.7 Routing skeleton ───→ 1.8 Logout flow
│ │
│ └─→ (1.7 also depends on 1.5)
│
└─→ 1.9 Gitea CI + Dockerfile ───→ 1.10 Deploy compose block
1.9 + 1.10 (deploy plumbing) can be developed in parallel with 1.4–1.8 (auth flow) once 1.3 lands. They merge cleanly because they touch different parts of the repo.
Files modified
Phase 1 produces this layout in spa/:
spa/
├── .gitea/workflows/build.yml
├── public/
│ ├── config.json # runtime config; replaced by proxy in stage/prod
│ └── (favicon, etc.)
├── src/
│ ├── main.tsx # already exists
│ ├── App.tsx # rewritten to host the router
│ ├── routes/ # TanStack Router file-based routes
│ │ ├── __root.tsx
│ │ ├── index.tsx # placeholder home
│ │ ├── login.tsx
│ │ └── _authed/ # protected route group
│ │ └── route.tsx
│ ├── auth/
│ │ ├── client.ts # Directus SDK client + auth helpers
│ │ ├── store.ts # Zustand auth store (user, status)
│ │ └── guard.ts # protected-route guard hook
│ ├── config/
│ │ ├── load.ts # fetch /config.json + zod-validate
│ │ ├── schema.ts # zod schema for runtime config
│ │ └── context.tsx # React context provider
│ ├── ui/
│ │ ├── primitives/ # shadcn/ui components live here
│ │ └── pages/
│ │ ├── login.tsx
│ │ └── home.tsx
│ ├── lib/
│ │ └── query-client.ts # TanStack Query singleton
│ └── styles/
│ └── globals.css # Tailwind directives
├── Dockerfile
├── nginx.conf # static-serve config
├── compose.dev.yaml # local dev with Directus + Processor stubs (optional)
├── package.json # extended with the Phase 1 deps
├── tailwind.config.ts
├── postcss.config.js
├── components.json # shadcn/ui config
├── .prettierrc
├── eslint.config.js # extended for Tailwind + Prettier integration
└── README.md
Tech stack additions to the existing scaffold
The Phase 1.1 scaffold already includes Vite 8, React 19, TypeScript 6, ESLint 10, and @vitejs/plugin-react. Phase 1 adds:
tailwindcss+@tailwindcss/vite— utility CSS. Tailwind 4 (the Vite-plugin-based generation, not PostCSS) for cleanliness.shadcn/ui— copy-in component primitives. Not a runtime dep; thecomponents.jsonconfig +npx shadcn add ...populatessrc/ui/primitives/.@tanstack/react-router+@tanstack/router-plugin— file-based routing, type-safe.@tanstack/react-query— server state cache for Directus calls.zustand— high-frequency client state (auth store now; live-position store in Phase 2).@directus/sdk— typed Directus client.zod— runtime validation (config schema, form validation).react-hook-form+@hookform/resolvers— form library.prettier+eslint-config-prettier+eslint-plugin-prettier— formatting.
Test infra (Vitest + React Testing Library) deferred to Phase 3 — Phase 1's tests are minimal and unit-testable with no DOM.
Non-negotiable design rules (phase-specific)
These are in addition to the global rules in the ROADMAP.
- No build-time secrets. No env vars baked into the Vite build that change between environments. Everything env-specific is in
/config.json. - Auth state lives in Zustand, not React Context. Context cascades re-renders on every change; Zustand selectors don't. The auth store is small but it's read in many places.
- Directus SDK over hand-rolled
fetch. Use@directus/sdk's typed client for REST; only fall back to barefetchfor the runtime-config endpoint (which is served by the proxy, not Directus). - One
QueryClientinstance. Module-level singleton, imported wherever needed. No provider re-creating it on render.
Acceptance for the phase as a whole
- All 9 task files (1.2–1.10) done.
pnpm typecheck,pnpm lint,pnpm devclean and runnable locally.pnpm buildproduces a deployable static bundle indist/.- Gitea CI builds and pushes the Docker image; Portainer redeploy puts the SPA at the public URL.
- Manual smoke against stage: navigate to public URL → redirected to
/login→ submit valid Directus credentials → see placeholder home page → click logout → back to/login. End-to-end happy path works. - Wrong credentials show an error on the login form, not a stack trace.
- Refresh-on-401 works without re-prompting login (verifiable by waiting for the access token to expire while idle on the home page; SPA silently refreshes).