[AZ-447] autodev Steps 1-4 baseline: docs, tests, refactor specs

Captures the full output of autodev existing-code Phase A through
Step 4 (Code Testability Revision) for the Azaion UI workspace:

- Step 1 Document: _docs/02_document/ (FINAL_report, architecture,
  glossary, components/, modules/, diagrams/, system-flows,
  module-layout) plus _docs/00_problem/ + _docs/01_solution/ +
  _docs/legacy/ + _docs/how_to_test + README.
- Step 2 Architecture Baseline: architecture_compliance_baseline.md.
- Step 3 Test Spec: _docs/02_document/tests/ (environment,
  test-data, blackbox/performance/resilience/security/
  resource-limit tests, traceability-matrix), enum_spec_snapshot,
  expected_results/results_report.md (98 rows), plus the
  run-tests.sh + run-performance-tests.sh runners.
- Step 4 Code Testability Revision: 01-testability-refactoring/
  run dir (list-of-changes C01-C07, deferred_to_refactor,
  analysis/research_findings + refactoring_roadmap) and the 7
  child task specs AZ-448..AZ-454 under _docs/02_tasks/todo/
  plus _dependencies_table.md.
- _docs/_autodev_state.md pins the cursor at Step 4 / refactor
  Phase 4 entry so /autodev resumes cleanly.

