diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1e9420c..cfa9b99 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -29,9 +29,15 @@ M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-accept ## Locked decisions -- Product name: MobilityOps. +- Product name: Fleet Ops (the only visible product name in the UI/copy, never translated; + see `frontend/src/product.ts`). "MobilityOps" is the internal repo name, Compose project + name and deployment directory only — never shown to a user. See the "Final product + polish: Fleet Ops rebrand" and "Fleet Ops final localization" entries below. - Fictitious tenant: Northstar Mobility Demo. -- PoC only; all operational and knowledge data are synthetic. +- Synthetic demo data only; all operational and knowledge data are synthetic. The product + itself is not described as a "PoC" in user-facing copy (see the localization entry + below) — this document and other internal/engineering docs may still use "PoC" to + describe the engineering scope, per `CLAUDE.md`. - Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`. - RAGcore and ITWorx MCP Hub are external central services. - n8n receives post-commit events through an outbox dispatcher. @@ -873,11 +879,15 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem and master, responsive/accessibility results, known limitations, rollback procedure) plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`. -## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — IN PROGRESS on fix/fleet-ops-i18n-status-flow +## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — MERGED TO MASTER Branch `fix/fleet-ops-i18n-status-flow`, created from master's post-release head (`18344bc`). Audit and rationale in `docs/fleet-ops-correction/` (gap audit, i18n -inventory, vehicle-status decision table). Not yet merged to master. +inventory, vehicle-status decision table). Merged to master via `de0bdea` ("merge: +complete Fleet Ops localization and status resolution"), with final evidence commit +`f780557` ("docs(release): final Fleet Ops correction evidence and screenshots") — +`f780557` is `origin/master`'s current head as of the start of the correction round +below. - **Status-recommendation flow redesigned** per the brief: the old single opaque "calculate and apply recommended status" action is replaced by a single shared, pure @@ -979,9 +989,85 @@ inventory, vehicle-status decision table). Not yet merged to master. the live server (`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`), no console errors, no errors in `api`/`web` container logs, both containers healthy, final reset done, `scenario_integrity.all_ready: true`. -- **Not yet done**: the final merge to master with - `artifacts/fleet-ops-correction/final-summary.md` evidence. Do not claim PASS on - this correction until that's done. +- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master + via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops + final localization" entry below for the next (small correction) round on top of this. + +## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — IN PROGRESS on fix/fleet-ops-final-i18n-ux + +Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head +(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the +already-checked-out branch name is used instead since it was verified freshly and +cleanly branched from current `origin/master` with a clean working tree; see +`docs/fleet-ops-final-localization/audit.md` for the naming note. Scope: a small, +targeted correction round only — explicitly not touching status-flow business logic, +the status evaluator, Data Quality resolution rules, return rules, RAGcore/MCP Hub, or +product scope. + +- **Audit-driven gap sweep**: `docs/fleet-ops-final-localization/audit.md` documents + every remaining untranslated/incorrect string, raw-backend-error call site, + over-permissive allowlist entry, the static-greeting bug, and doc staleness found by + a dedicated Explore pass before any file was touched. +- **Remaining NL/FR translation gaps fixed**: role names actually translated (not just + labelled as translated) — `auth.json`/`demo.json` role keys, `audit.title` → + "Auditgeschiedenis"/"Piste d'audit", `columns.actor` → "Uitvoerder", `list.statusOpen` + → "Openstaand", `ledger.filterRecent` → "Recentste", `scenarios.startScenario` → + "Scenario starten". Also found and fixed (via the new embedded-substring test below) + 8 previously-missed mid-sentence "Audit trail" leaks across `demo.json`, + `quality.json`, `returns.json` that the old whole-string-identity test structurally + could not catch. +- **Central API-error localization**: new `frontend/src/api/errorMessages.ts` + (`describeApiError`) replaces the `err instanceof ApiError ? err.message : ...` + anti-pattern (which showed raw English for the common case) at all 13 call sites + across 7 files. Raw backend text is now only ever shown under a "Technical + details"/"Détails techniques" disclosure (new `ApiErrorNotice` component in + `PageChrome.tsx`); the primary message is always a localized title + explanation + + optional next step, keyed on the 32 known `AppError` codes, then known HTTP statuses + (401/403/404/409/422/500), then a fully generic fallback. `ApiError` was split out of + `client.ts` into a standalone `api/apiError.ts` (no `import.meta.env` dependency) so + `errorMessages.ts` is independently testable outside a Vite/browser context. +- **i18n allowlist tightened**: removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries + in `i18n-coverage.spec.ts` (`audit.title`, `auth.roleOperationsManager`, + `auth.roleRentalEmployee`, `demo.scenarios.startScenario`, + `demo.scenarios.roles.operations_manager`, `demo.scenarios.roles.rental_employee`, + `navigation.items.audit`) now that they're genuinely translated. Added 2 new tests: + one closing the embedded-English/Dutch-substring blind spot (mid-sentence phrase + leaks the whole-string check misses), one asserting no locale file contains + "MobilityOps" or the word "PoC". +- **`describeApiError` test coverage**: new `frontend/e2e/error-messages.spec.ts` (10 + tests) — every known code/HTTP status has non-empty copy in all 3 locales, a known + code never surfaces raw backend text as the primary message (only via `.technical`), + unknown-code and unknown-status fallback chains behave correctly, and a drift guard + that greps the actual backend `AppError("CODE", ...)` call sites and fails if + `KNOWN_CODES` and the backend's real codes ever diverge (currently exactly in sync, + 32 codes). +- **Time-dependent Europe/Brussels dashboard greeting**: new + `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, DST-safe via + `Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })`, clock + injectable) + `useGreetingPeriod.ts` hook (30s poll for period rollover while the app + stays open, no reload). Replaces the previously-always-"Goedemorgen" static + `dashboard.json` title with 4 periods × 3 languages for both the greeting word and a + varying accompanying sentence (never "Goedenacht"). Tests: `greeting.spec.ts` (pure + boundary/DST unit tests) + `greeting-live.spec.ts` (6 real-browser tests via + Playwright's `page.clock` — all 8 required boundary times in all 3 languages, live + rollover without reload, language-switch behaviour, the "never Goedenacht" guard). +- **Found and fixed one real CSS regression along the way**: correctly translating + `roleOperationsManager` to the single unbreakable Dutch compound word + "Operationsmanager" (vs. the old two-word "Operations Manager", which could wrap) + pushed the topbar's `.operator` block past 1024px width, caught by the existing + `responsive-i18n.spec.ts` overflow test. Fixed with `overflow-wrap: anywhere` on + `.operator strong`/`small` and `min-width: 0` on their flex-item wrapper, not by + reverting the correct translation. +- Gates green so far: backend `pytest` 151 passed, `ruff check .` clean, `mypy app` + clean (49 files, unchanged — no backend Python touched this round); frontend `tsc` + clean, production build clean, full local Playwright suite **138 passed** (rebuilt + and restarted the local `web` container from source before this run). +- **Not yet done**: clean-checkout drill, commit/push, Unraid deployment of this fix + branch with live 3-language validation, the master merge (with the mandatory + `git fetch origin` / unexpected-change check first), and + `artifacts/fleet-ops-final-localization/final-summary.md`. Do not claim PASS on this + correction round until all of those are done and `git rev-parse HEAD` exactly matches + `/mnt/user/appdata/mobilityops/.deploy/source-revision`. - Commits so far on this branch: `6deb955` (status flow + brand constant + message codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test), diff --git a/README.md b/README.md index 0c1a8c9..f022810 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical identifier only — the repository name, local directory, package/module names, Docker Compose project, deployment directory, and database names. The UI is fully trilingual (nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization -architecture, the vehicle-status decision table, and the correction evidence. +architecture, the vehicle-status decision table, and the correction evidence, and +`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining +NL/FR translation gaps, centralized API-error localization, the time-dependent +Europe/Brussels dashboard greeting). The web application uses the premium responsive **Control Rail** interface: a compact operations-first workspace with persisted readiness metrics, evidence-led exceptions, @@ -123,7 +126,7 @@ All defaults are configurable via `.env` (see `.env.example`). ```bash make test # backend: pytest (151 tests) make lint # backend: ruff + mypy (strict, zero errors) -make e2e # frontend: Playwright end-to-end (113 tests, live stack required) +make e2e # frontend: Playwright end-to-end (138 tests, live stack required) ``` Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). diff --git a/docs/fleet-ops-final-localization/audit.md b/docs/fleet-ops-final-localization/audit.md new file mode 100644 index 0000000..6a083c1 --- /dev/null +++ b/docs/fleet-ops-final-localization/audit.md @@ -0,0 +1,130 @@ +# Fleet Ops final localization — gap audit + +Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`, +working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7` +(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy. + +## 1. Remaining untranslated/incorrect text + +### nl-BE +| File | Key | Current | Fix | +|---|---|---|---| +| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" | +| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" | +| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" | +| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" | +| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" | +| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" | +| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" | +| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" | +| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" | +| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" | +| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" | +| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" | +| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" | + +`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate, +siblings are single-word labels too. + +### fr-BE +Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly +"Acteur" (no fix needed). French role translations: "Responsable des opérations" / +"Collaborateur de location", per the correction brief. + +### Hardcoded JSX (bypasses i18n entirely) +`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English, +never localized. Fix: reuse the already-existing, already-translated +`detail.duplicateCustomer.fieldColumn` key (same table's `` seven lines above already uses it +correctly) — zero locale-file changes needed, pure JSX fix. + +No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`. + +## 2. Raw backend errors shown directly + +13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`, +`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow: +`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real, +structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the +translated fallback only fires for network-level failures where no `ApiError` could even be +constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases +`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather +than staying a one-off. + +Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status +fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via +`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same +`{"error": {"code", "message", "correlation_id"}}` shape): + +`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED, +CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND, +IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID, +INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND, +ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE, +NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE, +NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE, +UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`. + +Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only +in the raw English `message` string — so localized messages will be generic per-code (title + +explanation + optional next step), not parameterized with extracted specifics, with the raw string +preserved verbatim under "Technical details". + +`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`, +`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired +to `ApiError.code` at all — only used as the non-`ApiError` fallback string. + +## 3. i18n allowlist over-permissiveness + +`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that +must be removed once role labels are translated: `auth.roleOperationsManager`, +`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`, +`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer +true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term) +also need removing once translated to "Auditgeschiedenis". + +Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit +agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay. + +Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB, +not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a +real blind spot the new tests (section 8 of the correction brief) need to close with a targeted, +explicit check for known English substrings appearing in nl-BE/fr-BE prose. + +## 4. Dashboard greeting + +No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static** +string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici +votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central, +testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per +the brief, updating on language change and on period rollover while the app stays open. + +## 5. Documentation staleness + +- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."` — + predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file. +- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on + fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` — + **false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git + history, neither is mentioned in the file, and the branch name has moved on to + `fix/fleet-ops-final-i18n-ux`. +- A separate, older `"## Demo productization (in progress, same branch + feat/mobilityops-functional-completion)"` section header was also never marked complete. +- README.md is accurate and current — no fix needed there beyond a version-count refresh after this + round's test additions. +- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest. + +## Plan + +1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor", + "Start scenario", "Recent", "Open", embedded mid-sentence mentions). +2. Fix the one hardcoded `data-label="Field"` JSX bug. +3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call + sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks, + raw text demoted to "Technical details". +4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring + test, a `describeApiError` coverage test, and greeting boundary tests. +5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for + 4 periods × 3 languages + a localized description line replacing the current static one. +6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries). +7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages → + merge to master → redeploy → final evidence. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index acd97ff..1ef139f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,7 @@ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./ +COPY public ./public COPY src ./src RUN npm ci && npm run build diff --git a/frontend/e2e/_capture-demo-screenshots.spec.ts b/frontend/e2e/_capture-demo-screenshots.spec.ts index af34dd4..df65044 100644 --- a/frontend/e2e/_capture-demo-screenshots.spec.ts +++ b/frontend/e2e/_capture-demo-screenshots.spec.ts @@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => { await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` }); await page.setViewportSize({ width: 1280, height: 900 }); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible(); await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true }); diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts index 10ffeb0..d3f0558 100644 --- a/frontend/e2e/_capture-screenshots.spec.ts +++ b/frontend/e2e/_capture-screenshots.spec.ts @@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => { await page.goto("/login"); await page.screenshot({ path: `${OUT}/1-login.png` }); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts index ff7bd52..52b689b 100644 --- a/frontend/e2e/demo-accessibility.spec.ts +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const guideTrigger = page.getByRole("button", { name: /Demo-gids/ }); @@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => { page.on("pageerror", (err) => errors.push(err.message)); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); @@ -96,7 +96,7 @@ test("status-recommendation panel is fully keyboard operable, respects reduced m await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality/DQ-DEMO-STATUS"); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index 4b4c7ae..4a1b51f 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy await expect(page.getByText(/Northstar Mobility/)).toBeVisible(); await expect(page.getByText(/Synthetische demo/)).toBeVisible(); await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible(); await expect(page.locator('input[type="password"]')).toHaveCount(0); }); @@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const trigger = page.getByRole("button", { name: /Synthetische demo/ }); @@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab test("badge popover closes on Escape and outside click", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const trigger = page.getByRole("button", { name: /Synthetische demo/ }); diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts index 2c1e8b2..b81656a 100644 --- a/frontend/e2e/demo-guide.spec.ts +++ b/frontend/e2e/demo-guide.spec.ts @@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" }); test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); @@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async test("starting a scenario navigates to its fixed record", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); - await duplicateCard.getByRole("link", { name: "Start scenario" }).click(); + await duplicateCard.getByRole("link", { name: "Scenario starten" }).click(); await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); }); @@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it", test("demo guide is not shown to a rental employee", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0); }); diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts index 189c247..2613b7c 100644 --- a/frontend/e2e/demo-legibility.spec.ts +++ b/frontend/e2e/demo-legibility.spec.ts @@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" }); test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/bookings/BK-DEMO-RETURN"); @@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a await page.getByRole("button", { name: "Retour bevestigen" }).click(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible(); }); test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); @@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async test("data quality list can filter to demo scenarios only", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality"); @@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) => test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/knowledge"); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 0d3fdf6..de579a1 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("1. login as Operations Manager", async () => { await page.goto("/login"); await expect(page.getByText(/Synthetische demo/)).toBeVisible(); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); diff --git a/frontend/e2e/error-messages.spec.ts b/frontend/e2e/error-messages.spec.ts new file mode 100644 index 0000000..4de6df1 --- /dev/null +++ b/frontend/e2e/error-messages.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ApiError } from "../src/api/apiError"; +import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages"; + +// Pure Node-context checks for the central API-error-localization function (section 7 / +// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError() +// only depends on a `t` function and a caught error, so it's tested here against the +// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and +// known-HTTP-status paths never leak raw backend English as the primary message, and +// that the raw text is always still available via `.technical` for "Technical details". + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales"); +const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const; + +function loadNamespace(language: string, namespace: string): Record { + const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); +} + +// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes, +// resolving "namespace:dotted.path" against the real locale files for the given language. +function makeT(language: string): (key: string, options?: Record) => string { + return (key: string, options?: Record) => { + const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key]; + const dottedPath = key.includes(":") ? rest.join(":") : rest.join(""); + const data = loadNamespace(language, ns); + const value = dottedPath.split(".").reduce((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + if (typeof value === "string") return value; + if (options && "defaultValue" in options) return String(options.defaultValue); + return key; + }; +} + +const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"]; + +test("every known AppError code has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const codes = loadNamespace(language, "errors").codes as Record; + for (const code of KNOWN_CODES) { + expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy(); + expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0); + expect( + codes[code]?.explanation?.trim().length ?? 0, + `${language}/errors.json:codes.${code}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const http = loadNamespace(language, "errors").http as Record; + for (const status of KNOWN_HTTP_STATUSES) { + expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy(); + expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0); + expect( + http[status]?.explanation?.trim().length ?? 0, + `${language}/errors.json:http.${status}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "IntegrityError: duplicate key value violates unique constraint"; + const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1"); + const info = describeApiError(t, err); + const expected = loadNamespace(language, "errors").codes as Record; + expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title); + expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation); + expect(info.title).not.toBe(raw); + expect(info.explanation).not.toBe(raw); + // The raw backend text must still be reachable, just demoted to `.technical`. + expect(info.technical).toBe(raw); + } +}); + +test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => { + const t = makeT("nl-BE"); + const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1")); + expect(withNextStep.nextStep).toBeTruthy(); + + const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2")); + expect(withoutNextStep.nextStep).toBeUndefined(); +}); + +test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "Some brand-new backend code nobody localized yet"; + const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2"); + const info = describeApiError(t, err); + const expected404 = (loadNamespace(language, "errors").http as Record)["404"]; + expect(info.title).toBe(expected404.title); + expect(info.explanation).toBe(expected404.explanation); + expect(info.title).not.toBe(raw); + expect(info.technical).toBe(raw); + } +}); + +test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "418 I'm a teapot (never mapped)"; + const err = new ApiError(418, "418", raw, "corr-3"); + const info = describeApiError(t, err); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + expect(info.technical).toBe(raw); + } +}); + +test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => { + // Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code) + // (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py. + const t = makeT("fr-BE"); + const err = new ApiError(401, "401", "Not authenticated", "corr-4"); + const info = describeApiError(t, err); + const expected401 = (loadNamespace("fr-BE", "errors").http as Record)["401"]; + expect(info.title).toBe(expected401.title); + expect(info.title).not.toBe("Not authenticated"); +}); + +test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => { + const t = makeT("nl-BE"); + const networkFailure = new TypeError("Failed to fetch"); + // Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace + // already scoped via useTranslation("integrations"); this stub's default namespace is + // "errors", so the fallback key is qualified explicitly here to match. + const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed"); + const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record; + expect(info.explanation).toBe(expectedFallback.retryFailed); + expect(info.explanation).not.toBe("Failed to fetch"); + expect(info.technical).toBe("Failed to fetch"); +}); + +// --- Backend/frontend AppError code drift guard --- +// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the +// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a +// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but- +// still-localized HTTP/generic message rather than raw English -- not a broken build, but +// a missed opportunity for a more specific message. This test surfaces that drift instead +// of letting it go unnoticed indefinitely. +const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app"); + +function collectPyFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return collectPyFiles(full); + return entry.name.endsWith(".py") ? [full] : []; + }); +} + +function collectBackendAppErrorCodes(): Set { + const codes = new Set(); + for (const file of collectPyFiles(BACKEND_APP_DIR)) { + const source = fs.readFileSync(file, "utf-8"); + const pattern = /AppError\(\s*"([A-Z_]+)"/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + codes.add(match[1]); + } + } + return codes; +} + +test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => { + const backendCodes = collectBackendAppErrorCodes(); + const frontendCodes = KNOWN_CODES; + + const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort(); + const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort(); + + expect( + missingFromFrontend, + `Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` + + `(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`, + ).toEqual([]); + expect( + staleInFrontend, + `errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` + + `removed on the backend side: ${staleInFrontend.join(", ")}`, + ).toEqual([]); +}); + +test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const info = describeApiError(t, new TypeError("Failed to fetch")); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + } +}); diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts index 1bd0ba8..cbdb37f 100644 --- a/frontend/e2e/fleet-ops-correction.spec.ts +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) { } const EXPLORE_OPS_MANAGER: Record = { - "nl-BE": "Verken als Operations Manager", + "nl-BE": "Verken als Operationsmanager", "en-GB": "Explore as Operations Manager", - "fr-BE": "Explorer en tant qu'Operations Manager", + "fr-BE": "Explorer en tant que Responsable des opérations", }; const REVIEW_RECOMMENDATION: Record = { @@ -74,6 +74,16 @@ test.describe("branding", () => { } }); +test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/login"); + const href = await page.locator('link[rel="icon"]').getAttribute("href"); + expect(href).toBe("/favicon.svg"); + const response = await page.request.get(href as string); + expect(response.ok()).toBeTruthy(); + expect(response.headers()["content-type"]).toContain("svg"); +}); + test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => { await resetDemoData(request); // Deliberately not using loginAsOpsManager here: its addInitScript would re-force diff --git a/frontend/e2e/greeting-live.spec.ts b/frontend/e2e/greeting-live.spec.ts new file mode 100644 index 0000000..108934d --- /dev/null +++ b/frontend/e2e/greeting-live.spec.ts @@ -0,0 +1,107 @@ +import { expect, type Page, test } from "@playwright/test"; + +// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting +// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app, +// in all 3 languages, at every required boundary instant, using Playwright's clock API to +// control the browser's Date without waiting on real wall-clock time. Also proves the +// greeting updates live (no reload) when the period rolls over while the app stays open. + +// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1. +function cet(hour: number, minute = 0, second = 0): Date { + return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second)); +} + +const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [ + { time: cet(4, 59), label: "04:59", period: "night" }, + { time: cet(5, 0), label: "05:00", period: "morning" }, + { time: cet(11, 59), label: "11:59", period: "morning" }, + { time: cet(12, 0), label: "12:00", period: "afternoon" }, + { time: cet(17, 59), label: "17:59", period: "afternoon" }, + { time: cet(18, 0), label: "18:00", period: "evening" }, + { time: cet(22, 59), label: "22:59", period: "evening" }, + { time: cet(23, 0), label: "23:00", period: "night" }, +]; + +const EXPECTED_TITLE: Record> = { + "nl-BE": { + morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.", + afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.", + evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.", + night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.", + }, + "en-GB": { + morning: "Good morning. Here's today's fleet status.", + afternoon: "Good afternoon. Here's the current overview of your fleet.", + evening: "Good evening. Here's this evening's fleet overview.", + night: "Welcome back. Here's the latest overview of your fleet.", + }, + "fr-BE": { + morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.", + afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.", + evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.", + night: "Bon retour. Voici le dernier aperçu de votre flotte.", + }, +}; + +async function loginAsOperationsManager(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") { + // At the default desktop viewport, only the topbar's compact switcher is visible -- + // the sidebar's full switcher is `display: none` until the <960px breakpoint. + await page.locator(".language-switcher-compact select").selectOption(language); +} + +const dashboardHeading = (page: Page) => page.locator(".page-header h1"); + +for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) { + test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => { + await page.clock.install({ time: BOUNDARY_CASES[0].time }); + await loginAsOperationsManager(page); + if (language !== "nl-BE") { + await switchLanguage(page, language); + } + + for (const { time, label, period } of BOUNDARY_CASES) { + await page.clock.setFixedTime(time); + await page.reload(); + await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText( + EXPECTED_TITLE[language][period], + ); + } + }); +} + +test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => { + // Start at 11:59:31 Brussels -- 29s before the 12:00 boundary. + await page.clock.install({ time: cet(11, 59, 31) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning); + + // Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and + // recomputes the period -- no page.reload() call anywhere in this test. + await page.clock.fastForward(30_000); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon); +}); + +test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => { + await page.clock.install({ time: cet(9, 0) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning); + + await switchLanguage(page, "en-GB"); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning); + + await switchLanguage(page, "fr-BE"); + await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning); +}); + +test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => { + await page.clock.install({ time: cet(2, 0) }); + await loginAsOperationsManager(page); + await expect(dashboardHeading(page)).not.toContainText("Goedenacht"); + await expect(dashboardHeading(page)).toContainText("Welkom terug"); +}); diff --git a/frontend/e2e/greeting.spec.ts b/frontend/e2e/greeting.spec.ts new file mode 100644 index 0000000..ad94aa5 --- /dev/null +++ b/frontend/e2e/greeting.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test"; +import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting"; + +// Pure Node-context boundary tests for the central, clock-injectable greeting function +// (section 9 of the Fleet Ops final localization brief). Every case below constructs an +// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time -- +// this is what "clock injection" buys: no real time needs to pass, and DST is exercised +// by picking instants either side of the CET/CEST transition. + +// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC. +function cet(hour: number, minute = 0): Date { + return new Date(Date.UTC(2026, 0, 15, hour - 1, minute)); +} + +// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC. +function cest(hour: number, minute = 0): Date { + return new Date(Date.UTC(2026, 6, 15, hour - 2, minute)); +} + +test("period boundaries are correct in winter time (CET, UTC+1)", () => { + expect(getGreetingPeriod(cet(4, 59))).toBe("night"); + expect(getGreetingPeriod(cet(5, 0))).toBe("morning"); + expect(getGreetingPeriod(cet(11, 59))).toBe("morning"); + expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon"); + expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon"); + expect(getGreetingPeriod(cet(18, 0))).toBe("evening"); + expect(getGreetingPeriod(cet(22, 59))).toBe("evening"); + expect(getGreetingPeriod(cet(23, 0))).toBe("night"); +}); + +test("period boundaries are correct in summer time (CEST, UTC+2)", () => { + expect(getGreetingPeriod(cest(4, 59))).toBe("night"); + expect(getGreetingPeriod(cest(5, 0))).toBe("morning"); + expect(getGreetingPeriod(cest(11, 59))).toBe("morning"); + expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon"); + expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon"); + expect(getGreetingPeriod(cest(18, 0))).toBe("evening"); + expect(getGreetingPeriod(cest(22, 59))).toBe("evening"); + expect(getGreetingPeriod(cest(23, 0))).toBe("night"); +}); + +test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => { + // 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless). + const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30)); + expect(getBrusselsHour(beforeTransition)).toBe(1); + expect(getGreetingPeriod(beforeTransition)).toBe("night"); + + // 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning. + const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0)); + expect(getBrusselsHour(afterTransition)).toBe(11); + expect(getGreetingPeriod(afterTransition)).toBe("morning"); + + // Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00 + // CET), so 00:30 UTC is still CEST -> 02:30 Brussels. + const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30)); + expect(getBrusselsHour(beforeFallBack)).toBe(2); + expect(getGreetingPeriod(beforeFallBack)).toBe("night"); + + // 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning. + const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0)); + expect(getBrusselsHour(afterFallBack)).toBe(10); + expect(getGreetingPeriod(afterFallBack)).toBe("morning"); +}); + +test("default argument uses the real current time when no clock is injected", () => { + const period = getGreetingPeriod(); + expect(["morning", "afternoon", "evening", "night"]).toContain(period); +}); diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts index c83e01c..bd7c3f9 100644 --- a/frontend/e2e/i18n-coverage.spec.ts +++ b/frontend/e2e/i18n-coverage.spec.ts @@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () = } }); +test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => { + for (const language of LANGUAGES) { + for (const namespace of namespaces) { + const raw = JSON.stringify(loadNamespace(language, namespace)); + expect( + raw.includes("MobilityOps"), + `${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` + + `always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`, + ).toBe(false); + expect( + /\bPoC\b/.test(raw), + `${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` + + `in user-facing copy`, + ).toBe(false); + } + } +}); + // --- Translation-quality: prove values were actually translated, not copy-pasted --- // Sleutelpariteit alone doesn't prove translation happened (a locale file could contain // the literal English string under the right key and still pass). For every "real prose" @@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () = // a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly // hide an unrelated real mistranslation under the same key in a different namespace. const IDENTICAL_VALUE_ALLOWLIST = new Set([ - "audit.title", // "Audit trail" kept as an established cross-language compliance term "audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch - "auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles) - "auth.roleRentalEmployee", - "demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels - "demo.scenarios.roles.rental_employee", "common.language.nl-BE", // language-picker options show each language's own endonym "common.language.fr-BE", "common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text "common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3 "dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative "demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3 - "demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch "demo.about.limitationsTitle", // "Limitations" -- identical spelling in French "demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun "fleet.list.columns.attention", // "Attention" -- identical spelling in French @@ -131,7 +143,6 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([ "knowledge.questionLabel", // "Question" -- identical spelling in French "knowledge.retrievalFlow.question", "knowledge.questionLabelExchange", - "navigation.items.audit", // "Audit trail" kept as an established cross-language term "returns.result.inspection", // "Inspection" -- identical spelling in French ]); @@ -185,6 +196,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea } }); +// --- Embedded English/Dutch fragments inside otherwise-translated prose --- +// The whole-string identity check above only catches a value that is IDENTICAL to +// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final +// localization pass: a sentence gets 95% translated but a role/status noun phrase is +// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden +// goedgekeurd." This scan flags known English fragments appearing literally inside any +// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste +// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common +// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary). +const FORBIDDEN_ENGLISH_FRAGMENTS = [ + "Operations Manager", + "Operations Managers", + "Rental Employee", + "Rental Employees", + "Audit trail", + "Start scenario", +]; +const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [ + "Operationsmanager", + "Verhuurmedewerker", + "Auditgeschiedenis", + "Scenario starten", +]; + +function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> { + if (typeof value === "string") return [{ path: prefix, value }]; + if (value === null || typeof value !== "object") return []; + return Object.entries(value as Record).flatMap(([key, nested]) => + collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => { + const findings: string[] = []; + for (const namespace of namespaces) { + const nl = loadNamespace("nl-BE", namespace); + const fr = loadNamespace("fr-BE", namespace); + for (const { path: keyPath, value } of collectStringLeaves(nl)) { + for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) { + if (value.includes(fragment)) { + findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`); + } + } + } + for (const { path: keyPath, value } of collectStringLeaves(fr)) { + for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) { + if (value.includes(fragment)) { + findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`); + } + } + } + } + expect(findings, findings.join("\n")).toEqual([]); +}); + // --- Hardcoded JSX text (section 11D) --- // A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a // `{...}` expression) containing two or more real words are almost always user-facing diff --git a/frontend/index.html b/frontend/index.html index 307c14e..373547b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + Fleet Ops diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..05c07c3 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/api/apiError.ts b/frontend/src/api/apiError.ts new file mode 100644 index 0000000..3f30deb --- /dev/null +++ b/frontend/src/api/apiError.ts @@ -0,0 +1,12 @@ +export class ApiError extends Error { + status: number; + code: string; + correlationId: string; + + constructor(status: number, code: string, message: string, correlationId: string) { + super(message); + this.status = status; + this.code = code; + this.correlationId = correlationId; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 21176b2..4543a34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,7 @@ +import { ApiError } from "./apiError"; + +export { ApiError } from "./apiError"; + const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; type UnauthorizedListener = () => void; @@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void { return () => unauthorizedListeners.delete(listener); } -export class ApiError extends Error { - status: number; - code: string; - correlationId: string; - - constructor(status: number, code: string, message: string, correlationId: string) { - super(message); - this.status = status; - this.code = code; - this.correlationId = correlationId; - } -} - async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...init, diff --git a/frontend/src/api/errorMessages.ts b/frontend/src/api/errorMessages.ts new file mode 100644 index 0000000..0cf2fe3 --- /dev/null +++ b/frontend/src/api/errorMessages.ts @@ -0,0 +1,100 @@ +import { ApiError } from "./apiError"; + +export interface ApiErrorInfo { + title: string; + explanation: string; + nextStep?: string; + technical: string; +} + +type TFn = (key: string, options?: Record) => string; + +// Backend AppError codes this frontend knows how to present with a localized title, +// explanation and (where useful) a next step -- see +// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site. +// Anything not in this list still gets a sensible HTTP-status-based fallback below, so +// a newly-introduced backend code never regresses to raw English -- it just falls back +// to a generic-but-localized message until this list is extended. +export const KNOWN_CODES = new Set([ + "VEHICLE_NOT_FOUND", + "BOOKING_NOT_FOUND", + "CUSTOMER_NOT_FOUND", + "ENTITY_NOT_FOUND", + "EVENT_NOT_FOUND", + "ISSUE_NOT_FOUND", + "ISSUE_NOT_OPEN", + "BOOKING_NOT_ACTIVE", + "INVALID_BOOKING_STATE", + "NOT_RETRYABLE", + "CONFLICT_STILL_PRESENT", + "OVERLAP_STILL_PRESENT", + "EMPTY_VALUE", + "NO_FIELDS_PROVIDED", + "INVALID_FIELD", + "INVALID_FIELD_OVERRIDE", + "INVALID_SURVIVOR", + "INVALID_BOOKING_REFERENCE", + "INVALID_EVENT_ID", + "INVALID_IDEMPOTENCY_KEY", + "IDEMPOTENCY_KEY_REUSED", + "CORRECTED_VALUE_REQUIRED", + "CORRECTION_BELOW_CANONICAL", + "NOT_A_DUPLICATE_ISSUE", + "NOT_A_MISSING_FIELD_ISSUE", + "NOT_AN_ODOMETER_ISSUE", + "NOT_AN_OVERLAP_ISSUE", + "NOT_A_STATUS_CONFLICT_ISSUE", + "UNSUPPORTED_ENTITY", + "MANUAL_REVIEW_REQUIRED", + "NO_CONFLICT_DETECTED", + "RECOMMENDATION_STALE", + "UNAUTHORIZED_SERVICE", +]); + +const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]); + +/** + * Turns a caught error into a localized {title, explanation, nextStep?, technical} + * for display. The raw backend/network text is only ever exposed as `technical` + * (shown under "Technical details" by ApiErrorNotice) -- never as the primary message. + * + * `fallbackKey` is an existing, already-localized `t()` key used as the explanation + * when the error isn't an ApiError at all (e.g. the fetch failed before a response + * existed) and errors:generic doesn't fit the specific action being attempted. + */ +export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo { + if (!(err instanceof ApiError)) { + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err instanceof Error ? err.message : String(err), + }; + } + + if (KNOWN_CODES.has(err.code)) { + const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:codes.${err.code}.title`), + explanation: t(`errors:codes.${err.code}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status); + if (KNOWN_HTTP_STATUSES.has(httpKey)) { + const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:http.${httpKey}.title`), + explanation: t(`errors:http.${httpKey}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err.message, + }; +} diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx index 8be19c1..5b671a9 100644 --- a/frontend/src/components/DemoGuide.tsx +++ b/frontend/src/components/DemoGuide.tsx @@ -1,13 +1,15 @@ import { useNavigate, useLocation } from "react-router-dom"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { api, ApiError } from "../api/client"; +import { api } from "../api/client"; +import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import { useAuth } from "../context/AuthContext"; import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoManifest } from "../context/DemoManifestContext"; import { useViewportTier } from "../hooks/useViewportTier"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "./Icons"; +import { ApiErrorNotice } from "./PageChrome"; import { PRODUCT_NAME } from "../product"; export function DemoGuideTrigger() { @@ -68,7 +70,7 @@ export function DemoGuide() { setCollapsedToChip, } = useDemoGuide(); const [resetting, setResetting] = useState(false); - const [resetError, setResetError] = useState(null); + const [resetError, setResetError] = useState(null); const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); const pendingTarget = useRef(null); @@ -119,7 +121,7 @@ export function DemoGuide() { await logout(); navigate("/login"); } catch (err) { - setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed")); + setResetError(describeApiError(t, err, "guide.restartFailed")); } finally { setResetting(false); } @@ -227,7 +229,7 @@ export function DemoGuide() { )} - {resetError &&

{resetError}

} +