Post-M2 self-walkthrough polish, batched into one commit. Map / heat: - fix heat-layer white-screen crash after login (add layer to map before setLatLngs; an off-map leaflet.heat layer has a null _map and throws) - normalize each heat layer to the densest pixel cell visible in the CURRENT viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend, so sparse poo data reaches red and stays normalized at any zoom level - dark CARTO basemap tiles when the color scheme is dark UI: - dark-mode toggle in the top-right, beside the settings gear - switch top-right nav (records / theme / settings / logout) to Feather icons with hover tooltips - home: Grafana-style quick time-range presets + back/forward shift buttons, placed between the From/To pickers and Apply; fix Select/tooltip z-index (Leaflet stacking) and the shift-button height alignment API client: - stop flooding GET /api/session with 401s: the session probe and the login endpoint own their 401s (no global redirect), which fixes the logout hang and the spinning login page Compose: - rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit, non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB); update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and the README "Docker Compose" section Tests: - pixel-grid peak counter, time-range presets, heat-layer ordering regression, and 401-redirect regression
Home Automation — Frontend
React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript. Scaffolded in M2-T06; feature pages filled in by T07–T10.
Stack
| Layer | Library | Version |
|---|---|---|
| Build | Vite | 6.x |
| UI framework | React | 18.x |
| Language | TypeScript | 5.x |
| Component library | Mantine | 7.x |
| Data fetching | TanStack Query | 5.x |
| Routing | react-router-dom | 6.x |
| API client codegen | openapi-typescript | 7.x |
| API client runtime | openapi-fetch | 0.17.x |
| Testing | Vitest + @testing-library/react | 4.x / 14.x |
npm Scripts
| Command | What it does |
|---|---|
npm run dev |
Start Vite dev server (with backend proxy — see below) |
npm run build |
tsc -b && vite build — type-check then build to dist/ |
npm run preview |
Serve the built dist/ locally |
npm run lint |
ESLint (flat config, React + TypeScript rules) |
npm run typecheck |
tsc --noEmit — type-check without emitting files |
npm run test |
Vitest (run once, no watch) |
npm run codegen |
Regenerate src/api/schema.d.ts from ../openapi/openapi.json |
All frontend gates must pass before any task is considered done:
npm run codegen
npm run lint
npm run typecheck
npm run test
npm run build # must produce dist/
Directory Structure
frontend/
├── index.html Vite entry HTML
├── vite.config.ts Vite + Vitest config; dev proxy
├── tsconfig.json References tsconfig.app.json + tsconfig.node.json
├── tsconfig.app.json App source TS config (strict, react-jsx)
├── tsconfig.node.json Vite config TS config
├── eslint.config.js Flat ESLint config (React + TypeScript rules)
├── package.json Dependencies + npm scripts
├── package-lock.json Lockfile (committed; CI uses npm ci)
└── src/
├── main.tsx Entry point; mounts <App> into #root
├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider)
├── vite-env.d.ts /// <reference types="vite/client" /> for CSS imports
├── test-setup.ts Vitest global setup (@testing-library/jest-dom)
├── api/
│ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed)
│ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware
│ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken)
├── auth/
│ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession()
│ └── ProtectedRoute.tsx Redirects to /login when unauthenticated
└── pages/
├── LoginPage.tsx Placeholder → T07 builds the real form
├── HomePage.tsx Placeholder → T09 builds the map/heatmap view
└── ConfigPage.tsx Placeholder → T08 builds the config editor
Dev Proxy (local development)
npm run dev starts Vite on port 5173. The Vite config proxies API/auth paths
to the FastAPI backend running on port 8000:
| Proxied path | Backend URL |
|---|---|
/api/* |
http://localhost:8000 |
/login |
http://localhost:8000 |
/logout |
http://localhost:8000 |
/static/* |
http://localhost:8000 |
/docs |
http://localhost:8000 |
/openapi.json |
http://localhost:8000 |
To develop locally:
- Start the backend:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - Start the frontend:
cd frontend && npm run dev - Open
http://localhost:5173— the app proxies all API calls to the backend.
Since the dev server proxies the session cookie path, auth flows work exactly as they would in the deployed (same-origin) setup.
Adding a New Page + Typed Query
This is the pattern every task T07–T10 follows to wire up a real page:
1. Run codegen (if the OpenAPI contract changed)
npm run codegen
The generated src/api/schema.d.ts is committed to the repo. CI enforces that
the file is in sync with openapi/openapi.json via:
npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts
2. Import the typed client
// src/pages/SomePage.tsx
import apiClient from '../api/client'
3. Write a typed TanStack Query
import { useQuery } from '@tanstack/react-query'
import apiClient from '../api/client'
function usePooRecords(limit = 100) {
return useQuery({
queryKey: ['poo', { limit }],
queryFn: async () => {
const res = await apiClient.GET('/api/poo', { params: { query: { limit } } })
// res.data is typed as PooResponse | undefined
// On non-2xx the middleware throws ApiError; TanStack Query catches it.
return res.data
},
})
}
The params.query and params.path objects are fully typed from schema.d.ts.
TypeScript will error if you pass unknown query params or mistype a path param.
4. Write a typed mutation (write request)
import { useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
function useDeletePoo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (timestamp: string) =>
apiClient.DELETE('/api/poo/{timestamp}', {
params: { path: { timestamp } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
})
}
The middleware (src/api/client.ts) automatically injects the X-CSRF-Token header
on all non-GET/HEAD requests (sourced from getCsrfToken()). You do not need to
handle CSRF manually in page code.
5. Add the route in App.tsx
// App.tsx
import { SomePage } from './pages/SomePage'
// Inside <Routes>:
<Route path="/some-path" element={<SomePage />} />
// or, if protected:
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/some-path" element={<SomePage />} />
</Route>
OpenAPI codegen + CI sync rule
src/api/schema.d.ts is committed to the repository (not gitignored).
Rule: whenever openapi/openapi.json changes (any backend task that modifies
a route or schema), CI must run:
cd frontend && npm run codegen
git diff --exit-code frontend/src/api/schema.d.ts
If the file has changed but the new version was not committed, CI fails.
To update manually after a backend change:
cd frontend
npm run codegen
git add src/api/schema.d.ts
git commit -m "M2-Txx: update generated OpenAPI types"
Production Build
The production build (npm run build) writes static files to frontend/dist/.
In the deployed setup (M2-T11 onwards), FastAPI serves dist/ as a static
directory and falls back to dist/index.html for all non-/api paths,
enabling client-side routing with deep links.
The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and
copies only dist/ into the Python image — the production image does not
contain Node or npm.