Epic AZ-447 (UI testability gates) tracks the 7 child tasks that
will land in subsequent commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 00:38:49 +03:00
parent da0a5aa187
commit 510df68bcf
84 changed files with 13065 additions and 0 deletions
@@ -0,0 +1,123 @@
# Module group: `mission-planner/`
> **Single consolidated doc** for the entire `mission-planner/` sub-project (37 modules across `services/`, `flightPlanning/`, `icons/`, `constants/`, `types/`, `utils.ts`, `config.ts`, `main.tsx`, `App.tsx`).
>
> **Why a single doc**: `mission-planner/` is **port-source, not deployed product** (per workspace `README.md` + `00_discovery.md §1`). The Dockerfile builds only the workspace `src/`. Documenting each of the 37 files at the same fidelity as the production SPA would burn context for code that exists only as a reference for the React-19 port. Future deletion of `mission-planner/` is a Step 8 / autodev follow-up once `src/features/flights/` reaches feature-parity.
## Role in the codebase
| Aspect | Status |
|---|---|
| Purpose | Reference implementation of the flight-mission planner UI being mechanically translated into `src/features/flights/`. Stand-alone CRA-flavoured Vite + React 18 + MUI 5 app. |
| Build | Has its own `vite.config.ts`, `tsconfig`, `index.html`, `package.json`. No alias path. |
| Deployment | None — workspace `Dockerfile` does NOT build it; `nginx.conf` does NOT serve it. |
| Tests | `src/test/jsonImport.test.ts` uses `describe/it/expect` style; **Jest is not installed** and there is no `test` script — the test cannot run as-is. Documented gap, no fix planned (out of scope per `00_discovery.md §11.5`). |
| Lifecycle | Will be deleted once `src/features/flights/` covers all mission-planner features. Not before. |
## Directory map
```
mission-planner/src/
├── main.tsx → mounts <LanguageProvider><FlightPlan /> into #root
├── App.tsx UNUSED — empty CRA stub; main.tsx mounts FlightPlan directly
├── config.ts COORDINATE_PRECISION, downang, upang, defaults
├── utils.ts newGuid (Math.random v4)
├── types/index.ts FlightPoint, CalculatedPointInfo, MapRectangle, AircraftParams,
│ WeatherData, MovingPointInfo, ActionMode, MapType, large
│ TranslationStrings interface tree
├── constants/
│ ├── actionModes.ts points / workArea / prohibitedArea
│ ├── maptypes.ts classic / satellite
│ ├── tileUrls.ts OSM + Esri ArcGIS tile URLs
│ ├── translations.ts English + Ukrainian dictionaries (raw, no i18next)
│ ├── languages.ts ISO codes + flag codes for LanguageSwitcher
│ └── purposes.ts tank, artillery
├── services/
│ ├── calculateDistance.ts Haversine + plane climb/cruise/descend
│ ├── AircraftService.ts mockGetAirplaneParams (returns hardcoded fixed-wing)
│ ├── WeatherService.ts OpenWeatherMap fetch
│ └── calculateBatteryUsage.ts Drag + thrust lookup; same algorithm as src/features/flights/flightPlanUtils.calculateBatteryPercentUsed
├── icons/
│ ├── MapIcons.tsx Leaflet icon factories
│ ├── PointIcons.tsx Per-purpose marker icons
│ ├── SidebarIcons.tsx MUI-styled side-panel SVGs
│ └── PhoneIcon.tsx Rotate-phone overlay (mobile orientation hint)
└── flightPlanning/
├── flightPlan.tsx Top-level page (369 lines) — owns state, dialogs, JSON I/O
├── MapView.tsx MapContainer + draw handlers + click-to-add (414 lines)
├── MiniMap.tsx Floating thumbnail; cyclic edge with MapView
├── MapPoint.tsx Draggable waypoint + popup
├── DrawControl.tsx Rectangle draw tool
├── PointsList.tsx Reorderable waypoint list
├── LeftBoard.tsx Side panel composing list + chart + totals + lang switcher
├── AltitudeChart.tsx Chart.js altitude visualizer
├── AltitudeDialog.tsx Add/Edit waypoint modal
├── JsonEditorDialog.tsx Edit-as-JSON modal
├── TotalDistance.tsx Total distance + time + battery readout
├── LanguageContext.tsx React Context for current locale (NOT i18next)
├── LanguageSwitcher.tsx Locale dropdown
├── WindEffect.tsx Wind heading / speed inputs + arrow preview
└── Aircraft.ts Aircraft helper (filename casing odd — TypeScript file, capitalised)
```
## Mapping `mission-planner/` ↔ `src/features/flights/`
The React 19 port translates module-for-module wherever possible. Status as of this commit:
| `mission-planner/src/...` | Ported to `src/features/flights/...` | Status |
|---|---|---|
| `flightPlanning/flightPlan.tsx` | `FlightsPage.tsx` | Ported with backend wiring (Flights API + SSE). MP-side has none. |
| `flightPlanning/MapView.tsx` | `FlightMap.tsx` | Ported. |
| `flightPlanning/MiniMap.tsx` | `MiniMap.tsx` | Ported. |
| `flightPlanning/MapPoint.tsx` | `MapPoint.tsx` | Ported. |
| `flightPlanning/DrawControl.tsx` | `DrawControl.tsx` | Ported. |
| `flightPlanning/PointsList.tsx` | `WaypointList.tsx` | Ported (renamed for parity with backend `Waypoint` entity). |
| `flightPlanning/LeftBoard.tsx` | `FlightParamsPanel.tsx` | Partial — MP-side hosts `LanguageSwitcher`, the SPA delegates language to global `Header` + `react-i18next`. |
| `flightPlanning/AltitudeChart.tsx` | `AltitudeChart.tsx` | Ported. |
| `flightPlanning/AltitudeDialog.tsx` | `AltitudeDialog.tsx` | Ported (file name kept; should be `WaypointDialog`). |
| `flightPlanning/JsonEditorDialog.tsx` | `JsonEditorDialog.tsx` | Ported. |
| `flightPlanning/WindEffect.tsx` | `WindEffect.tsx` | Ported. |
| `flightPlanning/TotalDistance.tsx` | (inlined into `FlightParamsPanel`) | Ported as a `<div>` strip in the params panel; no separate module. |
| `flightPlanning/LanguageContext.tsx` + `LanguageSwitcher.tsx` | (replaced by `react-i18next`) | Not ported as such — language is global via i18next. |
| `flightPlanning/Aircraft.ts` | (no equivalent) | Aircraft is server-side; the SPA fetches `/api/flights/aircrafts`. |
| `services/calculateDistance.ts` | `flightPlanUtils.calculateDistance` | Ported. |
| `services/calculateBatteryUsage.ts` | `flightPlanUtils.calculateBatteryPercentUsed` + `calculateAllPoints` | Ported. |
| `services/WeatherService.ts` | `flightPlanUtils.getWeatherData` | Ported (with the same hardcoded API key — Step 4 fix). |
| `services/AircraftService.ts` | `flightPlanUtils.getMockAircraftParams` (mock only) | Real fetch is `/api/flights/aircrafts` in `FlightsPage`. |
| `constants/translations.ts` + `LanguageContext.tsx` | `src/i18n/{en,ua}.json` + `i18n/i18n.ts` | Migrated to i18next. |
| `constants/{actionModes,maptypes,tileUrls,purposes,languages}.ts` | `features/flights/types.ts` (`PURPOSES`, `TILE_URLS`, `ActionMode`) | Consolidated into one file. |
| `icons/{MapIcons,PointIcons,SidebarIcons,PhoneIcon}.tsx` | `features/flights/mapIcons.ts` | Only the marker icons survived; SidebarIcons + PhoneIcon dropped (no rotate-phone overlay in the SPA today). |
| `utils.ts` (`newGuid`) | `flightPlanUtils.newGuid` | Ported. |
| `config.ts` | `features/flights/types.COORDINATE_PRECISION` | Single constant migrated. |
| `App.tsx` | (none) | Was already an unused CRA stub in MP; nothing to port. |
| `main.tsx` | (none) | Replaced by the workspace `src/main.tsx`. |
| `setupTests.ts`, `test/jsonImport.test.ts` | (none) | Cannot run; not migrated. |
## Things still in MP that are NOT in the SPA port
- **Rotate-phone overlay** (`icons/PhoneIcon.tsx`): MP shows a rotate-phone hint when held in portrait. The SPA does not.
- **Per-purpose marker icons** (`icons/PointIcons.tsx`): MP draws a different marker per `meta` purpose. The SPA uses three colour-coded icons (start / mid / end).
- **`Aircraft.ts` helper class**: never used in the SPA — aircraft state is fetched and treated as a plain DTO.
- **OpenWeather call directly from `WeatherService.ts`**: same flaw as the SPA port (hardcoded key, no proxy). Both flagged for Step 4.
- **MUI 5**: MP uses MUI for dialogs / inputs / icons. The SPA replaced everything with hand-rolled Tailwind components matching `_docs/ui_design/README.md`. MUI is not a dep of the workspace.
## Findings carried into Step 4 / 6 / 8
1. **`mission-planner/src/App.tsx` is an unused CRA stub** — `main.tsx` mounts `FlightPlan` directly. Deletion candidate but only after the port is complete (per `00_discovery.md §11.6`). Step 8.
2. **`mission-planner/src/test/jsonImport.test.ts` cannot run** (Jest not installed; no test script). Out of scope. Step 8 deletion or migration to the suite-level e2e harness.
3. **`flightPlanning/MapView.tsx ↔ MiniMap.tsx`** import each other (`MiniMap` imports the *named* `UpdateMapCenter` helper from `MapView`; `MapView` imports `MiniMap` as a JSX child). Module-level execution is non-circular because each side only uses the type/handle exposed at call time. Ported to `src/features/flights/FlightMap.tsx + MiniMap.tsx` without the cycle.
4. **MP uses raw translation tables** keyed by ISO code, not i18next. The SPA correctly migrated to `react-i18next`; the legacy Russian-language references (if any in `translations.ts`) are out of scope.
5. **`mission-planner/.env.example`** declares `VITE_SATELLITE_TILE_URL` — same env-driven pattern that the workspace `src/` should adopt for the Esri tile URL (currently hardcoded). Carry idea to Step 4.
6. **`mission-planner/package.json`** lockfile is uncommitted (`bun.lock` is workspace-only; MP uses npm without a committed lock). Reproducibility risk if anyone runs MP. Step 8 — when MP is deleted this becomes moot.
7. **Dependency divergence**: MP uses `react-leaflet` 4.2 vs workspace 5.x; `@hello-pangea/dnd` 16 vs 18; `chart.js` shared. None of this affects the deployed bundle. Step 8 — delete MP.
## Tests
The single `jsonImport.test.ts` cannot run. None of the other 36 modules have tests.
## Cross-doc references
- `_docs/02_document/00_discovery.md §1, §2b, §7b, §10, §11` — discovery-level facts about MP.
- Workspace `README.md` — declares MP as not-deployed.
- `mission-planner/README.md` — stale CRA boilerplate, do not trust.
- The 14 `src/features/flights/` modules — see consolidated `src__features__flights.md`.
@@ -0,0 +1,55 @@
# Modules: `src/App.tsx` + `src/main.tsx`
> Compact combined doc — both modules are tiny, top-of-tree wiring only.
## `src/main.tsx` (entry)
Mounts the React tree:
- Calls `createRoot(document.getElementById('root')!)` — the non-null assertion will throw at boot if `<div id="root">` is missing from `index.html` (it is present).
- Wraps in `<StrictMode>` (double-renders effects in dev) and `<BrowserRouter>` (HTML5 history).
- Imports `./i18n/i18n` for **side effects only** — that file calls `i18n.init({...})` at import time. See `src__i18n__i18n.md` for the locked-language finding (lng:'en' hardcoded).
- Imports `./index.css` — the Tailwind 4 stylesheet plus the `az-*` token definitions consumed by every component.
No props, no state, nothing testable.
## `src/App.tsx` (route tree)
Top-level routes:
| Path | Element | Notes |
|---|---|---|
| `/login` | `<LoginPage />` | Public; outside auth + flight providers. |
| `/*` | `<ProtectedRoute><FlightProvider><Header />...nested Routes...</FlightProvider></ProtectedRoute>` | Auth-gated container. Mounts `Header` once across all child routes. |
| `/flights` | `<FlightsPage />` | (default redirect target) |
| `/annotations` | `<AnnotationsPage />` | |
| `/dataset` | `<DatasetPage />` | |
| `/admin` | `<AdminPage />` | (no extra role gate — see Findings) |
| `/settings` | `<SettingsPage />` | (no extra role gate — see Findings) |
| `*` | `<Navigate to="/flights" replace />` | catch-all under the protected branch. |
Outside everything: `<AuthProvider>`. So:
- `LoginPage` can call `useAuth()`.
- `FlightProvider` only mounts after `ProtectedRoute` has confirmed an authenticated user — `FlightContext` queries `/api/flights` only once we know we're logged in. This avoids the 401-then-401-loop on first paint.
Layout: `flex flex-col h-screen` — header at top, content fills the rest with `overflow-hidden`. Each page owns its own scroll/resize.
## Findings carried into Step 4 / 6
1. **`/admin` is reachable by users without ADM permission (defence-in-depth gap)**: `App.tsx:30` route has no permission check. `Header.tsx:88` filters menu visibility via `hasPermission('ADM')`, but typing `/admin` directly bypasses the menu hide. Users without ADM see a partially-working Admin page until the server returns 403 on each write. Per parent `../../../../_docs/00_roles_permissions.md` only Admin / ApiAdmin holds ADM. **PRIORITY** for Step 4. Note: `/settings` is similarly ungated, but `_docs/00_roles_permissions.md` does NOT define a `SETTINGS` permission code — settings calls land on `/api/admin/...` endpoints which are server-enforced by ADM via 403. Open question for Step 6: should `/settings` also be ADM-gated client-side, or is the per-user-settings subset (`/api/admin/users/me/settings`) intended to be reachable by non-admins?
2. **No `<ErrorBoundary>` wrapping the protected branch**: a render error inside any page crashes the whole tree. Step 4 / Step 8.
3. **No lazy-loading of route chunks** (`React.lazy` / `Suspense`). The whole app bundles in one chunk. For now the bundle is small enough that this is acceptable — Step 8 candidate when bundle size grows.
4. **Default redirect target is `/flights`** even for users whose primary task is annotations or dataset. Could be a per-role default landing page. Step 6.
(Earlier draft of this doc claimed there was no mobile bottom-nav — that was incorrect. `Header.tsx:113-129` does render a bottom-nav at `< sm`. The whole-app `flex flex-col h-screen` layout is the same at all breakpoints by design.)
## Tests
None.
## Cross-doc references
- `src__main_tsx` (this doc) ← entry; depended-upon by all others transitively.
- `src/auth/AuthContext.tsx`, `src/auth/ProtectedRoute.tsx` — already documented.
- `src/components/FlightContext.tsx`, `src/components/Header.tsx` — already documented.
- Parent roles spec: `../../../../_docs/00_roles_permissions.md`.
@@ -0,0 +1,109 @@
# Module: `src/api/client.ts`
> **Source**: `src/api/client.ts` (65 lines)
> **Topo batch**: B2 (leaf — no internal imports)
## Purpose
Minimal `fetch` wrapper that injects the JWT bearer token, normalises HTTP errors into `Error` throws, and transparently retries a single time after a 401 by attempting a refresh. Acts as the single HTTP entry point for every page; there is no per-service typed client.
## Public interface
Token plumbing:
```ts
export function setToken(token: string | null): void
export function getToken(): string | null
```
HTTP API:
```ts
export const api = {
get: <T>(url) => Promise<T>
post: <T>(url, body?) => Promise<T>
put: <T>(url, body?) => Promise<T>
patch: <T>(url, body?) => Promise<T>
delete: <T>(url) => Promise<T>
upload: <T>(url, formData: FormData) => Promise<T>
}
```
## Internal logic
- Module-level mutable variable `let accessToken: string | null` holds the current bearer token.
- `request<T>(url, options)`:
1. Build a `Headers` from `options.headers`, inject `Authorization: Bearer <token>` if present.
2. If `options.body` is a `string`, set `Content-Type: application/json`. (Crucial: `upload()` passes a `FormData` body, which is **not** a string, so `Content-Type` is left to the browser to set with the multipart boundary.)
3. `fetch(url, ...)`.
4. On `401` *and* a present token: call `refreshToken()`. On success, set the new bearer and retry the same request once. On failure, clear the token and `window.location.href = '/login'`, then throw "Session expired".
5. Hand off to `handleResponse<T>`.
- `handleResponse<T>(res)`:
- `204``undefined as T`.
- `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
- Otherwise → `res.json()` (no schema validation — caller types the response).
- `refreshToken()``POST /api/admin/auth/refresh` with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean.
## Dependencies
- **Internal**: none.
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.
## Consumers (intra-repo)
The module-level `api` object is imported by:
- `src/auth/AuthContext.tsx` (login / logout / initial refresh)
- `src/components/FlightContext.tsx` (flights list, user settings get/put)
- `src/components/DetectionClasses.tsx` (admin classes load)
- `src/features/admin/AdminPage.tsx`
- `src/features/settings/SettingsPage.tsx`
- `src/features/dataset/DatasetPage.tsx`
- `src/features/flights/FlightsPage.tsx`
- `src/features/annotations/{AnnotationsPage,AnnotationsSidebar,MediaList}.tsx`
`setToken` is imported by `AuthContext` (login / refresh / logout).
`getToken` is imported by `src/api/sse.ts` (to append the token to SSE URLs).
## Data models
None defined here. The generic `T` parameter is supplied by call sites.
## Configuration
URLs are **string literals** at every call site (`/api/admin/...`, `/api/flights?...`, etc.). There is no base-URL constant. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends.
A `VITE_API_BASE_URL` env-var fix is the canonical Step 4 testability candidate (workspace `README.md` calls this out).
## External integrations
Every backend the SPA talks to flows through this module. See `nginx.conf` for the routing table:
| Path prefix | Backend service |
|---|---|
| `/api/admin/*` | `admin/` (.NET) |
| `/api/annotations/*` | `annotations/` (.NET) |
| `/api/flights/*` | `flights/` (.NET) |
| `/api/resource/*` | `satellite-provider/` |
| `/api/detect/*` | `detections/` (Cython) |
| `/api/loader/*` | `loader/` (Cython) |
| `/api/gps-denied-desktop/*` | `gps-denied-desktop/` |
| `/api/gps-denied-onboard/*` | `gps-denied-onboard/` |
| `/api/autopilot/*` | `autopilot/` |
## Security
- **Token storage**: in-memory only (`accessToken: string | null` at module scope). Survives in-tab navigations but not full reloads. The refresh path (`POST /api/admin/auth/refresh` with `credentials: 'include'`) implies the refresh token rides in an HttpOnly cookie set by the `admin/` service. The bearer access token is therefore short-lived and never persisted to `localStorage`. Acceptable XSS posture.
- **401 handling**: redirects to `/login` via `window.location.href` (full page reload) — clears any in-memory state including the bearer.
- **Race condition**: two parallel requests that both 401 will both call `refreshToken()` independently — one will succeed, one may receive a stale token mid-flight. Track for B3/B4 audit; minor under the current usage but should be serialised in Step 8.
- **No CSRF token**: relies on the bearer scheme only; `credentials: 'include'` is set only on `/refresh`, so other endpoints don't carry the cookie. Verify with `admin/` service contract during Step 6 `security_approach.md`.
## Tests
None.
## Notes / open questions
- The `Authorization` header is set BEFORE `refreshToken()` in the retry path, but `refreshToken()` mutates the module-level `accessToken` and the retry then `headers.set('Authorization', \`Bearer ${accessToken}\`)` reads the NEW token. Correct, but worth a comment.
- `request` is typed `<T>` and trusts callers; a runtime schema validation layer (Zod, valibot) would be the right Step 8 hardening but is too heavy for testability scope.
- `upload(url, formData)` does NOT set `Content-Type`, allowing the browser to compute the multipart boundary. This is intentional and correct.
@@ -0,0 +1,73 @@
# Module: `src/api/sse.ts`
> **Source**: `src/api/sse.ts` (25 lines)
> **Topo batch**: B3 (depends on B2 leaf: `api/client` for `getToken()` only)
## Purpose
A 25-line wrapper around the browser's native `EventSource` that (a) appends the current bearer token as an `access_token` query parameter (since `EventSource` does not let callers set headers), (b) parses each `MessageEvent` payload as JSON, and (c) returns a cleanup function. Used by features that listen for server-pushed updates (annotations queue, flight ingestion progress, etc.).
## Public interface
```ts
export function createSSE<T>(
url: string,
onMessage: (data: T) => void,
onError?: (err: Event) => void,
): () => void
```
The returned function closes the underlying `EventSource`. Callers MUST call it on unmount to avoid leaking long-lived connections.
## Internal logic
1. `const token = getToken()` — read the current bearer from `api/client.ts`.
2. Build `fullUrl`:
- If `token` is non-null: append `access_token=<token>` using `&` if the URL already has a query string, `?` otherwise.
- If `token` is null: use `url` as-is.
3. `new EventSource(fullUrl)`.
4. `source.onmessage = (e) => { try { onMessage(JSON.parse(e.data) as T) } catch { /* ignore */ } }`.
- JSON parse errors are silently swallowed. The contract is that the server sends valid JSON; a malformed frame degrades to "skipped".
5. `source.onerror = (e) => onError?.(e)` — forwarded straight through. The browser auto-reconnects by default; `onError` lets callers observe the disconnect.
6. Return `() => source.close()`.
## Dependencies
- **Internal**: `./client``getToken()`.
- **External**: `EventSource` (browser global).
## Consumers (intra-repo)
From the §7a dependency graph:
- `src/features/annotations/AnnotationsSidebar.tsx` — subscribes to the annotations stream.
- `src/features/flights/FlightsPage.tsx` — subscribes to flight ingestion / state updates.
## Data models
None defined here. The generic `T` is supplied by the caller.
## Configuration
URLs are passed in by callers (string-literal at call sites). The same testability remark as `api/client.ts` applies: a `VITE_API_BASE_URL` is the natural Step 4 fix.
## External integrations
Whichever backend exposes the SSE endpoint at the URL the caller provides. Per `nginx.conf`, the suite's `/api/*` reverse proxy forwards SSE traffic by default (no special EventSource-blocking config) — verify in Step 4.
## Security
- **Bearer in query string**: `access_token=<jwt>` ends up in browser-history URLs, server access logs, and proxy logs. This is a **known weakness of the EventSource API** — the API has no headers parameter, so cookie or query are the only options. The trade-off was made knowingly (SSE is a long-lived GET; the bearer is short-lived; nginx access logs are an internal-only artefact). Document in `security_approach.md` (Step 6) and consider rotating to a dedicated SSE-only short-lived token in Step 8.
- **`getToken()` on connect, no refresh**: if the bearer rotates mid-session (via `client.ts`'s 401 retry path), the `EventSource` keeps using the old token and will eventually error. Callers must observe `onError` and reconnect. The current consumers (`AnnotationsSidebar`, `FlightsPage`) do NOT do this — they create the source once on mount. Flag for Step 4 / Step 8.
- **Silent JSON parse error**: a single malformed frame is skipped without a `console.warn`. Acceptable for production noise reduction but obscures real backend bugs in dev. Defer.
## Tests
None.
## Notes / open questions
- **No reconnect / backoff logic** — relies on the browser's built-in EventSource auto-reconnect. The default is "keep retrying"; on a flaky connection this may produce a tight loop with backend logs. Confirm acceptable in Step 6 against the `annotations/` and `flights/` service rate-limit posture.
- **Cleanup on token-less call**: `getToken()` returning `null` produces an unauthenticated `EventSource`. The backend should reject with `401`, the `EventSource` then errors, and `onError?` fires. The caller is expected to interpret this as "user logged out, stop subscribing". None of the current consumers do that explicitly; instead, the `ProtectedRoute` gate prevents `useEffect` from ever running while logged out, so the path is unreachable in practice. Document but no action needed.
- The function is fully synchronous — does not return a `Promise`. The connection is initiated on call but the first message may arrive later. Consumers must handle the "no messages yet" UI state.
- The query-string token assembly does NOT URL-encode the token. JWTs are URL-safe Base64 by default and contain only `AZ, az, 09, -, _, .`, so this is safe for the current token shape. If the token format ever changes, add `encodeURIComponent`. Note for Step 8.
@@ -0,0 +1,116 @@
# Module: `src/auth/AuthContext.tsx`
> **Source**: `src/auth/AuthContext.tsx` (54 lines)
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
## Purpose
The single source of truth for the SPA's authentication state. Wraps the bearer-token plumbing from `api/client.ts` in a React context, exposes `useAuth()` for any descendant component, and bootstraps the session on app start by attempting a refresh. Together with `ProtectedRoute.tsx` and `LoginPage.tsx`, this is the WPF-era `LoginWindow.xaml` + auth service replacement (`_docs/legacy/wpf-era.md` §3 / §4).
## Public interface
```ts
interface AuthState {
user: AuthUser | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
hasPermission: (perm: string) => boolean
}
export function useAuth(): AuthState
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element
```
`AuthContext` itself is module-private (`createContext<AuthState>(null!)`). Consumers must go through `useAuth()`.
## Internal logic
State:
- `user: AuthUser | null``null` when unauthenticated.
- `loading: boolean``true` until the initial refresh attempt resolves (success or failure). Renders should gate on this.
**Bootstrap effect (mount-only)**:
```ts
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
.then(data => { setToken(data.token); setUser(data.user) })
.catch(() => {})
.finally(() => setLoading(false))
```
The refresh endpoint is invoked with `credentials: 'include'` only inside `client.ts`'s **internal** `refreshToken()` helper — but here we go through the public `api.get()` path, which does NOT include credentials. **This is a real divergence**: `client.ts`'s internal `refreshToken()` (used in the 401 retry) sends the cookie; the bootstrap call in `AuthContext` does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) **or** via some other mechanism (a refresh token in `localStorage`, etc.). **Flag for Step 4 verification** against the `admin/` service contract; this is likely a real bug masking by silent `.catch`.
**`login(email, password)`**:
```ts
const data = await api.post<{ token; user }>('/api/admin/auth/login', { email, password })
setToken(data.token); setUser(data.user)
```
Throws to caller (LoginPage) on bad credentials.
**`logout()`**:
```ts
try { await api.post('/api/admin/auth/logout') } catch {}
setToken(null); setUser(null)
```
Network failure on logout is silently swallowed because we want to clear local auth state regardless.
**`hasPermission(perm)`**: returns `user?.permissions.includes(perm) ?? false`. The permission strings are not constrained at the type level — any string passes. Backend-defined.
## Dependencies
- **Internal**:
- `../api/client``api`, `setToken`.
- `../types``AuthUser` type.
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).
## Consumers (intra-repo)
From the §7a dependency graph:
- `src/auth/ProtectedRoute.tsx` — gates routed children on `user !== null`.
- `src/components/Header.tsx` — shows current user, exposes Logout.
- `src/features/login/LoginPage.tsx` — calls `login(...)`, redirects on success.
- `src/App.tsx` — mounts `AuthProvider` near the root.
(Other features rely on the bearer token implicitly via `api/client.ts` — they don't import `useAuth` directly.)
## Data models
`AuthUser` (from `src/types/index.ts`) — see `_docs/02_document/modules/src__types__index.md`. Carries at minimum `id`, `email`, `permissions: string[]`.
## Configuration
Endpoints (string-literal): `/api/admin/auth/refresh`, `/api/admin/auth/login`, `/api/admin/auth/logout`. Routed by `nginx.conf` to the `admin/` service.
No env vars consumed directly — token storage policy is defined in `client.ts` (in-memory; not persisted to `localStorage`).
## External integrations
`admin/` (.NET) auth service via `/api/admin/auth/{refresh,login,logout}`.
## Security
- **In-memory token only**: the bearer is held by `client.ts` at module scope. Survives intra-tab navigation, lost on hard reload — at which point the refresh path must restore it (currently broken per the bootstrap-effect note above).
- **`hasPermission` runs client-side only**: the server is the authority; `hasPermission` is for UI affordances (hide vs. show buttons). The backend MUST re-check permissions on every endpoint. Document in `security_approach.md` (Step 6).
- **Silent error swallowing on bootstrap and logout** is intentional but obscures real failures. A dev-only `console.error` would help during the testability pass; do not add a user-visible toast (silent recovery is the correct UX).
- **No XSS exfiltration risk for the bearer**: in-memory only, never written to `localStorage` or a non-HttpOnly cookie. (Confirmed in `client.ts` doc.)
## Tests
None.
## Notes / open questions
- **Bootstrap-vs-refresh divergence** (above) — the highest-priority flag in this module. Either:
1. The refresh endpoint accepts an Authorization-less, cookie-bearing call → confirm the `admin/` service sets an HttpOnly cookie on `/login` and the cookie path matches `/api/admin/auth/refresh`. The `api.get()` path in `client.ts` does NOT send `credentials: 'include'`, so this currently CANNOT work. → **likely bug**.
2. Or the bootstrap should be calling the internal `refreshToken()` helper, which is currently not exported.
Either way, this needs a Step 4 fix (export `refreshToken()` and call it here, or change `api.get()` to allow per-call `credentials`).
- **`AuthContext = createContext<AuthState>(null!)`**: the non-null assertion means `useAuth()` will throw at the destructuring site if it's used outside `AuthProvider`. Acceptable given `App.tsx` mounts `AuthProvider` at the top, but a guard `if (!ctx) throw new Error(...)` would be friendlier. Defer.
- The `loading` flag is never re-set to `true` after the initial bootstrap. `login` and `logout` complete synchronously from the React tree's perspective (the `await` is inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8.
- `useAuth` returns the raw context value (no memoisation wrapper). React 18+ behaviour means `<AuthProvider>` re-renders all `useAuth` consumers on every state update — fine here because there's no high-frequency state.
@@ -0,0 +1,101 @@
# Module: `src/auth/ProtectedRoute.tsx`
> **Source**: `src/auth/ProtectedRoute.tsx` (19 lines)
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`)
## Purpose
A tiny route guard that gates its children behind an authenticated session.
While `AuthContext` is bootstrapping (`loading === true`) it shows a spinner;
when the bootstrap finishes with no user it redirects to `/login`; otherwise
it renders its children. Mounted exactly once in `App.tsx` between
`AuthProvider` and `FlightProvider` so every authenticated page benefits
from it. WPF parallel: the implicit "no LoginWindow open ⇒ MainWindow"
gate (`_docs/legacy/wpf-era.md` §4).
## Public interface
```ts
interface Props { children: ReactNode }
export default function ProtectedRoute(props: Props): JSX.Element
```
Always returns a `JSX.Element` — never `null`. The three rendered shapes
are:
1. Spinner (centered `<div>` with the orange ring) while `loading`.
2. `<Navigate to="/login" replace />` when `loading === false && user == null`.
3. `<>{children}</>` otherwise.
`replace` is intentional: it rewrites the history entry so the back
button does not return to a protected route the user was bounced off.
## Internal logic
- Single hook call: `const { user, loading } = useAuth()`. No local state.
- Branch order matters — `loading` is checked before `user` so a freshly
reloaded tab never renders the login redirect during the in-flight
refresh attempt. (See the open `AuthContext` bootstrap-vs-refresh
divergence flagged in `src__auth__AuthContext.md`; if that bug is
fixed the spinner duration becomes accurate.)
## Dependencies
- **Internal**: `./AuthContext``useAuth`.
- **External**: `react-router-dom` (`Navigate`), `react` (`ReactNode` type only).
## Consumers (intra-repo)
From the §7a dependency graph:
- `src/App.tsx` — wraps the entire authenticated route tree:
`AuthProvider → ProtectedRoute → FlightProvider → Header + nested Routes`.
Not used anywhere else; the SPA has a single protected zone.
## Data models
None.
## Configuration
The `/login` redirect target is hardcoded. Matches the public route
declared in `App.tsx`. If the public route is ever renamed, update both
sites.
The spinner uses Tailwind tokens `bg-az-bg`, `border-az-orange`
(defined in `src/index.css`).
## External integrations
None directly. Indirectly relies on whatever `AuthContext` calls during
bootstrap — currently `GET /api/admin/auth/refresh`.
## Security
- **No token check is duplicated here** — relies entirely on `AuthContext`.
The component cannot be confused into rendering protected children
before the bootstrap resolves because `loading` defaults to `true` in
`AuthProvider`.
- Backend authority is unchanged — this is a UI affordance only. Every
request the children make MUST also be enforced server-side.
- The redirect uses `Navigate`, so query params on the original URL are
lost. Acceptable today (no protected route relies on them); flag if a
future "deep-link after login" UX appears.
## Tests
None.
## Notes / open questions
- The spinner has no `role="status"` or accessible label — screen readers
hear nothing while the bootstrap runs. Cosmetic; flag for Step 4
verification against `_docs/ui_design/README.md` accessibility notes.
- No timeout on the `loading` state — if `AuthContext`'s bootstrap
somehow never resolves (e.g., the refresh endpoint hangs), the user
sees an infinite spinner. `client.ts` does not currently set a
request timeout either; flag jointly with Step 4.
- The `<>{children}</>` Fragment wrap is intentional: returning `children`
directly would make the type `ReactNode` rather than `JSX.Element` and
would not satisfy the route element slot in `react-router-dom@7`.
@@ -0,0 +1,74 @@
# Module: `src/components/ConfirmDialog.tsx`
> **Source**: `src/components/ConfirmDialog.tsx` (47 lines)
> **Topo batch**: B3 (uses `react-i18next` only; no intra-repo deps)
## Purpose
A controlled, single-message confirm dialog used wherever the SPA needs an "Are you sure?" gate. Replaces the legacy WPF `MessageBox.Show(..., MessageBoxButton.YesNo, ...)` pattern (`_docs/legacy/wpf-era.md` §4 / §"What survived").
## Public interface
```ts
interface Props {
open: boolean
title: string
message?: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog(props: Props): JSX.Element | null
```
When `open === false`, returns `null`. When `open === true`, renders a centered modal over a dimmed backdrop. The component is fully controlled — it owns no `open` state itself.
## Internal logic
- **Auto-focus**: when `open` flips to `true`, the Cancel button is focused via `cancelRef.current?.focus()` (see `useEffect` keyed on `open`). This makes Cancel the default keyboard target — a common pattern for destructive confirmations.
- **Escape-to-cancel**: a `keydown` handler is attached to `window` while `open === true`; pressing `Escape` calls `onCancel()`. The listener is removed on unmount or when `open` flips to `false`. This is the **only** dismiss path other than the explicit Cancel/Confirm buttons (no backdrop click handler).
- **Translation**: button labels use `t('common.cancel')` and `t('common.confirm')`. Title and message are passed in by the caller — the caller is responsible for translation.
## Dependencies
- **Internal**: none.
- **External**: `react` (`useEffect`, `useRef`), `react-i18next` (`useTranslation`).
## Consumers (intra-repo)
From the §7a dependency graph in `_docs/02_document/00_discovery.md`:
- `src/features/admin/AdminPage.tsx` — confirm before deleting users.
- `src/features/annotations/MediaList.tsx` — confirm before deleting media.
- `src/features/flights/FlightsPage.tsx` — confirm before deleting a flight.
- `src/features/dataset/DatasetPage.tsx` — confirm before deleting dataset items.
(`HelpModal.tsx` is the *non-confirm* sibling; it does NOT use `ConfirmDialog` and notably does **not** have Escape-to-close — see `src__components__HelpModal.md` §Notes.)
## Data models
None.
## Configuration
Tailwind tokens used: `bg-az-panel`, `border-az-border`, `text-az-text`, `bg-az-red`, `bg-az-bg`, `text-white`. All defined in `src/index.css` (per `_docs/02_document/00_discovery.md` §2a).
`z-[100]` is the chosen stacking layer. No other modal currently competes with it; if a third-party library introduces a portal at `z-[200]+`, layering will need a docs entry.
## External integrations
None.
## Security
- **No backdrop dismissal**: clicking outside the dialog does NOT cancel. Combined with Escape-to-cancel and a default-focused Cancel button, this gives a deliberate, keyboard-safe destructive-action flow.
- **No `aria-modal` / role="dialog"**: not annotated for screen readers; flag for Step 4 verification against the `_docs/ui_design/README.md` §"Confirmation dialogs" spec (which specifies modal semantics).
## Tests
None.
## Notes / open questions
- The "destructive" colour (`bg-az-red`) is hardcoded into the Confirm button. Some callers use `ConfirmDialog` for non-destructive confirms (e.g. flight selection in some flows — verify in B7); a future variant prop (`destructive: boolean`) would be cleaner. Defer to Step 8.
- No `width` prop — the dialog is fixed at `w-80` (320px). On the mobile breakpoint defined in `_docs/ui_design/README.md` §"Responsive Breakpoints" (640px), this fits, but very long titles or multi-line messages will overflow. Flag for Step 4 cosmetic verification.
- Escape handler attaches to `window`, not the dialog DOM. If two `ConfirmDialog`s are mounted simultaneously (the SPA never does this today, but it's not enforced), both would call their `onCancel()` on a single Escape press. Acceptable under current usage.
@@ -0,0 +1,99 @@
# Module: `src/components/DetectionClasses.tsx`
> **Source**: `src/components/DetectionClasses.tsx` (99 lines)
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `features/annotations/classColors`, `types/index`)
## Purpose
A side-panel widget that lets the annotator pick a detection class (19 or click) and a photo mode (`Regular`, `Winter`, `Night`). Loads the class catalogue from the backend and falls back to a hardcoded list when the API returns empty / errors. Replaces the legacy WPF detection-class picker referenced in `_docs/legacy/wpf-era.md` §"What survived".
## Public interface
```ts
interface Props {
selectedClassNum: number
onSelect: (classNum: number) => void
photoMode: number
onPhotoModeChange: (mode: number) => void
}
export default function DetectionClasses(props: Props): JSX.Element
```
Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The current contract for `photoMode` values is integer offsets `0` (Regular), `20` (Winter), `40` (Night), matching the `photoMode` field of `DetectionClass` (`src/types/index.ts`) and the offsets baked into `FALLBACK_CLASSES` below.
## Internal logic
- **Class catalogue load** (mount-only `useEffect`):
- `api.get<DetectionClass[]>('/api/annotations/classes')`.
- On a non-empty array → `setClasses(list)`.
- On an empty array OR a thrown error → `setClasses(FALLBACK_CLASSES)`.
- **`FALLBACK_CLASSES`** is a module-private 3 × |`FALLBACK_CLASS_NAMES`| matrix:
- For each mode offset in `[0, 20, 40]`, build one class entry per name in `FALLBACK_CLASS_NAMES` (imported from `features/annotations/classColors.ts`).
- Each entry: `{ id: i + modeOffset, name, shortName: name.slice(0, 3), color: getClassColor(i), maxSizeM: 10, photoMode: modeOffset }`.
- This means: in offline / API-down mode, the ID range is `0N-1` (Regular), `2020+N-1` (Winter), `4040+N-1` (Night). The backend's class IDs MUST follow the same convention or fallback↔backend handoff yields ID collisions. Confirm in Step 4 via the `annotations/` service contract.
- **Numeric hotkeys 19** (effect keyed on `[classes, photoMode, onSelect]`):
- `keydown` on `window`. `parseInt(e.key)` → if 19, picks `classes[(num - 1) + photoMode]`.
- **Bug-shaped**: `photoMode` is `0 | 20 | 40` (an *offset*), not a row count. `classes[idx + 0]` is correct for Regular; for Winter/Night the index `idx + 20` / `idx + 40` is meaningful only when `classes` is in the contiguous `[0..N-1, 20..20+N-1, 40..40+N-1]` shape that `FALLBACK_CLASSES` produces. **If the backend returns its classes in a different order**, hotkey 19 will pick the wrong class. Verify backend ordering in Step 4 — this is the principal correctness risk in this module. Flag.
- **Auto-select first class on mode change** (effect keyed on `[classes, photoMode, selectedClassNum, onSelect]`):
- Filter `classes` to the active `photoMode`.
- If `selectedClassNum` is not within that filtered set, call `onSelect(modeClasses[0].id)`.
- This guarantees the parent always holds a class ID consistent with the selected photo mode.
- **Render**:
- Class list — only entries whose `photoMode` matches the active mode.
- Photo-mode bar — three buttons (Sunny / Snowflake / Moon icons) for Regular / Winter / Night.
## Dependencies
- **Internal**:
- `../api/client``api.get<T>()`.
- `../features/annotations/classColors``getClassColor(i)`, `FALLBACK_CLASS_NAMES`.
- `../types``DetectionClass` type.
- **External**: `react`, `react-i18next`, `react-icons/md`, `react-icons/fa`.
## Consumers (intra-repo)
From the §7a dependency graph:
- `src/features/annotations/AnnotationsPage.tsx`
- `src/features/dataset/DatasetPage.tsx`
This is the **canonical example** of the cross-layer import flagged in `_docs/02_document/00_discovery.md` §8: a `components/` (shared) module importing from `features/annotations/`. Two clean fixes are in scope for Step 4 / Step 8:
1. Lift `classColors.ts` into `src/components/detection/` (or `src/shared/`) and update the two consumers.
2. Or: move `DetectionClasses` itself into `src/features/annotations/` since both consumers are in `features/`.
## Data models
`DetectionClass` (from `src/types/index.ts`) — see `_docs/02_document/modules/src__types__index.md`.
`FALLBACK_CLASSES` is module-private; see Internal logic above.
## Configuration
Endpoint: `/api/annotations/classes` — string-literal URL (testability fix scheduled for Step 4).
Photo-mode value set is `{0, 20, 40}` — hardcoded, mirrored by `FALLBACK_CLASSES`. If the backend grows a fourth mode (e.g. thermal, IR), every consumer of `photoMode` will need a coordinated change.
Tailwind tokens: `bg-az-orange` (Regular), `bg-az-blue` (Winter), `bg-purple-600` (Night). Defined in `src/index.css`.
## External integrations
- HTTP `GET /api/annotations/classes``DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
## Security
- **Silent failure on class load**: `.catch(() => setClasses(FALLBACK_CLASSES))` swallows the error and the user sees the fallback. Acceptable for UX continuity, but the lack of any user-visible signal means a misconfigured `/api/annotations/classes` deploy could go unnoticed in prod. Flag for Step 6 `security_approach.md` / Step 8.
- No input that flows back to the server here.
- The `getClassColor(i)` palette is deterministic; no PII.
## Tests
None.
## Notes / open questions
- **`getPhotoModeSuffix` redundancy** (carry-over from B1): `classColors.ts` exposes `getPhotoModeSuffix()` that derives the same suffix the typed `DetectionClass.photoMode` field already encodes. Once `classColors.ts` is repositioned (see Consumers), `getPhotoModeSuffix` is a deletion candidate. Defer to Step 8.
- **`FALLBACK_CLASS_NAMES.length`** is implicitly assumed to be ≤ 9 by the hotkey code (only 19 are bound). If the catalogue grows to 10+ entries, hotkeys can no longer cover the tail. Acceptable for now.
- **Mode-button colours don't use `az-` tokens for Night** (`bg-purple-600`, `text-purple-400` instead of an `az-purple` token). Cosmetic inconsistency; flag for Step 4 against `_docs/ui_design/README.md` colour palette.
- The class list is rendered with `1.`, `2.`, … prefixes derived from `i+1` — so the hotkey number always matches the visible label inside the active mode. Good.
- Does not handle modifier keys (`Shift`, `Ctrl`) on numeric hotkeys; pressing `Shift+5` will trigger the `5` branch. Fine for now.
@@ -0,0 +1,106 @@
# Module: `src/components/FlightContext.tsx`
> **Source**: `src/components/FlightContext.tsx` (52 lines)
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
## Purpose
A React context that holds the SPA's currently-selected flight and the cached flight list, keeps the selection in sync with the per-user backend setting, and exposes a refresh trigger. Sibling of `AuthContext.tsx` — both are mounted near the root (`App.tsx`) so any feature page can access flight state without prop-drilling. Replaces the legacy WPF "current mission" singleton (`_docs/legacy/wpf-era.md` §"What survived").
## Public interface
```ts
interface FlightState {
flights: Flight[]
selectedFlight: Flight | null
selectFlight: (f: Flight | null) => void
refreshFlights: () => Promise<void>
}
export function useFlight(): FlightState
export function FlightProvider({ children }: { children: ReactNode }): JSX.Element
```
`FlightContext` itself is module-private. Consumers must go through `useFlight()`.
## Internal logic
State:
- `flights: Flight[]` — most recent list returned by `GET /api/flights?pageSize=1000`.
- `selectedFlight: Flight | null` — the active flight, or `null` if none. Survives across pages because the provider is mounted above the route tree.
**`refreshFlights()`** (`useCallback`, no deps):
```ts
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
setFlights(data.items ?? [])
```
Errors are silently swallowed (`try { ... } catch {}`). `pageSize=1000` is a hardcoded ceiling — see Configuration.
**Bootstrap effect** (`useEffect` keyed on `[refreshFlights]`, runs once because `refreshFlights` is `useCallback([])`):
1. `refreshFlights()` (no `await` — runs in parallel with #2).
2. `api.get<UserSettings>('/api/annotations/settings/user')`
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>('/api/flights/${settings.selectedFlightId}')``setSelectedFlight(f)`.
- errors at every step silently swallowed.
The selected flight is therefore looked up by **its own GET**, not by indexing into the cached `flights` list. This is intentional — the user might have a `selectedFlightId` that fell off the first 1000 flights.
**`selectFlight(f)`** (`useCallback`, no deps):
```ts
setSelectedFlight(f)
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
```
Optimistic — local state updates immediately; the persisted setting is fire-and-forget. If the PUT fails, the next reload will fall back to the previously-stored ID and the user's selection silently reverts. Flag for Step 4.
## Dependencies
- **Internal**:
- `../api/client``api`.
- `../types``Flight`, `UserSettings` types.
- **External**: `react` (`createContext`, `useContext`, `useState`, `useEffect`, `useCallback`, `ReactNode`).
## Consumers (intra-repo)
From the §7a dependency graph:
- `src/App.tsx` — mounts `FlightProvider` inside `ProtectedRoute` and above the route tree (so `selectedFlight` survives navigation between `/flights`, `/annotations`, `/dataset`).
- `src/components/Header.tsx` — displays the currently-selected flight name.
- `src/features/flights/FlightsPage.tsx` — the primary editor; calls `selectFlight` on row click.
- `src/features/annotations/MediaList.tsx` — filters media by `selectedFlight.id`.
- `src/features/dataset/DatasetPage.tsx` — same.
## Data models
- `Flight` and `UserSettings` from `src/types/index.ts` — see `_docs/02_document/modules/src__types__index.md`.
## Configuration
Endpoints (string-literal): `/api/flights?pageSize=1000`, `/api/flights/{id}`, `/api/annotations/settings/user`. Routed by `nginx.conf` to the `flights/` and `annotations/` services.
`pageSize=1000` is a **hardcoded ceiling** — if the system ever has >1000 flights, the cache is silently truncated. The `flights/` service contract probably caps at 1000; verify and document in `data_parameters.md` (Step 6). Flag.
## External integrations
- `flights/` (.NET) — `GET /api/flights`, `GET /api/flights/{id}`.
- `annotations/` (.NET) — `GET /api/annotations/settings/user`, `PUT /api/annotations/settings/user`. The user-settings store is reused for the selected-flight pointer; this is a slight abstraction leak (selected flight is logically a UI-state preference, not an annotation setting), but it works as long as `UserSettings` keeps a `selectedFlightId` field.
## Security
- **No auth checks here** — relies on `api/client.ts` to inject the bearer; backend enforces visibility.
- **Silent failure on every async call** — same caveat as `AuthContext`. A misconfigured `/api/flights` will leave the user with an empty list and no error indication. Flag for Step 6 `security_approach.md` and Step 8 hardening.
## Tests
None.
## Notes / open questions
- **Race**: bootstrap fires `refreshFlights()` AND the user-settings GET in parallel. If the user-settings GET resolves first with a `selectedFlightId` that's not in the 1000 cached flights, the per-flight GET still succeeds. If the 1000 list is somehow stale and the per-flight GET 404s, `selectedFlight` stays `null` and the user sees nothing selected. Acceptable but worth documenting.
- **`selectFlight(null)` PUTs `{ selectedFlightId: null }`** — this clears the persisted preference. Confirm the `annotations/` service treats this as "unset" and not as an error or no-op. Flag for Step 4.
- **No realtime invalidation**: if another tab / user creates a flight, this client won't know until the next `refreshFlights()` call. The SPA also has SSE (`api/sse.ts`) but does NOT subscribe to flight updates. Mark as a Step 8 / future-feature hook.
- **`useCallback([])` for `refreshFlights`** is fine because `setFlights` is stable. The empty deps array means the bootstrap effect fires exactly once (as intended) — but ESLint with `react-hooks/exhaustive-deps` would still flag both `useCallback`s for missing `setFlights` (technically stable, but the linter doesn't know). Acceptable; project-wide ESLint config does not currently enforce.
@@ -0,0 +1,152 @@
# Module: `src/components/Header.tsx`
> **Source**: `src/components/Header.tsx` (133 lines)
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`, `components/FlightContext`, `components/HelpModal`, `types/index`)
## Purpose
The persistent top navigation bar of the authenticated SPA. Combines
five concerns into one component: brand mark, currently-selected-flight
dropdown, top-level navigation links (permission-gated), session info
(user email + Logout), language toggle (EN ↔ UA), and a `?` button that
opens `HelpModal`. Also renders a duplicate bottom-nav on the mobile
breakpoint. Replaces the legacy WPF top ribbon + window chrome
(`_docs/legacy/wpf-era.md` §4 / §"What survived").
## Public interface
```ts
export default function Header(): JSX.Element
```
No props — Header reads everything it needs from `AuthContext`,
`FlightContext`, and `react-i18next`. Mounted exactly once in `App.tsx`
above the protected route tree.
## Internal logic
- **Local state**:
- `showDropdown: boolean` — flight selector open/closed.
- `filter: string` — text filter for the flight selector list.
- `showHelp: boolean` — controls the `HelpModal` `open` prop.
- `dropdownRef: RefObject<HTMLDivElement>` — used to detect outside
clicks.
- **Outside-click effect**: registers a `mousedown` listener on `document`
while mounted; if the event target is not inside `dropdownRef.current`,
closes the dropdown. Listener is removed on unmount. (Always-on, even
while the dropdown is closed — cheap, but not the most surgical
pattern; flag to consider gating on `showDropdown` in Step 8.)
- **Filtered flights**: `flights.filter(f => f.name.toLowerCase().includes(filter.toLowerCase()))`
— case-insensitive substring match on the flight name. Empty filter
shows everything.
- **Logout**: `await logout(); navigate('/login')`. Always navigates,
even if `logout()` throws (it never does — `AuthContext.logout` swallows
network errors by design).
- **Language toggle**: `i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')`.
Same two-language assumption as `HelpModal` (treats every non-`'ua'`
value as English-equivalent). The toggle button label is the
*target* language ("UA" while EN is active, "EN" while UA is active).
- **Permission-gated nav** (`navItems`):
| `to` | `label` key | `perm` |
|---|---|---|
| `/flights` | `nav.flights` | `FL` |
| `/annotations` | `nav.annotations` | `ANN` |
| `/dataset` | `nav.dataset` | `DATASET` |
| `/admin` | `nav.admin` | `ADM` |
`Settings` (`/settings`) is always rendered — no permission gate.
Filter applied via `navItems.filter(n => hasPermission(n.perm))`.
- **Layout**: a single `<header>` containing brand → flight dropdown →
primary nav (`hidden sm:flex`) → spacer (`flex-1`) → user email
(`hidden sm:block`) → language toggle → `?``⚙` (settings link) →
logout button. A second `<nav>` element below is `sm:hidden` and
positions itself fixed at the bottom of the viewport — the mobile
nav. Both navs share the same filter logic.
## Dependencies
- **Internal**:
- `../auth/AuthContext``useAuth` (user, logout, hasPermission).
- `./FlightContext``useFlight` (flights, selectedFlight, selectFlight).
- `./HelpModal` — opens on `?` click.
- `../types``Flight` type for the dropdown row.
- **External**: `react-router-dom` (`NavLink`, `useNavigate`),
`react-i18next` (`useTranslation`),
`react` (`useState`, `useRef`, `useEffect`).
## Consumers (intra-repo)
- `src/App.tsx` — mounted inside `ProtectedRoute → FlightProvider`.
No other intra-repo consumer; `Header` is a top-level chrome component.
## Data models
The `navItems` array is a module-private literal of
`{ to: string; label: string; perm: string }`. The filtered `flights`
list is `Flight[]` (from `types/index.ts`).
## Configuration
- **i18n keys consumed**: `nav.flights`, `nav.annotations`, `nav.dataset`,
`nav.admin`, `nav.logout`. (Verified to exist in `src/i18n/en.json`.)
The filter placeholder ("Filter…") and the empty-state ("— Select
Flight —", "No flights") are hardcoded strings — flag for Step 4.
- **Tailwind tokens**: `bg-az-header`, `border-az-border`, `bg-az-panel`,
`text-az-text`, `text-az-muted`, `text-az-orange`, `text-az-red`,
`bg-az-bg`. Defined in `src/index.css`.
- **Breakpoint**: `sm:` from Tailwind defaults to 640px — matches the
responsive-breakpoint spec in `_docs/ui_design/README.md`.
- **Permission strings**: `FL`, `ANN`, `DATASET`, `ADM`. Backend-defined
by the `admin/` service; treated as opaque strings here.
## External integrations
None directly. Triggers `logout()` which calls `POST /api/admin/auth/logout`
inside `AuthContext`, and `selectFlight()` which calls `PUT /api/annotations/settings/user`
inside `FlightContext`.
## Security
- `hasPermission(perm)` is **client-side advisory** — a user with the
`/admin` route URL can navigate there without going through the nav
link. The backend `admin/` service is the authority. Document in
`security_approach.md` (Step 6).
- The flight-selector dropdown displays every flight returned by
`GET /api/flights?pageSize=1000` — there is no permission check here.
If `flights/` ever returns flights the user should not see, this
component would leak them. Trust the backend filter.
- `user?.email` is rendered raw in the header — React JSX-escapes it,
so XSS via a malicious email is not possible at this layer, but
validate that the `admin/` service enforces email format.
## Tests
None.
## Notes / open questions
- **Outside-click listener is always attached** — slightly wasteful when
the dropdown is closed. Gating on `showDropdown` would also remove a
closure capture. Defer to Step 8.
- **Dropdown is not keyboard-accessible**: no `role="combobox"`, no
`aria-expanded`, no Esc-to-close, focus does not move into the filter
input on open beyond `autoFocus` (which works only the first time
the input mounts). Flag against `_docs/ui_design/README.md` keyboard
shortcuts for Step 4.
- **Mobile bottom nav duplicates `navItems`** twice in source — DRY win
by extracting a `<NavSet items={...}/>` subcomponent, but a deferral
candidate.
- **Hardcoded English strings** ("AZAION", "— Select Flight —",
"Filter...", "No flights"). Brand mark is intentional; the others
are content and should move to `nav.*` keys. Step 4 candidate.
- **`/settings` link** is unguarded by `hasPermission`. Per
`_docs/ui_design/README.md` settings page is available to every
authenticated user — confirmed intent.
- **`new Date(f.createdDate).toLocaleDateString()`** uses the browser's
default locale, not `i18n.language`. Mild inconsistency; Step 8
cosmetic.
- **`flights` cache truncation**: the dropdown shows at most 1000
flights because of the hardcoded `pageSize=1000` in `FlightContext`.
Documented as a flag there; Header inherits the limitation.
@@ -0,0 +1,64 @@
# Module: `src/components/HelpModal.tsx`
> **Source**: `src/components/HelpModal.tsx` (62 lines)
> **Topo batch**: B2 (uses `react-i18next` for the language flag only; no intra-repo deps)
## Purpose
A modal dialog that surfaces annotation guidelines and the keyboard-shortcut cheat-sheet. Triggered from `Header.tsx` → "Help" entry. Replaces the legacy WPF `HelpWindow.xaml` (`_docs/legacy/wpf-era.md` §4).
## Public interface
```ts
interface Props {
open: boolean
onClose: () => void
}
export default function HelpModal(props: Props): JSX.Element | null
```
When `open === false`, returns `null`. Otherwise renders a centered `<div>` overlay.
## Internal logic
- Reads `i18n.language` from `useTranslation()`; treats anything other than `'ua'` as English (`lang = i18n.language === 'ua' ? 'ua' : 'en'`).
- `GUIDELINES`: a hardcoded array of 6 `{ en, ua }` rules covering annotation quality. **Hardcoded — not loaded via the `i18n` resource bundle**, despite the module already importing `useTranslation`. (Inconsistency vs. the rest of the SPA — flag for Step 4.)
- Renders the guidelines as a numbered list and a 12-row keyboard-shortcut grid (`Space`, `← →`, `Ctrl + ← →`, `Enter`, …).
- Click on the dim backdrop closes the modal; click inside is `stopPropagation`'d.
- `Close` button is a styled `<button>` calling `onClose()`.
## Dependencies
- **Internal**: none.
- **External**: `react-i18next` (for `useTranslation()` only — to detect the language; *not* for content lookup).
## Consumers (intra-repo)
- `src/components/Header.tsx` — owns `open` state and the trigger button.
## Data models
The `GUIDELINES` array — module-private. Each entry: `{ en: string, ua: string }`.
## Configuration
The keyboard-shortcut grid hardcodes the same shortcut taxonomy that `_docs/ui_design/README.md` §"Keyboard Shortcuts" enumerates. Verify parity during Step 4.
## External integrations
None.
## Security
None directly. Note that ESCAPE-key handling appears in the *sibling* `ConfirmDialog` but **not** here — Esc currently does NOT close `HelpModal`. Backdrop click is the only dismiss path beyond the Close button. Inconsistent UX; flag for Step 4 verification against the UI spec.
## Tests
None.
## Notes / open questions
- The 6 guidelines stored as `{ en, ua }` literals duplicate the Annotation Quality Guidelines documented in `_docs/ui_design/README.md` §"Annotation Quality Guidelines" (which lists six rules with subtly different wording). Choose one source of truth in Step 5; preferred is to lift the strings into `en.json` / `ua.json` like the rest of the SPA.
- The `<h2>` "How to Annotate" heading is hardcoded English; should also be translated.
- The `lang === 'ua' ? 'ua' : 'en'` branch silently treats every non-Ukrainian language as English — only correct as long as exactly two locales exist. If a third locale is added, this needs to use the `i18n` lookup table.
- Width is hardcoded `w-[500px]` and `max-h-[80vh]`. Does not adapt to mobile breakpoints documented in `_docs/ui_design/README.md` §"Responsive Breakpoints" (640px). Flag.
@@ -0,0 +1,215 @@
# Module: `src/features/admin/AdminPage.tsx`
> **Source**: `src/features/admin/AdminPage.tsx` (209 lines)
> **Topo batch**: B4 (depends on B3: `api/client`, `components/ConfirmDialog`, `types/index`)
## Purpose
The administrator console of the SPA. Bundles four management surfaces
into a single page: detection-classes catalogue (CRUD-lite),
AI-recognition tuning form, GPS-device endpoint config, user
list (CRUD-lite), and aircraft default-selector. Replaces the WPF
`AdminWindow.xaml` cluster (`_docs/legacy/wpf-era.md` §§4, 5).
Behind the `/admin` route, gated by `Header`'s `ADM` permission
filter (a route-level permission re-check is the responsibility of the
`admin/` service — Header gating is UI-only; see Security below).
## Public interface
```ts
export default function AdminPage(): JSX.Element
```
No props. Reads everything via `api/client` and local state.
## Internal logic
- **State**:
- `classes: DetectionClass[]` — the detection-class table.
- `aircrafts: Aircraft[]` — the aircraft list with `isDefault` flag.
- `users: User[]` — the user table.
- `newClass: { name; shortName; color; maxSizeM }` — staging buffer
for the "add detection class" form (initial: `{ name: '',
shortName: '', color: '#FF0000', maxSizeM: 7 }`).
- `newUser: { name; email; password; role }` — staging buffer for
"add user" (initial: `{ name: '', email: '', password: '', role:
'Annotator' }`).
- `deactivateId: string | null` — drives the `ConfirmDialog`'s open
state for user deactivation.
- **Bootstrap effect** (`useEffect([])` — runs once at mount):
```ts
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
```
Three independent calls, all silently swallowed on error. No retry,
no error UI, no loading state — empty arrays render as empty
tables. Flag for Step 4 against the user-feedback patterns in
`_docs/ui_design/README.md`.
- **`handleAddClass()`**:
1. Guard: `if (!newClass.name) return`.
2. `await api.post('/api/admin/classes', newClass)`.
3. Refetch via `api.get('/api/annotations/classes')` — note the
**read** path is the public `annotations/` endpoint, while the
**write** path is the `admin/` endpoint. Architectural caveat:
two different services own the same logical entity. Document in
`architecture.md` §integration-points (Step 3a).
4. Reset `newClass` to its initial values.
No error path — a failed POST throws (because `client.ts` throws on
non-2xx); the throw is uncaught and reaches React's error boundary
(none configured). Flag.
- **`handleDeleteClass(id)`**: optimistic local update —
`await api.delete('/api/admin/classes/${id}')` then
`setClasses(prev => prev.filter(c => c.id !== id))`. **No
ConfirmDialog** despite this being destructive. Inconsistent with
the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
against `_docs/ui_design/README.md` confirmation-dialog spec.
- **`handleAddUser()`** — analogous to `handleAddClass` against
`POST /api/admin/users` and `GET /api/admin/users`. Guards on
`email && password`.
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
1. `PATCH /api/admin/users/${deactivateId}` with `{ isActive: false }`.
2. Optimistic local update: marks the row inactive.
3. Closes the dialog (`setDeactivateId(null)`).
No "reactivate" path — once `isActive: false`, the row only renders
the badge and no Deactivate button. Verify with `admin/` service:
is reactivation an admin task or out of scope?
- **`handleToggleDefault(a)`** — `PATCH /api/flights/aircrafts/${a.id}`
with `{ isDefault: !a.isDefault }`, then optimistic local flip. Note
this allows multiple `isDefault: true` aircraft to coexist (the
backend should enforce exclusivity; the UI does not).
- **Layout** (left → center → right, all in one horizontal flex):
- **Left column** (`w-[340px]`): detection-classes table + add row.
- **Center column** (`flex-1 max-w-md`): AI settings form, GPS
settings form, users table + add row. The AI and GPS forms have
`defaultValue` only — there is **no** state, no `Save` handler
wired up. The buttons render but do nothing. Flag.
- **Right column** (`w-[280px]`): aircraft list with star toggle.
- `<ConfirmDialog>` mounted at the end, controlled by `deactivateId`.
## Dependencies
- **Internal**:
- `../../api/client` — `api`.
- `../../components/ConfirmDialog` — for user deactivation.
- `../../types` — `DetectionClass`, `Aircraft`, `User`.
- **External**: `react` (`useState`, `useEffect`),
`react-i18next` (`useTranslation`).
## Consumers (intra-repo)
- `src/App.tsx` — mounted at the `/admin` route inside the protected
tree.
## Data models
Stateful local copies of `DetectionClass[]`, `Aircraft[]`, `User[]`.
The `newClass` and `newUser` buffers are anonymous shapes —
intentionally narrower than `DetectionClass` / `User` because the
backend assigns `id` and other server-managed fields.
## Configuration
- **i18n keys consumed**: `admin.classes`, `admin.aiSettings`,
`admin.gpsSettings`, `admin.users`, `admin.aircrafts`,
`admin.deactivate`, `common.save`. (Confirmed present in
`src/i18n/en.json` admin/common groups.) Plenty of hardcoded
English strings — placeholders ("Name", "Email", "Password"), table
headers (`#`, `Name`, `Color`, `Email`, `Role`, `Status`), role
options (`Annotator`, `Admin`, `Viewer`), the GPS protocol options
(`TCP`, `UDP`), the AI tuning labels ("Frame Period Recognition",
etc.), and the deactivation confirmation message. Step 4 candidate.
- **Hardcoded defaults**:
- `maxSizeM: 7` for new detection classes.
- `color: '#FF0000'` for new detection classes.
- `Frame Period Recognition: 5`, `Frame Recognition Seconds: 1`,
`Probability Threshold: 0.5` (`step: 0.05`, `min: 0`, `max: 1`).
- `Device Address: 192.168.1.100`, `Port: 5535`, default protocol
`TCP`. **Hardcoded internal IP** is a smell; should come from
`system_settings` or be a placeholder. Flag for Step 4 / Step 6
(`security_approach.md`).
- **Tailwind tokens**: `bg-az-panel`, `bg-az-bg`, `bg-az-orange`,
`bg-az-blue/20`, `bg-az-green/20`, `text-az-{text,muted,red,green,
blue,orange}`, `border-az-border`. Defined in `src/index.css`.
## External integrations
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/annotations/classes` | List detection classes (read path uses annotations service) |
| `POST` | `/api/admin/classes` | Create detection class (write path uses admin service) |
| `DELETE` | `/api/admin/classes/{id}` | Delete detection class |
| `GET` | `/api/flights/aircrafts` | List aircraft |
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
| `GET` | `/api/admin/users` | List users |
| `POST` | `/api/admin/users` | Create user |
| `PATCH` | `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/`
backends.
## Security
- **`/admin` is access-controlled by Header's `ADM` permission filter
AND by every backend endpoint** (each `/api/admin/*` is the
authority). The page itself does not call `hasPermission` — a user
who deep-links to `/admin` would render the page but every fetch
would 401/403. Acceptable for production; flag a UX improvement
(server-error → friendly redirect) for Step 8.
- **Password input has `type="password"`** — masked. The new-user
password is held in plaintext component state until the POST
resolves. Acceptable; cleared by `setNewUser({...})` after success.
- **`color: '#FF0000'`** is a hex string — when posted to
`/api/admin/classes`, the backend must validate. UI-side validation
(regex / `<input type="color">`) is enforced because the input is
a color-picker.
- **No CSRF** — relies on the bearer-token scheme. Same posture as the
rest of the SPA (see `client.ts` Security section).
- **No row-level access checks visible to the UI**: the user list
shows every row the backend returns; if the `admin/` service ever
filtered by tenant, the UI would not need changes.
## Tests
None.
## Notes / open questions
- **Detection-class read/write split** between `annotations/` and
`admin/` is unusual. Verify with the suite ADRs whether
`/api/annotations/classes` and `/api/admin/classes` are the same
underlying entity. If yes, the split is a legacy artifact; if no,
the UI is conflating two collections. Step 3a / Step 4 verification.
- **AI settings & GPS settings forms are wired to nothing** — every
`<input>` uses `defaultValue` (uncontrolled), there is no submit
handler, and the Save button does nothing. Either:
- The legacy WPF AI/GPS settings have not been ported yet (most
likely — `_docs/legacy/wpf-era.md` §"What is intentionally NOT
being ported" might apply), OR
- The backend endpoint exists but the wiring was lost during the
port.
Flag prominently in Step 4 problem-extraction. Probably a missing
feature, not a bug.
- **`handleDeleteClass` has no confirmation** — UX inconsistency with
user deactivation. Consider unifying via `ConfirmDialog`. Step 4.
- **Aircraft default-toggle race**: clicking two aircraft in quick
succession will fire two parallel `PATCH`es. The backend may end up
with both `isDefault: true` if it does not enforce exclusivity at
the persistence layer. Flag for Step 6 problem-extraction. Trust
the backend; do not add UI debouncing.
- **The "Active" / "Inactive" badge text is hardcoded English** even
though the surrounding column header (`Status`) is too. Either
localize all of them or accept the inconsistency. Step 4.
- **`u.isActive` as the only mutable user field** — no rename, no
password reset, no role change. Document the gap; may be a feature
cycle item for Phase B.
- **`newClass.shortName` is collected but not displayed in the table**
— the table only shows `id, name, color, ×`. The `shortName` lives
only in the staging buffer and is sent to the backend. Verify the
backend stores it.
- **Hardcoded GPS device address `192.168.1.100`** is a non-routable
RFC1918 default that should not ship with the production bundle.
Step 4 flag.
@@ -0,0 +1,110 @@
# Module group: `src/features/annotations/`
> Compact doc covering all 5 annotations modules (`classColors.ts` is a shared leaf — see existing `src__features__annotations__classColors.md`). The annotations feature is the **central legacy concern** of the codebase per `_docs/legacy/wpf-era.md §4` (`Azaion.Annotator` window) — what's documented here is the React port. For the canonical product spec see `_docs/ui_design/README.md` (Annotations Tab Layout, Annotation Quality Guidelines, Affiliation Icons, Combat Readiness, Annotation Row Gradient, Keyboard Shortcuts, Video Annotation Time-Window Display) and parent suite `../../../../_docs/01_annotations.md` for the API contract.
## Scope
Owns the `/annotations` route. Lets the user:
1. Browse media (video / image) for the currently selected flight, with a 300-ms debounced name filter, drag-and-drop / file-picker / folder-picker upload, and right-click delete.
2. Play / pause / step a video, scrub the timeline, mute, with frame stepping at 1 / 5 / 10 / 30 / 60 frames in both directions (assumed 30 FPS — see Findings).
3. Draw / move / resize / multi-select bounding boxes on a custom canvas overlay, click-and-drag with Ctrl-modifier behaviours, snap to image-normalized coordinates 01.
4. Pick the active detection class (19 hotkeys) and PhotoMode (Regular / Winter / Night) via the shared `DetectionClasses` component.
5. Save the per-frame detection set back to `POST /api/annotations/annotations`, with **fall-back to in-memory storage** when the file is a local blob: URL or the backend is unreachable.
6. Stream the annotations sidebar from the `GET /api/annotations/annotations/events` SSE feed; show each row with the gradient defined in `_docs/ui_design/README.md`.
7. Trigger AI detection via `POST /api/detect/{mediaId}` (modal log overlay).
8. Download an annotation as YOLO `.txt` + a PNG of the frame with rectangles burned in.
## Module map
| Module | Layer | Responsibility |
|---|---|---|
| `classColors.ts` | leaf | (already documented separately) Class-number → colour + photoMode-suffix lookup. |
| `MediaList.tsx` | sub-component | Left panel media browser. Owns `media[]` state, debounced filter, dropzone upload, blob: local-mode fallback when backend POST fails. Calls `GET /api/annotations/media`, `DELETE /api/annotations/media/{id}`, `POST /api/annotations/media/batch`. |
| `VideoPlayer.tsx` | sub-component | Native `<video>` wrapper. `forwardRef` exposes `seek(seconds)` and `getVideoElement()`. Custom progress slider + frame-step toolbar. Global `keydown` handler for Space / ←/→ (Ctrl=±150) / M. |
| `AnnotationsSidebar.tsx` | sub-component | Right panel: SSE-driven annotation list (`/api/annotations/annotations/events` filtered by `mediaId`), AI detect button, gradient row background built from per-detection class colour + confidence-modulated alpha, download button (delegates to page). |
| `CanvasEditor.tsx` | sub-component | Canvas overlay used in both image-only and video-overlay modes. `forwardRef` exposes `deleteSelected / deleteAll / hasSelection`. Owns zoom (Ctrl+wheel, 0.110×), pan (held in `pan` state — there is no Ctrl+drag pan code, only an unused `pan` state — see Findings), 8-handle resize, multi-select, draw-on-Ctrl-mousedown. Renders detections + a "time-window" preview of *other* annotations during video playback. |
| `AnnotationsPage.tsx` | page | Orchestrator: 3-panel layout via `useResizablePanel` (left 250 / right 200, min/max bounds). Owns `selectedMedia`, `annotations`, `detections`, `selectedClassNum`, `photoMode`, `currentTime`. Wires `MediaList``CanvasEditor``VideoPlayer``AnnotationsSidebar`. Handles the `Save` POST + local-fallback. Builds the YOLO + PNG download. |
## Key contracts
- **`Detection`** (from `src/types/index.ts`): `{ id, classNum, label, confidence ∈ [0,1], affiliation: 0|1|2, combatReadiness, centerX, centerY, width, height }` — all coords normalized 01. Matches `_docs/legacy/wpf-era.md §10` and parent suite `_docs/01_annotations.md` Detection DTO.
- **`AnnotationListItem`**: `{ id, mediaId, time: 'HH:MM:SS.mmm' | null, createdDate, source: AnnotationSource, status: AnnotationStatus, isSplit, splitTile, detections[] }`. Matches `Annotations` table in parent `_docs/00_database_schema.md` modulo client-side `isSplit / splitTile`.
- **AI detect endpoint**: `POST /api/detect/{mediaId}` — matches parent `../../../../_docs/03_detections.md §2` after nginx strips `/api/detect/`. NOTE: UI does NOT forward the `X-Refresh-Token` header that the spec requires for long-running video detection (`_docs/03_detections.md §2 request headers`). Carried as a finding.
- **Save body**: `{ mediaId, time: 'HH:MM:SS.mmm' | null, detections: Detection[] }`. .NET `TimeSpan.Parse` accepts that format so the round-trip works for `time → VideoTime`. **Body is missing required `Source` and optional `WaypointId`** required by parent spec `CreateAnnotationRequest` — see Findings.
## External integrations
| Endpoint / origin | Where | Direction | Notes |
|---|---|---|---|
| `GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000. |
| `GET /api/annotations/media/{id}/file` | `CanvasEditor`, `VideoPlayer`, `AnnotationsPage.handleDownload` | egress | Image / video bytes. |
| `POST /api/annotations/media/batch` | `MediaList.uploadFiles` | egress | `multipart/form-data`. |
| `DELETE /api/annotations/media/{id}` | `MediaList.handleDelete` | egress | Skipped for local blob: entries. |
| `GET /api/annotations/annotations?mediaId&pageSize=1000` | `MediaList.handleSelect`, `AnnotationsSidebar`, `AnnotationsPage.handleSave` | egress | Refresh after save / SSE event. |
| `POST /api/annotations/annotations` | `AnnotationsPage.handleSave` | egress | Body: `{ mediaId, time, detections }`. Falls back to in-memory on 4xx/5xx. |
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor.loadImage` | egress | Per-annotation hashed image; preferred over the raw media for image annotations. |
| `GET /api/annotations/annotations/events` (SSE) | `AnnotationsSidebar` | egress | Listens for `{ annotationId, mediaId, status }` events; filters client-side by `media.id`. |
| `POST /api/detect/{mediaId}` | `AnnotationsSidebar.handleDetect` | egress | Triggers AI inference — endpoint shape unverified vs `_docs/03_detections.md`. |
| `URL.createObjectURL(File)` | `MediaList.uploadFiles`, `AnnotationsPage.handleDownload` | browser API | Local-mode blob URLs are revoked on delete or unmount. |
## Findings carried into Step 4 / 6 / 8
1. **`VideoPlayer.stepFrames` hardcodes `fps = 30`** — frame stepping is wrong for any other fps (most drone footage is 25 / 30 / 60). UI spec says "Frame duration = 1 / video FPS" (`_docs/ui_design/README.md`). Should read from `video.getVideoPlaybackQuality()` / metadata. Step 4.
2. **`VideoPlayer` Ctrl+Left/Right is documented in `_docs/ui_design/README.md` as "skip 5 seconds"** but here it does `±150 frames` (`= 5s @ 30fps` only). Same fps coupling as #1.
3. **`VideoPlayer.error` state has no setter call beyond initial `null`/onLoadedMetadata reset** — but the `setError` call on `onError` only sets a string when source fails to load; spec says status messages should appear in a status bar (`_docs/ui_design/README.md`). The status-bar pattern is not implemented.
4. **`CanvasEditor` Ctrl+drag pan is documented (`_docs/ui_design/README.md`)** but not implemented — `pan` state exists, only `setPan(...)` calls are inside the (no-op) zoom flow. The canvas always renders at `pan = {0,0}`. Step 4 / Step 6 problem extraction.
5. **`CanvasEditor.useEffect[draw]` has missing deps** (`isVideo`, `imgSize` dependency tracked indirectly through other deps; `currentTime`/`annotations` are listed but `getTimeWindowDetections` is recreated each render and is closed-over). Specifically: `draw`'s closure reads `getTimeWindowDetections()` and the inner `getTimeWindowDetections` reads `media`, `currentTime`, `annotations` — those *are* in the dep list, but `media` itself is missing. Step 4 a11y / correctness.
6. **`CanvasEditor` time-window threshold is `< 2_000_000` ticks** in `getTimeWindowDetections` — at 10 000 000 ticks/second this is **±200 ms (400 ms total window)**. Spec is **asymmetric 50 ms before + 150 ms after (200 ms total)** per `../../ui_design/README.md`. Implementation window is 4× too wide and centred on the wrong offset. Step 4.
7. **`CanvasEditor` `ctx.fillStyle = color; ctx.globalAlpha = 0.1; ctx.fillRect(...)`** — the box "fill" is class colour at 10% opacity, but the **affiliation icon** (NATO MIL-STD-2525 shape) and **combat-readiness indicator** are missing. The current code only renders a tiny green dot for `combatReadiness === 1` (Ready). UI spec demands Friendly/Hostile/Unknown/None affiliation icons and Ready/NotReady/Unknown CR. Step 4 vs `_docs/ui_design/README.md` — significant gap.
8. **`CanvasEditor.AFFILIATION_COLORS` constant is dead code** — defined but never used. Likely the seed of the missing affiliation rendering. Step 4.
9. **Annotation row gradient cap is wrong by ~9 percentage points**: `AnnotationsSidebar.getRowGradient` uses `Math.round(alpha * 40).toString(16)``40` is **decimal**, not hex, so the maximum alpha byte is `0x28` (decimal 40 ≈ **16% opacity**). Spec wireframe `../../ui_design/annotations.html` uses `rgba(...,0.25)` = `0x40` hex (25%). Almost certainly a typo: should be `* 64` (decimal) or `* 0x40` to match the wireframe. Step 4.
10. **`AnnotationsSidebar` AI-detect modal**: `setDetectLog` writes "Detection complete" immediately after `await api.post(...)` returns — there is no progress streaming despite the spec ("scrolling log of detection progress" via `DetectionEvent` SSE per `_docs/03_detections.md`). The Detection SSE feed is not subscribed. Step 6.
11. **`AnnotationsSidebar` SSE refresh** is fire-and-forget (`.catch(() => {})`) — silent error suppression, violates `coderule.mdc`. Step 4.
12. **`AnnotationsPage.formatTicks(seconds)`** produces `HH:MM:SS.mmm` (string). Parent suite `_docs/01_annotations.md` carries `TimeSpan VideoTime` — .NET `TimeSpan.Parse` accepts that format, so the round-trip works, but the API contract is `TimeSpan` ticks, not a string. Verify in Step 4.
13. **`AnnotationsPage.handleDownload` never sets `crossOrigin` on the video element** (only on the standalone image fallback) — Canvas `toBlob` will throw "tainted canvas" if the video served from `/api/annotations/media/.../file` doesn't include `Access-Control-Allow-Origin`. Same-origin via the dev proxy and nginx is fine, but cross-origin (CDN / direct) breaks download. Step 4.
14. **`AnnotationsPage.handleSelect` annotation flow** does NOT expose `Validate` (V key per UI spec). The Annotations tab shouldn't validate (that's Dataset Explorer), but UI spec keyboard shortcuts list V on Annotations tab too — confirm scope in Step 6.
15. **No `R` key for AI detect** in any module here despite UI spec (`R = Trigger AI detection`). The AI Detect button is only reachable by mouse. Step 4.
16. **No PageUp / PageDown for prev/next media file** despite UI spec.
17. **No Camera config side panel** (altitude / FocalLength / SensorWidth) per `_docs/ui_design/README.md` — completely missing. Documented in Findings of `AdminPage.tsx` too: aircraft camera defaults are global, but per-session override UI is not built. Step 6.
18. **`MediaList` uses `alert(...)` for "Unsupported file type"** — not the project modal/toast pattern. Step 4.
19. **`MediaList` blob: local-mode is a graceful degradation** that lets the page work without backend — useful for demos but the user has no indication that "you're working offline, save will be lost on reload". Document explicitly in Step 6.
20. **`MediaList.fetchMedia` always merges blob: locals on top of backend results** even when filtering — local entries ignore the name filter. Step 4.
21. **`AnnotationsSidebar` `try { ... } catch (e: any)`** — `any` cast bypasses TS strict; `e.message` may be `undefined`. Step 4.
22. **`AnnotationsPage` left/right panels resize but widths NOT persisted** to `UserSettings` — UI spec says they should be restored per-user across sessions. The `useResizablePanel` hook only owns runtime state. Step 6 / Step 8.
23. **`CanvasEditor.handleMouseDown` Ctrl-modifier semantics**: Ctrl+click on a box should multi-select (per spec). Code does this. Ctrl+click on empty space should NOT start a draw — but the current branch starts `dragState = 'draw'` either way, then differentiates inside `handleMouseUp` by drawRect size. Slight UX cost only. Step 4.
24. **Tile / split-image annotation rendering**: spec mentions "Tile zoom" — auto-zoom to a tile region when opening a split-image detection. `AnnotationListItem.splitTile` exists but no consumer code reads it. Step 6 problem extraction.
25. **`AnnotationsPage.handleSave` 4xx/5xx fallback creates an in-memory `local-${uuid}` annotation** that looks identical to a saved one. The user can't distinguish the two — risk of data loss on reload. Step 6 problem extraction.
26. **`AnnotationsPage` does not subscribe to the inference detect SSE** — no AI progress visualization, no update on detect completion, no error if inference returns 5xx. Step 6.
27. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `AnnotationStatus` enum diverges from spec. UI declares `Created=0, Edited=1, Validated=2` (`src/types/index.ts:23`). Spec (`../../../../_docs/09_dataset_explorer.md`) is `None=0, Created=10, Edited=20, Validated=30, Deleted=40`. Action: change `src/types/index.ts` to the spec values. Cascades through every status filter, every PATCH/POST that sends a status, every render. **PRIORITY** — without this fix every dataset status filter and every PATCH `/dataset/{id}/status` from the UI sends a wrong integer.
28. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `Affiliation` enum diverges from spec. UI has 3 values `Unknown=0, Friendly=1, Hostile=2`. Spec (`../../ui_design/README.md` Affiliation Icons + parent `../../../../_docs/03_detections.md`) requires four values `None, Friendly, Hostile, Unknown`. Action: change `src/types/index.ts` to the four-value set; integer values to be confirmed once with the .NET service before patching (likely `None=0, Friendly=1, Hostile=2, Unknown=3`). Without this fix the UI cannot send/render the **None** (no-icon) case.
29. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `CombatReadiness` enum diverges from spec. UI has `NotReady=0, Ready=1`. Spec adds `Unknown` (no indicator rendered). Action: add `Unknown` to `src/types/index.ts`; confirm integer values with the .NET service.
30. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `MediaStatus` enum diverges from spec. UI has `New=0, AiProcessing=1, AiProcessed=2, ManualCreated=3`. Spec (`../../../../_docs/01_annotations.md` SSE + `_docs/03_detections.md §2`) is `None, New, AIProcessing, AIProcessed, ManualCreated, Confirmed, Error`. Action: change `src/types/index.ts` to the seven-value set; confirm integer values with the .NET service. Without this fix the UI cannot render the **Error** SSE event when inference fails.
31. **[RESOLVED 2026-05-10 — PARENT-DOC FIX APPLIED]** `AnnotationSource` numeric serialization is canonical; UI is correct (`AI=0, Manual=1`). Parent `../../../../_docs/01_annotations.md` §5 response example and AnnotationSource enum table updated to integer values; `../../../../_docs/09_dataset_explorer.md §1`, §2, §4 examples likewise. Both files also got a "wire format is numeric" header note. No UI change needed.
32. **[STEP 4 — UI FIX, user 2026-05-10]** `AnnotationsPage.handleSave` must add `Source` and `WaypointId` to the request body and rename `time``videoTime`. Required body shape per parent `../../../../_docs/01_annotations.md §1` `CreateAnnotationRequest`:
```json
{
"mediaId": "<string>",
"waypointId": "<guid | null>",
"source": 1,
"videoTime": "HH:MM:SS.mmm",
"detections": [ ... ]
}
```
Notes: (a) `userId` is supplied server-side from JWT — do not send. (b) `image` is omitted in the save flow; the server reuses the media's frame at `videoTime`. (c) `source` is `Manual` (= `1`) for hand-edited save; AI inference posts use `AI` (= `0`) and are sent server-to-server by the Detections service. (d) `waypointId` must come from `Media.waypointId` (already typed in `src/types/index.ts:16`); the UI currently does not thread waypoint association through to save. (e) Field name `time` must become `videoTime` (.NET PascalCase `VideoTime` → camelCase on the wire).
33. **[RESOLVED 2026-05-10 — PARENT-DOC FIX APPLIED]** `DatasetItem.isSplit` is correct on the UI side; parent spec response now includes it. `../../../../_docs/09_dataset_explorer.md §1` `items[]` shape updated with `isSplit: boolean` plus a description tying it to `_docs/03_detections.md §4` Tile-Based Detection. No UI change needed.
34. **[STEP 4 — UI FIX, user 2026-05-10]** `Detect` endpoint needs conditional `X-Refresh-Token` header. Spec `../../../../_docs/03_detections.md §2` lists it as required for long-running video detection. Access tokens expire in 3600 s per `../../../../_docs/10_auth.md §1`, so any video that takes >1 h to process loses auth on the server-to-server `POST /annotations` call. Action: when `media.mediaType === MediaType.Video`, attach `X-Refresh-Token: <localStorage refreshToken>` to the `POST /api/detect/{mediaId}` request. Caveat: `/auth/refresh` rotates the refresh token (per `_docs/10_auth.md §2`), so the value can go stale if the UI also refreshes its own token mid-flight. Step 4 must decide whether to (i) cache the refresh token at detect-start, (ii) use a long-lived service token, or (iii) accept the failure mode and surface it to the user. For images and short videos the header is unnecessary — but the UI cannot tell upfront, so default to "always send for video".
## Tests
None.
## Cross-doc references
- Parent suite Annotations API: `../../../../_docs/01_annotations.md`
- Parent suite Detections (AI inference): `../../../../_docs/03_detections.md`
- Parent suite Database schema: `../../../../_docs/00_database_schema.md`
- Legacy WPF reference: `../../legacy/wpf-era.md` §4 (Annotator window) and §10 (what survived).
- UI spec: `../../ui_design/README.md` (Annotations Tab Layout + keyboard shortcuts + time-window + annotation row gradient + affiliation icons + combat readiness).
- Shared component used here: `src/components/DetectionClasses.tsx` (already documented).
@@ -0,0 +1,73 @@
# Module: `src/features/annotations/classColors.ts`
> **Source**: `src/features/annotations/classColors.ts` (24 lines)
> **Topo batch**: B1 (leaf — no internal imports)
## Purpose
Pure-function fallbacks for detection-class color and display name. Used when the live `DetectionClass[]` from the admin API hasn't been loaded (initial render) or doesn't include a class for a given `Detection.classNum`.
Also exposes the **PhotoMode-aware suffix** logic that mirrors the WPF-era `yoloId = classId + photoModeOffset` convention (see `_docs/legacy/wpf-era.md` §10): class numbers in `[0, 19]` are "Regular", `[20, 39]` are "Winter", `[40, 59]` are "Night".
## Public interface
```ts
export const FALLBACK_CLASS_NAMES: string[] // 12 generic English labels
export function getClassColor(classNum: number): string // hex, no '#' alpha
export function getPhotoModeSuffix(classNum: number): string // '' | ' (winter)' | ' (night)'
export function getClassNameFallback(classNum: number): string // FALLBACK_CLASS_NAMES[base] or '#<n>'
```
A 12-color palette `CLASS_COLORS` is defined module-private and referenced through `getClassColor`.
## Internal logic
```
base = classNum % 20
mode = floor(classNum / 20)
color = CLASS_COLORS[base % CLASS_COLORS.length] // wraps if base >= 12
name = FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length]
?? `#${classNum}`
suffix = mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
```
The `??` guard exists for the unreachable case `base = 12..19` against an array of length 12 — except `base % length` brings it back into range first, so `?? '#<n>'` is dead. **Flag for Step 4 verification** — either the array is wrong or the `??` is dead code.
## Dependencies
- **Internal**: none.
- **External**: none.
## Consumers (intra-repo)
- `src/features/annotations/CanvasEditor.tsx` — labels + crosshair color.
- `src/features/annotations/AnnotationsPage.tsx``getClassColor` for selected class indicator.
- `src/features/annotations/AnnotationsSidebar.tsx` — gradient stops in the annotation row.
- `src/features/annotations/MediaList.tsx`*not currently a consumer* despite being adjacent (no import seen).
- `src/components/DetectionClasses.tsx` — fallback name + color when admin classes haven't loaded. **This is the cross-layer edge** flagged in `00_discovery.md` §8: a `components/` (shared-layer) file imports from a feature.
## Data models
`CLASS_COLORS: string[]` (12 hex strings) and `FALLBACK_CLASS_NAMES: string[]` (12 English strings). Neither is wire-coupled — pure UI defaults.
## Configuration
None.
## External integrations
None.
## Security
None.
## Tests
None.
## Notes / open questions
- The "+20 winter / +40 night" PhotoMode convention is a direct port of the legacy WPF DetectionClasses contract (`_docs/legacy/wpf-era.md` §10). Verify against the current admin API DTO during component assembly (Step 2) — if the API exposes `photoMode` as a separate field on `DetectionClass` (it does, per `src/types/index.ts`), then computing the suffix from `classNum / 20` here is **redundant** and risks disagreeing with the admin-defined value. Candidate Step 4 testability fix: remove `getPhotoModeSuffix` once consumers have access to the typed `photoMode`.
- `FALLBACK_CLASS_NAMES` lists generic English labels (Car, Person, Truck, …) that bear no relation to the actual military classes the legacy doc enumerates (Military Vehicle, Truck, Car, Artillery, Active Mine — see `_docs/ui_design/README.md` §"Detection Classes Table"). Acceptable for a *fallback* only ever shown if the admin classes failed to load; document this in Step 5 (Solution Extraction).
- Cross-layer import surfaced in `00_discovery.md` §8 — proper home for these helpers is either `src/components/detection/` (with `DetectionClasses.tsx`) or a new `src/shared/` namespace. Decision deferred to Step 2.5 module-layout derivation.
@@ -0,0 +1,69 @@
# Module: `src/features/dataset/DatasetPage.tsx`
> Compact module doc. Spec: `_docs/ui_design/README.md` (Dataset Explorer Layout) and parent suite `../../../../_docs/09_dataset_explorer.md` (API contract). Imports `CanvasEditor` from `../annotations/` — see cross-feature edge in `00_discovery.md §8`.
## Purpose
Owns the `/dataset` route. Read-side surface for the `Annotations` table: paginated thumbnail grid, date / class / status / objects-only / name filters, inline editing through the Annotations Tab's `CanvasEditor`, and a class-distribution chart. Mirrors the legacy `Azaion.Dataset.DatasetExplorer` window (`_docs/legacy/wpf-era.md §5`).
## Public interface
Default-exported page component, no props. Mounts under `/dataset` in `App.tsx`.
## Internal logic
- **Tabs**: `'annotations'` (grid, default) | `'editor'` (hidden until a thumbnail is double-clicked) | `'distribution'` (bar chart). State held in component.
- **Filters**: `fromDate`, `toDate`, `statusFilter` (`AnnotationStatus`), `selectedClassNum` (from `DetectionClasses`), `objectsOnly` (boolean), `search` (400 ms debounced via `useDebounce`).
- **Pagination**: client `page` state, server `pageSize` fixed at 20, `totalPages = ceil(totalCount / pageSize)`.
- **Selection**: `Set<annotationId>`. Plain click replaces; Ctrl+click toggles. Validate button appears when set is non-empty.
- **Validate**: `POST /api/annotations/dataset/bulk-status` with `{ annotationIds[], status: Validated }`.
- **Distribution**: lazy-loaded on tab switch via `GET /api/annotations/dataset/class-distribution`.
- **Editor tab** mounts `<CanvasEditor>` with a synthesised `Media` stub built from `editorAnnotation.mediaId` (most fields blank). Used so `CanvasEditor` can fetch the image via `/api/annotations/annotations/{id}/image`.
## Dependencies
- Internal: `api/client`, `useDebounce`, `useResizablePanel` (left panel 250 / 200400), `FlightContext`, `DetectionClasses`, `ConfirmDialog` (imported but not used here — see Findings), `features/annotations/CanvasEditor`, `types/index`.
- External: `react`, `react-i18next`.
## External integrations
| Endpoint | Where | Notes |
|---|---|---|
| `GET /api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name`. |
| `GET /api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
| `POST /api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
| `GET /api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
| `GET /api/annotations/annotations/{id}/thumbnail` | grid tile `<img src=>` | One HTTP per visible tile. |
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor` (delegated) | When the editor is open. |
Spec contract is in parent suite `_docs/09_dataset_explorer.md`.
## Findings carried into Step 4 / 6 / 8
1. **No keyboard shortcuts implemented**: spec demands `19` (class), Enter (save), Del (delete), X (delete all), Esc (close editor), V (validate selected), Up/Down/PageUp/PageDown (navigate). Only Ctrl+click for multi-select works. The editor tab has no Save / Delete buttons either. Step 6 problem extraction.
2. **`Refresh thumbnails` button is missing** — spec calls for a status-bar action to regenerate the thumbnail database with progress. Step 6.
3. **No virtualisation in the grid**: spec says "virtualized grid" — current code renders all `items` returned by the page (max 20 due to `pageSize`). For larger pages performance would degrade. Today not a problem because `pageSize=20`. Step 8.
4. **Editor tab does not Save**: opening an annotation in the editor lets the user edit `editorDetections` locally but there's no Save / Cancel control wired up. Edits are discarded when the user changes tab. Step 6.
5. **`editorMedia` synthetic stub** — `mediaType: 1` is a magic number (`MediaType.Image = 1`); should reuse the enum import already present. Step 4.
6. **`ConfirmDialog` imported but never used** — dead import. Step 4 cleanup.
7. **`fetchItems` does `catch {}`** silently — same `coderule.mdc` violation as elsewhere. Step 4.
8. **Empty-state**: when `items.length === 0` the grid renders nothing; no "No matches" message. Step 4.
9. **`thumbnail` URL does not include a cache-buster**; if a thumbnail is regenerated server-side, the browser will keep the stale image. Step 4 / Step 8.
10. **`isSeed` red-ring is correctly rendered**, but `isSeed` is not in `AnnotationStatus` filters — the user can't filter to seeds-only. Step 6 if intended.
11. **Date inputs accept any `<input type="date">` string**; no validation that `fromDate ≤ toDate`. Step 4.
12. **Status filter buttons skip status `None`** (spec lists None as one of the four buttons) — current code shows `All`, `Created`, `Edited`, `Validated`, conflating `All` with "include None". Step 4.
13. **`selectedClassNum=0` is the "no filter" sentinel** but also a real class number (class 0 exists per `_docs/ui_design/README.md` → military vehicle). Selecting class 0 in `DetectionClasses` is indistinguishable from "no filter". Step 6.
14. **Inherits one cross-cutting UI fix and one cross-repo parent-doc fix** (resolved with user 2026-05-10):
- **[STEP 4 — UI FIX]** `AnnotationStatus` values must change from UI `0/1/2` to spec `0/10/20/30/40` (`src/types/index.ts:23`). Every status filter button in this page (`null`/`null`/`Created`/`Edited`/`Validated`) currently sends a wrong integer. The "All" button is value `null` (no filter) — keep, but also expose a real "None" filter using the new `AnnotationStatus.None=0`. **PRIORITY** for Step 4. See annotations doc finding #27.
- **[RESOLVED 2026-05-10]** `DatasetItem.isSplit` is correct on UI; parent `../../../../_docs/09_dataset_explorer.md §1` response schema now includes it. See annotations doc finding #33.
## Tests
None.
## Cross-doc references
- Parent suite Dataset Explorer API: `../../../../_docs/09_dataset_explorer.md`
- UI spec: `../../ui_design/README.md` (Dataset Explorer Layout, Keyboard Shortcuts).
- Imports `CanvasEditor` from `features/annotations/` — see `00_discovery.md §8` cross-feature edge.
- Legacy WPF reference: `../../legacy/wpf-era.md §5` (Dataset Explorer window).
@@ -0,0 +1,106 @@
# Module group: `src/features/flights/`
> **Note**: this is a deliberately compact doc covering all 15 flights modules. Behaviour is mostly an in-progress port of `mission-planner/` into the React 19 SPA. For the canonical product spec see `_docs/ui_design/README.md` (Flights Page Layout) and `../../../_docs/02_flights.md` / `11_gps_denied.md` in the parent suite repo (Flights API contract + GPS-Denied semantics).
## Scope
Owns the `/flights` route. Lets the user:
1. Browse / create / delete `Flight` rows (`POST/DELETE /api/flights/...`).
2. Plan a mission on a Leaflet map: add waypoints, draw work-area / no-go rectangles, edit altitude + purpose per point, see live total distance, time, battery %.
3. Toggle into GPS-Denied mode — opens an SSE stream `/api/flights/{id}/live-gps` (and the panel for orthophoto upload + correction once that backend wiring lands; today only the live-GPS readout is connected).
4. Save waypoints back to the Flights API (`/api/flights/{id}/waypoints`).
5. Import / export the plan as JSON.
Currently handles only the planning surface; the gps-denied orthophoto upload / correction inputs in `_docs/ui_design/flights.html` are not yet implemented.
## Module map
| Module | Layer | Responsibility |
|---|---|---|
| `types.ts` | leaf | All flight-feature-only types (`FlightPoint`, `CalculatedPointInfo`, `MapRectangle`, `WindParams`, `AircraftParams`, `MovingPointInfo`, `ActionMode`), plus tile URLs (`TILE_URLS`), `PURPOSES` (`tank` / `artillery`), and `COORDINATE_PRECISION = 8`. |
| `mapIcons.ts` | leaf | Three coloured Leaflet `Icon` instances + the default Leaflet pin (loaded from a CDN — see Findings). |
| `flightPlanUtils.ts` | leaf | Pure-ish helpers: `newGuid`, haversine `calculateDistance` (with plane climb/cruise/descend profile), OpenWeatherMap fetch, semi-empirical `calculateBatteryPercentUsed`, `calculateAllPoints` (sequential reduce), `parseCoordinates`, `getMockAircraftParams`. |
| `WaypointList.tsx` | sub-component | `@hello-pangea/dnd` reorderable list, hover-only Edit/Remove buttons, shows distance / time / battery / altitude per point. |
| `AltitudeChart.tsx` | sub-component | `react-chartjs-2` Line chart of altitude over normalized distance; pulls all controllers via `chart.js/auto`. |
| `WindEffect.tsx` | sub-component | Two number inputs (heading 0360°, speed 030 m/s) with a small SVG arrow preview. |
| `MiniMap.tsx` | sub-component | 240×180 `react-leaflet` thumbnail anchored to a moving point; `attributionControl={false}`. |
| `AltitudeDialog.tsx` | sub-component | Add / Edit waypoint modal: lat / lng / altitude / `meta: string[]` purpose multi-select. Fully controlled. |
| `MapPoint.tsx` | sub-component | One waypoint marker: draggable, popup with altitude slider, purpose checkboxes, remove button. |
| `DrawControl.tsx` | sub-component | Headless Leaflet handler that draws work-area / prohibited-area rectangles via `mousedown / mousemove / mouseup`. |
| `FlightListSidebar.tsx` | sub-component | Left rail: flight list, "+ Create", inline-create row, telemetry date stub. |
| `JsonEditorDialog.tsx` | sub-component | Modal `<textarea>` over the plan JSON with live `JSON.parse` validation. |
| `FlightParamsPanel.tsx` | composite | Hosts `WaypointList` + `AltitudeChart` + `WindEffect` + all per-flight inputs (aircraft, initial altitude, FoV, comm address, action-mode buttons, totals strip, Save / Upload / EditAsJSON / Export). |
| `FlightMap.tsx` | composite | Wraps `MapContainer`; mounts `MapPoint` × N, `DrawControl`, `MiniMap` (when a point is moving), polyline + arrow decorator, satellite/classic toggle. |
| `FlightsPage.tsx` | page | Orchestrator: owns all state, talks to `api/client`, opens the SSE stream, mediates between sidebar / params panel / map / dialogs. |
## Key contracts (read by other docs)
- **`FlightPoint`**: `{ id: string; position: { lat; lng }; altitude: number; meta: string[] }`. `meta``{ 'tank', 'artillery' }`. The shape diverges from the legacy WPF `Point` (radio, single purpose) — the React SPA uses checkboxes (multi).
- **`CalculatedPointInfo`**: `{ bat: number /* % */; time: number /* hours */ }`. Index `i` = state at point `i` after the segment from `i-1`. `lastInfo.bat` drives the Good / Caution / Low colour status (`>12 / >5 / ≤5`).
- **`PURPOSES = [{ value: 'tank', label: 'options.tank' }, { value: 'artillery', label: 'options.artillery' }]`** — i18n keys are `flights.planner.${label}`.
- **JSON plan shape** (`handleEditJson` / `handleExport` / `handleJsonSave`): `{ operational_height: { currentAltitude }, geofences: { polygons: [{ northWest, southEast, fence_type: 'EXCLUSION'|'INCLUSION' }] }, action_points: [{ point: { lat, lon }, height, action: 'search', action_specific: { targets: string[] } }] }`. Used for both export-to-file and the JSON editor.
- **Tile URLs**: classic OSM and an Esri ArcGIS `World_Imagery` (in `types.ts`). Both are direct upstream — neither goes through the suite `satellite-provider/` proxy. See Findings.
## External integrations
| Endpoint / origin | Where | Direction | Notes |
|---|---|---|---|
| `GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
| `GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
| `POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
| `DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
| `POST/DELETE /api/flights/{id}/waypoints[/{wp}]` | `FlightsPage.handleSave` | egress | Delete-all-then-recreate, sequentially. |
| `GET /api/flights/{id}/live-gps` (SSE) | `FlightsPage` | egress | Open while in GPS mode + flight selected. |
| `https://api.openweathermap.org/...` | `flightPlanUtils.getWeatherData` | egress | Direct browser→3rd-party. **Hardcoded API key.** See Findings. |
| `tile.openstreetmap.org` (`TILE_URLS.classic`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
| `server.arcgisonline.com/.../World_Imagery` (`TILE_URLS.satellite`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
| `unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png` | `mapIcons.defaultIcon` | egress | CDN, version pinned to 1.7.1 while package is 1.9.4 (drift). |
| `navigator.geolocation.getCurrentPosition` | `FlightsPage` mount | browser API | Fallback to hardcoded `47.242, 35.024` (Zaporizhzhia). |
## Findings carried into Step 4 / 6 / 8
These are the real findings; the per-module rationale is in git history of the deleted per-file docs. Numbered for cross-reference from `state.json.notes`.
1. **HARDCODED OPENWEATHER API KEY**`flightPlanUtils.ts:60`. HIGH severity. Step 4 source-code fix; upstream rotation is a parallel user task.
2. **`flightPlanUtils.calculateAllPoints` does N sequential `await`s** to OpenWeatherMap — N × RTT latency. Not parallelisable as-is because `info[i].bat` depends on `info[i-1]`. Step 8 refactor: fetch weather first in parallel, then reduce.
3. **`flightPlanUtils.calculateDistance` mixes km and metres** (`R = 6371`, altitudes converted `/1000` inline, returned `time = dist / aircraft.speed` assumes km/h). No type forces correctness. Step 4.
4. **`AircraftParams.batteryCapacity` unit is ambiguous** (Wh vs W·s). `calculateBatteryPercentUsed` divides W·s by it × 100 — only correct if W·s. Verify against `mission-planner/src/services/calculateBatteryUsage.ts`. Step 4.
5. **`flightPlanUtils.getWeatherData` swallows errors silently** (`catch { return null }`); callers can't distinguish "no wind" from "key revoked". Step 4.
6. **`mapIcons.defaultIcon` CDN URL is leaflet@1.7.1** while `package.json` is 1.9.4. Step 4 — switch to bundled assets or match version.
7. **`FlightMap` and `MiniMap` bypass the suite `satellite-provider/` proxy** (Esri tiles direct from `server.arcgisonline.com`). Possible licence + rate-limit concern. Step 4 + `architecture.md`.
8. **`MiniMap` sets `attributionControl={false}`** — drops OSM / Esri attribution. Possible licence-compliance gap. Step 4.
9. **`MiniMap` is fixed 240×180 + zoom 18 hardcoded** — overflows below the 640px mobile breakpoint. Step 4 vs `_docs/ui_design/README.md` responsive specs.
10. **`AltitudeDialog` lacks Esc-to-close, backdrop-click-to-cancel, `role="dialog"`, `aria-modal`** — inconsistent with `ConfirmDialog`. Same for `JsonEditorDialog`. Pick one modal convention in Step 4.
11. **`AltitudeDialog` accepts any number for lat/lng** (no `[-90,90]` / `[-180,180]` guard). `AltitudeDialog.altitude` and `WindEffect` inputs use `Number('')` → 0 silently — same pitfall noted in `SettingsPage`. Step 4.
12. **`AltitudeDialog` purpose multi-select** vs WPF radio (single choice). Confirm intent in Step 6: is the SPA expanding the data model on purpose, or is this a UI bug?
13. **`WindEffect` `max=360`** allows duplicate heading at 0 vs 360. Step 4.
14. **`WaypointList` drag handle is the entire row** (prevents text selection); Edit/Remove buttons are hover-only (unusable on touch); no a11y reorder announcements. Step 4 / Step 8 a11y.
15. **`WaypointList.calculatedPointInfo[i]`** silently degrades to alt-only label if length mismatches; tightly coupled to `FlightsPage` keeping arrays in lockstep.
16. **`AltitudeChart` pulls every chart.js controller** via `chart.js/auto` (bundle bloat); colours are duplicated as hex literals (drift from the `az-*` Tailwind tokens). Step 8.
17. **`AltitudeDialog` naming**: file is "AltitudeDialog" but the dialog covers lat / lng / altitude / purpose. Port-vestige from `mission-planner/`. Rename to `WaypointDialog` in Step 8.
18. **`flightPlanUtils.ts` is single-file** — newGuid + geo + weather + battery + parser + mock all together. Splitting into `geo.ts` / `weather.ts` / `battery.ts` / `mockAircraft.ts` is a Step 8 SRP candidate.
19. **`FlightsPage.handleSave`** deletes all existing waypoints then recreates — N+M sequential PUT/DELETE round-trips, not transactional, no progress UI, partial failure leaves the flight half-saved.
20. **`FlightsPage.handleSave` body shape does NOT match the Flights API spec — UI will likely 400 on a strict server**. Code POSTs `{ name, latitude, longitude, order }`. Parent `../../../../_docs/02_flights.md §3` `CreateWaypointRequest` requires `{ Geopoint: {Lat, Lon, MGRS}, Source: WaypointSource, Objective: WaypointObjective, OrderNum, Height }`. Mismatches: (a) lat/lon not nested under `Geopoint`; (b) field is `order`, spec is `OrderNum`; (c) `Source`, `Objective`, `Height` not sent at all; (d) UI sends `name` which the spec does not define on `Waypoint` (`Waypoint` interface in `src/types/index.ts:76` invents `name`). This collides with finding #19 — every save will round-trip waypoints in the wrong shape. **PRIORITY** for Step 4. Open question for Step 6: are these columns being added to the Flights API schema, or is the React UI to be aligned to the spec? Same comment for `altitude` and `meta` which have no place in the current spec.
21. **`FlightsPage.useEffect` bootstraps `getMockAircraftParams()`** as the active `aircraft` regardless of what the backend returns — the dropdown choice is cosmetic for now. Real wiring is a Step 6 / Step 8 follow-up.
22. **GPS-Denied panel is partial** — only the SSE live-GPS readout is wired; orthophoto upload, GPS correction (per `_docs/ui_design/README.md`) are not. Step 6 problem extraction.
23. **`handleImport` silently drops the file picker** if the user cancels (`if (!file) return`) — fine. But `handleJsonSave`'s catch uses `alert(...)` for a UX-grade error — replace with the project's modal/toast pattern in Step 4.
24. **`MapPoint` popup recomputes the marker DOM offset on every drag move** to choose dx/dy for the moving-point indicator. Acceptable, but the `(marker as unknown as { _icon: HTMLElement })._icon` cast leaks Leaflet internals.
25. **`DrawControl` registers global `mousedown`/`mousemove`/`mouseup` on the map** while a draw mode is active and disables `map.dragging` for the duration — fine, but no Esc-to-cancel mid-draw.
26. **`FlightContext` ceiling**: `FlightsPage` reads `flights` from `useFlight()` which fetches with `pageSize=1000` (already flagged in `FlightContext` doc). Won't surface here, but `selectFlight` is fire-and-forget — if the PUT to `/api/flights/select` fails the next page reload reverts the choice without notice.
## What's intentionally NOT here
- The orthophoto-upload / GPS-correction sub-panel (in `_docs/ui_design/flights.html` but not in source).
- Any per-segment annotation-time-window logic (that's the Annotations module).
- Any aircraft battery model that respects altitude-dependent air density (constant 1.05 kg/m³ in source).
## Tests
None — confirmed by `00_discovery.md §5`. Documented test gap is owned by Steps 3 / 57 of autodev (test-spec → implement → run).
## Cross-doc references
- Parent suite Flights API contract: `../../../../_docs/02_flights.md` (DTOs, endpoint shapes).
- Parent suite GPS-Denied: `../../../../_docs/11_gps_denied.md` (SSE event shape, correction flow).
- UI spec: `../../ui_design/README.md` (Flights Page Layout, GPS-Denied panel toggle, mobile breakpoint).
- Port-source: `mission-planner/src/flightPlanning/*` — covered separately as one consolidated doc.
@@ -0,0 +1,151 @@
# Module: `src/features/login/LoginPage.tsx`
> **Source**: `src/features/login/LoginPage.tsx` (95 lines)
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`)
## Purpose
The single public route of the SPA. Collects email + password, calls
`AuthContext.login(...)`, and on success runs a four-step "unlock"
animation (download key → decrypting → starting services → ready)
before navigating to `/flights`. Replaces the WPF `LoginWindow.xaml`
including its multi-step progress UI (`_docs/legacy/wpf-era.md` §3 / §4).
## Public interface
```ts
export default function LoginPage(): JSX.Element
```
No props. Reads `useAuth().login` and `react-router-dom`'s `useNavigate`.
## Internal logic
- **State machine** — `step: UnlockStep` cycles through:
`idle → authenticating → downloadingKey → decrypting → startingServices → ready`,
with `idle` also reachable on error from `authenticating`.
```ts
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey'
| 'decrypting' | 'startingServices' | 'ready'
```
- **Local state**:
- `email: string`, `password: string` — controlled inputs.
- `error: string` — empty unless the previous attempt failed.
- `step: UnlockStep` — drives both the form/spinner branch and the
spinner caption.
- **`handleSubmit(e)`**:
1. `preventDefault`, clear `error`, set `step = 'authenticating'`.
2. `await login(email, password)` — this throws on bad credentials
(`AuthContext.login` rethrows the `api.post` error).
3. On success: call `runUnlockSequence()`.
4. On failure: reset `step` to `'idle'`, set `error = t('login.error')`.
- **`runUnlockSequence()`** — sequentially sets `step` to each of
`downloadingKey`, `decrypting`, `startingServices`, `ready`, with a
600ms `setTimeout` delay between each. After `ready`, navigates to
`/flights`. The unlock steps are **purely presentational** — there is
no real downloadKey/decrypt work happening; the SPA's bearer token is
already set inside `await login(...)`. The animation reproduces the
WPF "vault unlocking" UX for continuity, not for any cryptographic
operation. Total minimum wait after a successful auth: 4 × 600 ms =
2.4 s. Document explicitly to avoid future "what does this decrypt?"
confusion.
- **`STEP_KEYS`**: a `Record<UnlockStep, string>` mapping each step to a
translation key. `'idle'` maps to the empty string (the spinner
caption is not rendered when idle).
- **Render**: when `step === 'idle'`, the form is shown (email +
password + error + submit). Otherwise the form is hidden and a
centered spinner with the localized step caption is shown. Note the
spinner is rendered even during `authenticating` (real network), so
the user sees a single uninterrupted spinner across the entire
auth → animation flow.
## Dependencies
- **Internal**: `../../auth/AuthContext` — `useAuth().login`.
- **External**: `react` (`useState`, `FormEvent` type),
`react-router-dom` (`useNavigate`),
`react-i18next` (`useTranslation`).
## Consumers (intra-repo)
- `src/App.tsx` — mounted at the public `/login` route, *outside*
`AuthProvider`. (Verify in B8 — currently `App.tsx` puts
`AuthProvider` inside the protected branch only, so `LoginPage`
reaches `useAuth()` only because `App.tsx` actually wraps everything
in `AuthProvider` at a higher level. Confirmed via the §7a graph
edge `App → AuthProvider`.)
## Data models
`UnlockStep` and `STEP_KEYS` are module-private. No DTOs or shared
types.
## Configuration
- **i18n keys**: `login.title`, `login.email`, `login.password`,
`login.submit`, `login.error`, `login.authenticating`,
`login.downloadingKey`, `login.decrypting`, `login.startingServices`,
`login.ready`. (All present in `src/i18n/en.json` and `ua.json` —
confirmed.)
- **Tailwind tokens**: `bg-az-bg`, `bg-az-panel`, `border-az-border`,
`text-az-orange`, `text-az-text`, `text-az-muted`, `text-az-red`.
Defined in `src/index.css`.
- **Hardcoded animation timing**: `600 ms` per step. No env override.
## External integrations
None directly. Indirect: `AuthContext.login` calls
`POST /api/admin/auth/login` (routed by `nginx.conf` to the `admin/`
service).
## Security
- Both inputs use the right `type` attribute (`email`, `password`),
giving the browser the chance to mask the password and offer
password-manager autofill.
- The HTML form does NOT have `autoComplete="off"` — autofill is
intentionally allowed.
- The component does NOT log credentials anywhere. The `error` state
carries only the localized "Invalid credentials" string, never the
raw backend error.
- After successful auth, the bearer is already in memory (`AuthContext`
set it). The 2.4s "unlock" animation does NOT extend the auth window
— if the bearer expires server-side during the animation the next
request retries via `client.ts`'s 401 → refresh path.
- The `setError(t('login.error'))` shows a single generic error for
every failure mode (wrong password, account locked, server down).
Acceptable for security (no user-enumeration leak), but logs an
observability flag — backend should keep specific reasons in its
audit log.
## Tests
None.
## Notes / open questions
- **The unlock animation is theatrical** — it suggests cryptographic
work that is NOT happening. If a future phase actually adds
client-side key derivation, the animation should reflect real
progress (`'decrypting'` would block on actual work). Keep the names
but document the placeholder status in `solution.md` (Step 5).
- **No "loading" while `authenticating`** beyond hiding the form —
Submit button shows no spinner of its own; the form simply hides
the moment `step` leaves `'idle'`. Mild UX gap (a quick auth
failure shows the form blink). Defer to Step 8.
- **Caps Lock indicator missing** — the WPF version had one
(`_docs/legacy/wpf-era.md`). Not currently a goal; flag if the UX
spec calls for it.
- **No "Forgot password" link** — out of scope; the `admin/` service
may or may not support that flow. Verify during Step 6 problem
extraction.
- **Form does not disable Submit while authenticating** — but the
form is unmounted as soon as `step !== 'idle'`, so a double-click
cannot fire a second `login(...)` call. Acceptable.
- **`runUnlockSequence` resolution race**: if the user navigates
away mid-animation (e.g. closes the tab), the pending `setTimeout`s
still fire but harmlessly. React's StrictMode double-invokes the
effect-mount path in dev — but `runUnlockSequence` is invoked from
`handleSubmit`, not an effect, so no duplication.
@@ -0,0 +1,181 @@
# Module: `src/features/settings/SettingsPage.tsx`
> **Source**: `src/features/settings/SettingsPage.tsx` (107 lines)
> **Topo batch**: B4 (depends on B3: `api/client`, `types/index`)
## Purpose
The per-tenant configuration screen. Three side-by-side panels:
**Tenant** (system settings: military unit, name, default camera
width and FoV), **Directories** (server-side filesystem paths for
videos, images, labels, results, thumbnails, GPS sat / route),
and **Aircrafts** (read-only list with star-toggle for default
selection). Replaces the legacy WPF `SettingsWindow.xaml` plus the
"system" tab (`_docs/legacy/wpf-era.md` §4).
Available to every authenticated user — `Header` does not gate
`/settings` behind a permission check. The backend is the authority
for who is allowed to write.
## Public interface
```ts
export default function SettingsPage(): JSX.Element
```
No props.
## Internal logic
- **State**:
- `system: SystemSettings | null` — loaded from
`GET /api/annotations/settings/system`. `null` until the GET
resolves; the panel does not render until then (`{system && (...)}`).
- `dirs: DirectorySettings | null` — analogous, from
`GET /api/annotations/settings/directories`.
- `aircrafts: Aircraft[]` — from `GET /api/flights/aircrafts`.
- `saving: boolean` — disables the two Save buttons during a PUT.
- **Bootstrap effect** (`useEffect([])`):
```ts
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
```
Three independent calls, all silently swallowed on error. Empty UI
on failure (no error banner). Flag for Step 4.
- **`saveSystem()`**:
1. Guard: `if (!system) return`.
2. `setSaving(true)`.
3. `await api.put('/api/annotations/settings/system', system)`.
4. `setSaving(false)`.
No optimistic update needed (the PUT body **is** the local state).
No re-fetch — assumes the server echoes the same values. **Error
path is missing**: a thrown PUT leaves `saving: true` permanently
(no `try/finally`). Flag for Step 4.
- **`saveDirs()`** — analogous against
`PUT /api/annotations/settings/directories`. Same missing
`try/finally` issue.
- **`handleToggleDefault(a)`** — duplicate of the same handler in
`AdminPage`: `PATCH /api/flights/aircrafts/${a.id}` with
`{ isDefault: !a.isDefault }` then optimistic local flip. Two copies
of the same logic in two pages — extract to a shared helper or to
`FlightContext` in Step 8 (the legacy WPF had a single
`AircraftService.SetDefault(...)`).
- **`field(label, value, onChange, type='text')`** — local helper that
renders a labeled `<input>`. Always controlled (`value={value ?? ''}`).
Converts numeric inputs via `parseInt(v) || 0` /
`parseFloat(v) || 0` at the call site. Note: `parseInt` / `parseFloat`
on an empty string returns `NaN`, and `NaN || 0` is `0` — so
clearing a numeric field silently writes `0`, not `null`. Flag for
Step 4 against the `SystemSettings` type which permits `null`.
- **Layout** — three independent flex children:
- Tenant (`w-[300px]` shrink-0): `field()` × 4 + Save.
- Directories (`w-[300px]` shrink-0): `field()` × 7 + Save.
- Aircrafts (`flex-1 max-w-sm`): list with star toggle.
## Dependencies
- **Internal**:
- `../../api/client` — `api`.
- `../../types` — `SystemSettings`, `DirectorySettings`, `Aircraft`.
- **External**: `react` (`useState`, `useEffect`),
`react-i18next` (`useTranslation`).
## Consumers (intra-repo)
- `src/App.tsx` — mounted at the `/settings` route inside the
protected tree.
## Data models
- `SystemSettings` includes `id, name, militaryUnit,
defaultCameraWidth, defaultCameraFoV, thumbnailWidth, thumbnailHeight,
thumbnailBorder, generateAnnotatedImage, silentDetection`. The page
exposes only the first four — every other field is read on GET and
echoed back on PUT untouched. Flag if a future cycle needs to expose
thumbnails / silent-detection toggles.
- `DirectorySettings` has all 7 directory fields exposed as text
inputs. Path validation is server-side only.
- `Aircraft` (`id, model, type, isDefault`) — same shape as in
`AdminPage`.
## Configuration
- **i18n keys consumed**: `settings.tenant`, `settings.directories`,
`settings.aircrafts`, `settings.save`. (Confirmed in
`src/i18n/en.json`.) Hardcoded English labels for every form field
("Military Unit", "Name", "Default Camera Width", "Default Camera
FoV", "Videos Dir", "Images Dir", "Labels Dir", "Results Dir",
"Thumbnails Dir", "GPS Sat Dir", "GPS Route Dir"). Flag for Step 4.
- **Tailwind tokens**: `bg-az-panel`, `bg-az-bg`, `bg-az-orange`,
`text-az-{text,muted,orange}`, `bg-az-blue/20`, `bg-az-green/20`,
`text-az-{blue,green}`, `border-az-border`. Defined in
`src/index.css`.
## External integrations
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/annotations/settings/system` | Load tenant config |
| `PUT` | `/api/annotations/settings/system` | Save tenant config |
| `GET` | `/api/annotations/settings/directories` | Load directory paths |
| `PUT` | `/api/annotations/settings/directories` | Save directory paths |
| `GET` | `/api/flights/aircrafts` | Load aircraft list |
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
Routed by `nginx.conf` to `annotations/` and `flights/` backends.
## Security
- **No client-side write authorization check** — the page renders the
Save buttons for every user. The backend (`annotations/` service)
is the authority. Document in `security_approach.md` (Step 6).
- **Hardcoded internal directory paths** are **not** present in this
file (unlike `AdminPage`'s hardcoded GPS device IP) — every value
is server-supplied. Good.
- **Path inputs are free-text** — server-side path traversal
validation is mandatory. Verify the `annotations/` service rejects
e.g. `../../etc/passwd`. Flag for Step 6.
- **`saving` state can stick on PUT failure** because there is no
`try/finally`, leaving the Save button permanently disabled. Step 4
candidate (matches the `AdminPage` "AI/GPS save buttons do
nothing" pattern — there is a clear lack of UX-level error
handling across both admin/settings pages).
## Tests
None.
## Notes / open questions
- **Numeric clear-to-zero pitfall**: `parseInt('') || 0` writes `0`
for an empty input, not `null`. This silently overwrites a
legitimate `null` (per the `SystemSettings` type, all four numeric
fields are nullable). Step 4 fix: detect empty input and pass
`null`.
- **Aircraft toggle handler is duplicated** between `AdminPage` and
`SettingsPage` — extract to a shared helper. Step 8.
- **No optimistic concurrency**: two admins editing system settings
simultaneously will overwrite each other. The `id` field is sent
back on PUT but no `version` / `etag` / `If-Match` header. Flag for
Step 6 problem-extraction. Backend may not support optimistic
concurrency yet.
- **The Aircrafts panel is read-only-ish here** but allows the
star-toggle, same as `AdminPage`. The duplication suggests an open
question: is this intentional (settings is "personal preferences",
admin is "global config", but default-aircraft is a *global*
setting) or accidental? Surface to the user in Step 6 problem
extraction.
- **`saving` is a single global flag** even though the page has two
independent Save buttons (system / dirs). A user who clicks
"Save System" then quickly clicks "Save Dirs" while the first PUT
is in flight will see the Dirs button disabled too. Acceptable
given the latency budget; flag if both saves become slow.
- **`thumbnailWidth/Height/Border` and `generateAnnotatedImage`,
`silentDetection`** in `SystemSettings` are not exposed in the UI
but are echoed back on PUT. If a future cycle adds them, ensure the
GET → PUT round-trip preserves any concurrent change made by another
client between the GET and the PUT.
@@ -0,0 +1,55 @@
# Module: `src/hooks/useDebounce.ts`
> **Source**: `src/hooks/useDebounce.ts` (12 lines)
> **Topo batch**: B1 (leaf)
## Purpose
Generic React hook that delays propagation of a rapidly-changing value to its consumers, used to throttle search inputs against the backend.
## Public interface
```ts
export function useDebounce<T>(value: T, delay: number): T
```
Returns the latest `value` only after `delay` milliseconds have elapsed without any further change. Generic in `T`, so callers can debounce strings, numbers, or any reference value (referential identity matters; see notes).
## Internal logic
`useState<T>(value)` for the debounced output; `useEffect` registers a `setTimeout(setDebounced, delay)` and returns a `clearTimeout` cleanup. The effect re-runs whenever `value` or `delay` changes — each new input value cancels the pending timeout from the previous render and starts a fresh one.
## Dependencies
- **Internal**: none.
- **External**: `react` (`useState`, `useEffect`).
## Consumers (intra-repo)
- `src/features/annotations/MediaList.tsx` — debounces the media-name search input.
- `src/features/dataset/DatasetPage.tsx` — debounces the dataset name-search filter (specified as 400ms in `_docs/ui_design/README.md` §"Dataset Explorer", but the actual delay is set by the caller).
## Data models
None.
## Configuration
None.
## External integrations
None.
## Security
None.
## Tests
None.
## Notes / open questions
- The hook compares values by referential equality (`useEffect` deps array). Passing a fresh object literal each render — e.g. `useDebounce({ q }, 300)` — would re-trigger the timer endlessly. Current callers pass primitives only; safe.
- No "leading edge" / "trailing edge" / "max wait" knobs. Adequate for the two current consumers; if/when richer behaviour is needed, prefer `useDebouncedCallback` from a library over extending this module.
@@ -0,0 +1,70 @@
# Module: `src/hooks/useResizablePanel.ts`
> **Source**: `src/hooks/useResizablePanel.ts` (33 lines)
> **Topo batch**: B1 (leaf)
## Purpose
React hook that backs a draggable splitter between two horizontally-arranged panels. Owns the panel width as state and exposes a mouse handler the host attaches to the splitter element.
## Public interface
```ts
export function useResizablePanel(
initialWidth: number,
min?: number, // default 100
max?: number, // default 600
): {
width: number
onMouseDown: (e: React.MouseEvent) => void
setWidth: React.Dispatch<React.SetStateAction<number>>
}
```
`width` is the current panel width (px). `onMouseDown` should be wired to the splitter element. `setWidth` is exposed for programmatic resets — currently unused by callers but kept for parity with the persistence story (see Notes).
## Internal logic
1. `useState(initialWidth)` for `width`.
2. Drag bookkeeping is held in three `useRef` cells (`dragging`, `startX`, `startWidth`) — kept out of state so the move/up handlers never re-render the host.
3. `onMouseDown` (memoized via `useCallback([width])`) snapshots `clientX` and `width`, marks `dragging = true`, and calls `e.preventDefault()` to avoid triggering text selection.
4. A `useEffect` registers global `mousemove` / `mouseup` listeners on `window`. While `dragging.current === true`, `mousemove` updates `width` to `clamp(startWidth + (e.clientX - startX), min, max)`. `mouseup` flips `dragging` back to `false`.
5. The effect cleans up on unmount.
## Dependencies
- **Internal**: none.
- **External**: `react` (`useState`, `useCallback`, `useRef`, `useEffect`).
## Consumers (intra-repo)
- `src/features/annotations/AnnotationsPage.tsx` — left + right panel widths.
- `src/features/dataset/DatasetPage.tsx` — left + right panel widths.
Both pages persist their layout server-side via `UserSettings.{annotationsLeftPanelWidth, annotationsRightPanelWidth, datasetLeftPanelWidth, datasetRightPanelWidth}` (see `src/types/index.ts`). The persistence read/write happens in the page, not in this hook.
## Data models
None.
## Configuration
None.
## External integrations
DOM only — `window.addEventListener('mousemove' / 'mouseup')`.
## Security
None.
## Tests
None.
## Notes / open questions
- The hook is keyboard- and touch-blind: only mouse drag is supported, so accessibility for non-pointer input is missing. Track for the broader a11y pass; out of scope for `/document`.
- `setWidth` is exposed but no current caller hydrates `initialWidth` from `UserSettings` via it — they pass the persisted width directly to `useResizablePanel(persistedWidth)` on mount. If the persisted width arrives **after** mount (asynchronous load), the panel jumps. Flag for the consumers' module docs (B8).
- Default bounds (100 / 600) are arbitrary; consumer pages may want different ceilings (e.g., the annotations sidebar). Currently both consumers accept the defaults.
@@ -0,0 +1,64 @@
# Module: `src/i18n/i18n.ts`
> **Source**: `src/i18n/i18n.ts` (13 lines)
> **Topo batch**: B2 (depends only on JSON data files)
## Purpose
Module-load-time initialisation of `i18next` with the `react-i18next` adapter. Imports the English and Ukrainian translation tables and registers them as the available `resources`. Exporting the configured singleton lets call sites use `useTranslation()` directly.
## Public interface
```ts
export default i18n // configured i18next instance
```
## Internal logic
```ts
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, ua: { translation: ua } },
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
```
- `lng: 'en'` is hardcoded at boot. There is **no** runtime mechanism to detect the user's locale or to read a saved preference. Switching language at runtime works (via `i18n.changeLanguage(...)`) but the page reload always lands on English first.
- `escapeValue: false` is the documented default for React (which already escapes JSX text).
- Module side-effect at top level: `init()` runs as soon as `src/main.tsx` imports this file.
## Dependencies
- **Internal**: `./en.json`, `./ua.json` (JSON imports — Vite handles).
- **External**: `i18next`, `react-i18next`.
## Consumers (intra-repo)
- `src/main.tsx` — side-effect import (`import './i18n/i18n'`).
- Indirectly: every component that calls `useTranslation()` from `react-i18next` (e.g. `ConfirmDialog`, `HelpModal`, `JsonEditorDialog`, …).
## Data models
The two JSON resource files (`en.json`, `ua.json`) define the key namespace. Per `_docs/ui_design/README.md` §"Localization" and `_docs/legacy/wpf-era.md` §10, the SPA must support EN + UA at parity. Inspection of the JSON files (deferred to Step 4 verification) will confirm whether parity is held.
## Configuration
`lng: 'en'` and `fallbackLng: 'en'` are inlined. `localStorage` persistence (e.g., via `i18next-browser-languagedetector`) would be the natural Step 8 enhancement for "remember user choice"; deferred.
## External integrations
None.
## Security
`escapeValue: false` is correct in React but would be unsafe if any consumer rendered translation values via `dangerouslySetInnerHTML`. None do (verify in Step 4).
## Tests
None.
## Notes / open questions
- The `mission-planner/` sub-project does NOT use `react-i18next` — it has its own `LanguageContext` + raw `translations` table (see `00_discovery.md` §11 finding 8). The port to `src/features/flights/` should consume this module instead. Track for MP-B3.
- Adding a third language is a one-line edit (`resources: { en, ua, <new> }`) plus a JSON file. Document the procedure in the final `solution.md`.
@@ -0,0 +1,83 @@
# Module: `src/types/index.ts`
> **Source**: `src/types/index.ts` (161 lines)
> **Topo batch**: B1 (leaf — no internal imports)
## Purpose
Shared TypeScript contract surface for the SPA: pure type aliases, interfaces, and numeric enums describing the DTOs that travel between the React UI and the suite backends (`admin/`, `annotations/`, `flights/`, `loader/`, etc.).
## Public interface
Generic shape:
| Symbol | Kind | Notes |
|---|---|---|
| `PaginatedResponse<T>` | `interface` | `{ items, totalCount, page, pageSize }`. Returned by paged list endpoints (e.g., `GET /api/flights?pageSize=...`). |
Domain entities (mirroring backend DTOs):
| Symbol | Kind | Backing concept |
|---|---|---|
| `Media` | `interface` | A media file (image / video) attached to a flight. Fields: `id, name, path, mediaType, mediaStatus, duration, annotationCount, waypointId, userId`. |
| `Detection` | `interface` | A bounding box. Fields: `id, classNum, label, confidence, affiliation, combatReadiness, centerX, centerY, width, height` (normalized 01 coords, see `_docs/legacy/wpf-era.md` §10). |
| `AnnotationListItem` | `interface` | A frame's annotation set. `time` is `string \| null` (ISO duration), `splitTile` carries tile zoom info for split-image detections. Embeds `Detection[]`. |
| `DetectionClass` | `interface` | Admin-managed detection class metadata (`id, name, shortName, color, maxSizeM, photoMode`). Drives the Detection Classes panel + GSD validation. |
| `Flight`, `Aircraft`, `Waypoint` | `interface` | Flight feature entities. `Aircraft.type` is union `'Plane' \| 'Copter'`. |
| `SystemSettings`, `DirectorySettings`, `CameraSettings`, `UserSettings` | `interface` | Settings domain. `UserSettings` carries the global `selectedFlightId` plus per-page panel widths persisted server-side via the annotations API (see `src/components/FlightContext.tsx`). |
| `DatasetItem` | `interface` | Row in the Dataset Explorer grid (`annotationId, imageName, thumbnailPath, status, createdDate, createdEmail, flightName, source, isSeed, isSplit`). |
| `ClassDistributionItem` | `interface` | Aggregate row for the Class Distribution chart. |
| `User` | `interface` | Admin user list row. |
| `AuthUser` | `interface` | Authenticated session user (`id, email, name, role, permissions[]`); used by `AuthContext`. |
Numeric enums (must match backend wire values bit-for-bit):
| Enum | Members | Used by |
|---|---|---|
| `MediaType` | `None=0, Image=1, Video=2` | media list, video player branching |
| `MediaStatus` | `New=0, AiProcessing=1, AiProcessed=2, ManualCreated=3` | media list state |
| `AnnotationSource` | `AI=0, Manual=1` | annotation provenance |
| `AnnotationStatus` | `Created=0, Edited=1, Validated=2` | dataset filter buttons |
| `Affiliation` | `Unknown=0, Friendly=1, Hostile=2` | bounding-box icon (see `_docs/legacy/wpf-era.md` §10) |
| `CombatReadiness` | `NotReady=0, Ready=1` | bounding-box readiness dot |
## Internal logic
None. Declarations file only.
## Dependencies
- **Internal**: none (true leaf).
- **External**: none.
## Consumers (intra-repo)
`src/auth/AuthContext.tsx`, `src/components/FlightContext.tsx`, `src/components/Header.tsx`, `src/components/DetectionClasses.tsx`, `src/api/*` (none directly — API calls are typed at call sites), every page under `src/features/*` except `src/features/login/LoginPage.tsx`. Roughly 15 importing files.
## Data models
The whole module **is** the data-model surface for the SPA. Two implicit constraints:
1. **Numeric enum values are wire-coupled** — changing them silently breaks every backend that returns the same number. Treated as a public contract; downstream changes must coordinate with the corresponding `.NET` / Cython service.
2. **`*.id` is `string`** for every entity (suite uses GUIDs), but `Detection.classNum` and the four enums are `number`. Mixed-key shapes are intentional.
## Configuration
None.
## External integrations
None directly. The types are the *contract* surface for HTTP responses from `/api/admin/*`, `/api/annotations/*`, `/api/flights/*`. Ground truth for shapes lives in the respective backend submodule (`suite/admin/`, `suite/annotations/`, `suite/flights/`).
## Security
`AuthUser.permissions: string[]` is the only security-relevant field — checked by `AuthContext.hasPermission(perm)`. The permission strings are not enumerated here; they are defined by the `admin/` service.
## Tests
None.
## Notes / open questions
- `Media.duration` is `string | null` rather than `number | null` — this matches a `TimeSpan`-style ISO duration the backend returns. Document the format precisely once the consumer (`VideoPlayer`) is reached in B7.
- `UserSettings` mixes "selected flight" with "per-page panel widths" — possibly a candidate for splitting along ownership lines, but the API endpoint (`/api/annotations/settings/user`) treats them as one document. Out of scope for `/document`.