Files
spa/.planning/phase-1-foundation
julian 7e3808237e feat: task 1.9 gitea CI + dockerfile + nginx static serve
- 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.
2026-05-02 18:49:01 +02:00
..

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 touches localStorage.
  • Route guards check user.role in 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 test are green; Gitea CI builds and pushes a Docker image; Portainer redeploys the stack from trm/deploy's compose.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.41.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; the components.json config + npx shadcn add ... populates src/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.

  1. No build-time secrets. No env vars baked into the Vite build that change between environments. Everything env-specific is in /config.json.
  2. 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.
  3. Directus SDK over hand-rolled fetch. Use @directus/sdk's typed client for REST; only fall back to bare fetch for the runtime-config endpoint (which is served by the proxy, not Directus).
  4. One QueryClient instance. 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.21.10) done.
  • pnpm typecheck, pnpm lint, pnpm dev clean and runnable locally.
  • pnpm build produces a deployable static bundle in dist/.
  • 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).