The price chart keyed its X axis on formatLocalTime() "HH:mm" labels, which repeat across a today+tomorrow range. Recharts resolves axis tooltips by value (combineTooltipPayload -> findEntryInArray), so hovering a slot after midnight matched today's identically labelled point: the tooltip showed today's prices and the active dot jumped back to today's position instead of following the cursor. Key the axis on the ISO instant instead (buildChartRows, sorted by slot start) and format down to HH:mm in the tick formatter; the tooltip label now carries the date so today and tomorrow are distinguishable. Also mark the price slot currently in effect by default: findActiveSlotIndex() locates the slot containing now, rendered as a ReferenceDot on the buy and sell lines plus a caption, re-evaluated every 30s. Regression test drives a real mousemove over a sized chart in jsdom and asserts the resolved slot and active-dot position.
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.