From 7c94eb9e87ac5cb7bf10a6abaa578b1ce52e1293 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:54:27 +0200 Subject: [PATCH 01/21] docs(demo): audit current demo readiness gaps Confirms the underlying data/business-logic is already demo-grade (Dutch/ Flemish names, .test emails, believable Belgian towns and RV brands; the 5 requested scenarios already exist as S1/S2/S4/S5/S6 in docs/13-seed-and-demo-scenarios.md) -- the real gaps are structural: no guided path, no visible fictional org identity (Northstar Mobility is already the locked tenant name internally, just never shown), a reproducible date-anchoring bug (seed dates are absolute and don't move with reset -- BK-DEMO-RETURN's end date is already in the past as of today), the knowledge page naming "RAGcore" directly instead of "demo mode", technical-register integration-status labels, and no About page. --- docs/demo-release/current-demo-gap-audit.md | 157 ++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/demo-release/current-demo-gap-audit.md diff --git a/docs/demo-release/current-demo-gap-audit.md b/docs/demo-release/current-demo-gap-audit.md new file mode 100644 index 0000000..a95e180 --- /dev/null +++ b/docs/demo-release/current-demo-gap-audit.md @@ -0,0 +1,157 @@ +# Current demo-readiness gap audit + +Performed 2026-08-03 against `feat/mobilityops-functional-completion` @ +`e0c7ed60112510687627d20a957af91c8b9db7f8` (verified: correct branch, working tree +clean, matches the deployed Unraid revision). Method: read `CLAUDE.md`/`AGENTS.md` +(identical, no demo-specific guidance yet), `README.md`, `PROJECT_STATE.md`, the docs +list in the brief, `artifacts/final-acceptance/summary.md`, +`artifacts/functional-completion/final-summary.md`, `seed/generate_seed.py`, +`seed/*.csv`, `backend/app/seed_loader.py`, `backend/app/core/config.py`, +`backend/app/api/routers/dashboard.py`; live-browsed the deployed app at +`http://192.168.10.150:1236` (login, dashboard, a data-quality issue, a booking detail, +knowledge, automation). + +## What already works well (do not rebuild) + +- The premium Control Rail UI, role-based nav, server-backed sessions, return preview/ + commit, all five data-quality resolution flows, audit before/after, role-aware search, + truthful integration status, demo reset, stale-lease recovery and the two n8n + workflows are all implemented, tested and live-verified per + `artifacts/functional-completion/final-summary.md` — this pass does not touch that + business logic. +- `docs/13-seed-and-demo-scenarios.md` already names exactly the scenarios this brief + wants surfaced: **S1** odometer-regression return (`BK-DEMO-RETURN`/`MO-024`), **S2** + duplicate customer (`CUS-0012`/`CUS-0178`, issue `DQ-DEMO-DUPLICATE`), **S4** booking + overlap (`MO-016`, issue `DQ-DEMO-OVERLAP`, bookings `BK-DEMO-OVERLAP-A/B`), **S5** + failed workflow (seeded failed outbox event, retryable from Automation), **S6** + grounded knowledge question. These map directly to the brief's 5 requested scenarios + — no new scenarios need to be invented, only surfaced with guidance. (S3, "missing + inspection before next booking" on `MO-031`, exists too but isn't one of the 5 + requested; leave it as-is, it still shows up in the attention queue.) +- `seed/generate_seed.py` already produces believable Flemish/Dutch data: first/last + name pools (Sofie, Lotte, Bram, Peeters, Janssens, Vermeulen, ...), real Kempen-region + towns (Geel, Turnhout, Herentals, Mol, Westerlo, Tessenderlo-Ham), `.test` email + addresses, and believable RV/camper makes (Adria, Dethleffs, Hymer, Bürstner, ...). + Confirmed live: `DQ-DEMO-DUPLICATE` shows customer "Bram Peeters" (Geel) with email + `bram.peeters.12@example.test`. **The underlying data quality is already + demo-grade — the gap is structural/experiential, not data flavour.** + +## Confirmed gaps + +### 1. No fictional organisation identity anywhere +Login/demo-entry screen says "MobilityOps · Control Centre" and generic marketing copy +("Every hand-off. One clear view.") but never names a demonstration organisation. A +stranger cannot tell *whose* fleet this is. **Confirmed via full-repo search:** +"Northstar Mobility" is never rendered in the UI, but it is **already the project's +locked fictitious tenant name** — `PROJECT_STATE.md`'s "Locked decisions" section +states verbatim *"Fictitious tenant: Northstar Mobility Demo"*, and +`ragcore_tenant` defaults to `"northstar-mobility-demo"` in `backend/app/core/config.py`, +`compose.yaml`, `.env.example`, `knowledge/manifest.json` and `contracts/mcp-tools.json`. +So using "Northstar Mobility" as the visible demo brand (as this brief suggests) is not +a new invention — it surfaces a decision the project already made and only ever used +internally as a RAGcore-tenant slug. No renaming/config changes needed, just make it +visible. + +### 2. No guided path — a visitor has no idea where to start +The dashboard shows real attention-queue items (duplicate customer, booking overlap, +vehicle status conflict, missing field) and today's movements (`BK-DEMO-RETURN` +11:00), but nothing tells a first-time visitor *which* of these is worth clicking, in +what order, or why. There is no "start guided demo" affordance, no scenario overview, +no step-by-step walkthrough. Everything technically works; nothing narrates. + +### 3. Live date anchoring is broken — confirmed, reproducible +`seed/bookings.csv` stores **absolute fixed ISO timestamps** authored around anchor +date 2026-08-01 (`generate_seed.py --anchor 2026-08-01`). `backend/app/core/config.py`'s +`demo_today` setting (`"2026-08-01"`) is used *only* by +`dashboard.py::_today()` to filter "today's movements" against that frozen date — nothing +re-anchors the underlying booking/inspection/maintenance/outbox dates themselves. +**Live-confirmed right now** (today is 2026-08-03): `BK-DEMO-RETURN` shows status +`active` with `ends_at = 01/08/2026 09:00` — already two days in the past, for a +booking that's supposed to look "in progress, due for return soon." `BK-DEMO-NEXT` +(meant to read as "near-future") starts 2026-08-02, also already past. Every day this +environment isn't reset, this gets worse, and after any `seed --reset` it snaps right +back to the same frozen 2026-08-01-relative positions regardless of the real reset +moment. This is exactly the failure mode brief section 7 describes and must be fixed at +the source (`seed_loader.py`), not papered over in the UI. + +### 4. Knowledge assistant names "RAGcore" directly, not honestly "demo mode" +`/knowledge` page copy: "Answers are shown only when **RAGcore** returns sufficient +cited evidence" and a "RAGcore" label on the sources block — while the actual active +provider is `KNOWLEDGE_PROVIDER=demo` (confirmed: badge below correctly shows +`demo · Available · 10 procedures indexed`, which contradicts the body copy one line +below it). This is the exact misleading-integration-status problem brief section 11 +warns against — the small badge is honest, the prose isn't. No suggested questions are +offered. + +### 5. Integration status labels are technical, not demo-plain-language +Automation page shows raw states like `not_configured`/`degraded`/`operational` (English, +developer register) rather than the plain-language labels section 12 asks for +(Operationeel / Demomodus / Niet gekoppeld / Voorbereid / Verwerking mislukt / Opnieuw +proberen mogelijk). + +### 6. Data-quality resolution panels are functionally complete but not narrated +Confirmed live on `DQ-DEMO-DUPLICATE`: the compare/merge UI works well, but nothing +explains *why this matters operationally* (duplicate billing/contact risk) or what +happens next in plain terms before the user acts. Same pattern across the other 4 +rule-type panels (already reviewed in this session's earlier batch). No "demo scenario" +filter exists to surface the curated issues first. + +### 7. No "About this demo" page +No page anywhere explains what's real, what's synthetic, what's live vs. demo-mode vs. +not-connected, or the architecture/testing approach in plain terms. A visitor exploring +alone has no way to self-answer "is this real?" + +### 8. No permanent, explained "synthetic demo" indicator +The existing `demo-banner` ("Synthetic demo data · no real customer or vehicle +information") is a full-width static bar repeated on every page — present, but static +text only, no link to more explanation, no reset-timestamp/anchor info, and (per section +4) shouldn't be a dominant warning bar; it should be a subtle, explained badge. + +### 9. Demo reset exists but is not discoverable as a demo feature +`POST /api/v1/demo/reset` and its sidebar UI trigger (this session's earlier batch) +work correctly and are Operations-Manager-gated, but reset isn't connected to any demo +narrative (no "prepare demo again" framing, not reachable from an About page or guide, +no post-reset scenario-integrity confirmation beyond the raw counts). + +### 10. No demo manifest / no single source of "what's on right now" +Nothing exposes demo mode, scenario list, integration status and guidance availability +as one small structured payload the frontend can render consistently (badge tooltip, +guide, scenario overview, About page all currently would have to duplicate this +knowledge if built independently). + +### 11. Everything is in English; the brief asks for consistent Dutch demo text +All existing UI copy is English. The brief explicitly asks for consistent Dutch product +text for the demo-facing additions. Recorded decision (no existing doc answers this, +and translating the entire already-shipped, already-tested Control Rail UI is out of +proportion to "add guidance," would touch dozens of files and contradicts "this is not +a redesign"): **new demo-productization surfaces are written in Dutch** (demo entry +copy, Demo Guide, scenario overview, About-this-demo page, the demo badge/tooltip, +plain-language integration-status labels). **Existing operational screens (Dashboard, +Vehicles, Bookings, Data Quality workbench, Audit, Automation internals) stay in +English** — they are already shipped, tested, and section 16 explicitly forbids +rebuilding working screens without functional reason. This mirrors how a real bilingual +guided demo works: narration in one language, software in whatever it already ships in. + +## Scope reminder (not gaps — explicitly out of scope per the brief) + +- No RAGcore or MCP Hub live implementation. +- No new business logic in the 5 resolution flows — only explanatory framing around + the existing ones. +- No full redesign, new component library, new color palette, or full retranslation. + +## Plan (implementation batches, matches task list) + +1. Fix date anchoring in `seed_loader.py` (shift all seeded dates by `today − anchor` + at every reset); switch `dashboard.py::_today()` to real wall-clock date; add a + `demo_data_seeded` audit marker recording the anchor/reset moment. +2. `GET /api/v1/demo/manifest` (org name, demo mode, scenarios, integration status, + reset timestamp) + Dutch demo entry screen + permanent demo badge with popover. +3. Demo Guide (collapsible panel/bottom sheet, 8 steps) + scenario overview (5 cards) + on the dashboard. +4. Layer plain-language Dutch explanation onto the return flow, the 5 DQ panels and the + knowledge assistant (fix the RAGcore-naming bug), without touching their logic. +5. Plain-language integration status labels, richer audit narration for guide-linked + events, "Over deze demo" page, reset wired into guide/About/OM menu with a + post-reset scenario-integrity check. +6. Full guided-demo Playwright test + targeted tests per section 19. +7. Clean-checkout demo drill, docs, Unraid deploy, final evidence with screenshots. From 8989ffb23c4215a752d027c562c58975c5f4afa9 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:08:02 +0200 Subject: [PATCH 02/21] fix(demo): anchor seeded dates to the real reset moment Booking/inspection/maintenance/outbox dates were authored as absolute timestamps around a fixed 2026-08-01 anchor and never re-anchored at seed/reset time, so demo scenarios (e.g. BK-DEMO-RETURN) silently drifted into the past. Every reset now shifts seeded dates by (today - authored anchor); dashboard's "today" filter uses real wall-clock time instead of the now-removed frozen demo_today setting. Adds seed-validation tests proving scenarios S1/S2/S4/S5 are present and internally consistent after every reset. --- .env.example | 1 - PROJECT_STATE.md | 35 ++++++++ backend/app/api/routers/dashboard.py | 6 +- backend/app/api/routers/demo.py | 12 ++- backend/app/core/config.py | 1 - backend/app/seed_loader.py | 44 ++++++++-- backend/tests/test_seed.py | 119 ++++++++++++++++++++++++++- compose.yaml | 1 - docs/13-seed-and-demo-scenarios.md | 14 ++++ 9 files changed, 218 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 7203756..9c0a478 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,6 @@ POSTGRES_DB=mobilityops POSTGRES_USER=mobilityops POSTGRES_PASSWORD=mobilityops APP_SECRET=replace-in-production -DEMO_TODAY=2026-08-01 TZ=Europe/Brussels # Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # current Unraid review environment); set true only once MobilityOps is served over HTTPS, diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 341b408..68fcb7f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -507,3 +507,38 @@ any change at `docs/functional-completion/server-baseline.md`. re-verified against the live server after every batch. See `artifacts/functional-completion/final-summary.md` for the definitive acceptance evidence. + +## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`) + +Follows the functional-completion work above; turns the now feature-complete PoC into a +guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named +scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`. + +### Batch 1 — seed date anchoring (complete) + +- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored + around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset + time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every + day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering + "today's movements" against the same frozen `demo_today` setting instead of real time. +- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per + `load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox + datetime column, so scenarios stay "today"/"near-future" relative to the actual reset + moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset` + returns them; a `demo_data_seeded` audit event records the anchor for traceability. + `dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock + UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`, + `compose.yaml`, `.env`, `.env.example` (nothing else referenced it). +- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/ + `MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/ + `BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event + `00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a + fresh reset, not silently auto-healed by the background dispatcher since it only claims + `pending` rows) are fully present after every reset, plus a dedicated anchoring test + asserting the shift and the audit marker. +- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends + today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03). +- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .` + clean, `mypy app` clean (46 files, canonical `make` scope). +- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent + demo badge (task #30), then the Demo Guide + scenario overview (task #31). diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index 7042107..6253019 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import UTC, date, datetime from typing import Literal from fastapi import APIRouter, Depends @@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} def _today() -> date: - return datetime.fromisoformat(settings.demo_today).date() + # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor + # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. + return datetime.now(UTC).date() @router.get("", response_model=DashboardOut) diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 85c3b6f..0ba66f7 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -99,8 +99,16 @@ def demo_reset( actor_label=user.display_name, action="demo_reset", entity_type="system", - metadata={"counts": result.counts}, + metadata={ + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + }, ) db.commit() response.delete_cookie(settings.session_cookie_name) - return {"status": "reset", "counts": result.counts} + return { + "status": "reset", + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "seeded_at": result.seeded_at.isoformat(), + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 14470d6..8f7a49d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,7 +32,6 @@ class Settings(BaseSettings): mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False cors_allow_origins: str = "http://localhost:1228" - demo_today: str = "2026-08-01" @lru_cache diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py index 041cc05..938d219 100644 --- a/backend/app/seed_loader.py +++ b/backend/app/seed_loader.py @@ -3,7 +3,7 @@ from __future__ import annotations import csv import uuid from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta from pathlib import Path from sqlalchemy import delete, insert, update @@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle +from app.services.audit import record_audit_event settings = get_settings() @@ -36,6 +37,17 @@ DEMO_USERS = [ }, ] +# seed/generate_seed.py authored the committed CSVs relative to this fixed date +# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every +# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps +# right now" scenarios stay true to the actual reset moment instead of decaying as real +# time passes between resets -- a fixed anchor with no shift goes stale within days. +SEED_AUTHORED_ANCHOR = date(2026, 8, 1) + + +def _seed_anchor_shift(today: date) -> timedelta: + return today - SEED_AUTHORED_ANCHOR + def _parse_dt(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None: @dataclass class SeedResult: counts: dict[str, int] + anchor_date: date + seeded_at: datetime def _seed_dir() -> Path: @@ -83,6 +97,8 @@ def clear_all(db: Session) -> None: def load_seed(db: Session) -> SeedResult: counts: dict[str, int] = {} + today = datetime.now(UTC).date() + shift = _seed_anchor_shift(today) user_rows = [ {"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS @@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult: "public_ref": row["public_ref"], "customer_id": customer_id_by_ref[row["customer_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "starts_at": _parse_dt(row["starts_at"]), - "ends_at": _parse_dt(row["ends_at"]), + "starts_at": _parse_dt(row["starts_at"]) + shift, + "ends_at": _parse_dt(row["ends_at"]) + shift, "status": row["status"], "start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]), @@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult: "damage_reported": _parse_bool(row["damage_reported"]), "technical_warning": _parse_bool(row["technical_warning"]), "odometer_km": int(row["odometer_km"]), - "completed_at": _parse_dt(row["completed_at"]), + "completed_at": _parse_dt(row["completed_at"]) + shift, "completed_by": None, } ) @@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult: "id": uuid.uuid4(), "public_ref": row["public_ref"], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "odometer_km": int(row["odometer_km"]), "category": row["category"], "summary": row["summary"], @@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult: }, "aggregate_ref": row["aggregate_ref"], }, - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "delivery_status": row["status"], "attempts": int(row["attempts"]), "next_attempt_at": None, @@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult: db.execute(insert(OutboxEvent), outbox_rows) counts["workflow_runs"] = len(outbox_rows) - return SeedResult(counts=counts) + seeded_at = datetime.now(UTC) + record_audit_event( + db, + actor_type="system", + actor_label="seed loader", + action="demo_data_seeded", + entity_type="system", + metadata={ + "anchor_date": today.isoformat(), + "seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(), + "counts": counts, + }, + ) + + return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at) def reset_and_seed(db: Session) -> SeedResult: diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 84ea3bd..076cd1a 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -1,13 +1,16 @@ +from datetime import UTC, datetime + from sqlalchemy import func, select from app.core.db import SessionLocal +from app.models.audit import AuditEvent from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle -from app.seed_loader import reset_and_seed +from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed def test_seed_counts_match_deterministic_dataset(): @@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present(): assert failed_run is not None finally: db.close() + + +def _by_ref(db, model, ref): + return db.scalar(select(model).where(model.public_ref == ref)) + + +def test_seed_scenario_s1_odometer_regression_return(): + """S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a + below-canonical odometer reading, using the vehicle's own current odometer.""" + db = SessionLocal() + try: + reset_and_seed(db) + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + vehicle = _by_ref(db, Vehicle, "MO-024") + assert booking is not None and vehicle is not None + assert booking.vehicle_id == vehicle.id + assert booking.status == "active" + assert booking.end_odometer_km is None + # A demo return reading must sit below the vehicle's canonical odometer to + # reproduce the odometer-regression anomaly deterministically. + assert vehicle.odometer_km > 0 + finally: + db.close() + + +def test_seed_scenario_s2_duplicate_customer_pair(): + """S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + primary = _by_ref(db, Customer, "CUS-0012") + duplicate = _by_ref(db, Customer, "CUS-0178") + assert primary is not None and duplicate is not None + assert primary.email == duplicate.email + assert duplicate.merged_into_customer_id is None + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE") + assert issue is not None + assert issue.rule_type == "possible_duplicate_customer" + assert issue.status == "open" + related = issue.evidence_json.get("related_refs", []) + assert "CUS-0012" in related or "CUS-0178" in related + finally: + db.close() + + +def test_seed_scenario_s4_booking_overlap(): + """S4: MO-016 carries two overlapping reservations plus a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + vehicle = _by_ref(db, Vehicle, "MO-016") + booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A") + booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B") + assert vehicle is not None and booking_a is not None and booking_b is not None + assert booking_a.vehicle_id == vehicle.id + assert booking_b.vehicle_id == vehicle.id + assert booking_a.starts_at < booking_b.ends_at + assert booking_b.starts_at < booking_a.ends_at + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP") + assert issue is not None + assert issue.rule_type == "booking_overlap" + assert issue.status == "open" + finally: + db.close() + + +def test_seed_scenario_s5_failed_workflow_run(): + """S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely + pending, so the background dispatcher never silently auto-heals it away.""" + db = SessionLocal() + try: + reset_and_seed(db) + failed = db.scalar( + select(OutboxEvent).where( + OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020" + ) + ) + assert failed is not None + assert failed.delivery_status == "failed" + assert failed.attempts >= 1 + assert failed.last_error + finally: + db.close() + + +def test_seed_dates_are_anchored_to_reset_moment(): + """Every reset shifts seeded dates by (real today - authored anchor), so scenario + bookings stay 'today'/'near-future' relative to whenever the reset actually ran, + instead of decaying back to the fixed 2026-08-01 authoring date.""" + db = SessionLocal() + try: + result = reset_and_seed(db) + today = datetime.now(UTC).date() + assert result.anchor_date == today + + shift = today - SEED_AUTHORED_ANCHOR + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + assert booking is not None + # Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the + # real reset date, not the frozen authoring date (unless shift is exactly zero). + assert booking.ends_at.date() == today or shift.days == 0 + + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + assert marker is not None + assert marker.metadata_json["anchor_date"] == today.isoformat() + assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() + finally: + db.close() diff --git a/compose.yaml b/compose.yaml index 3306969..a5f7759 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,6 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} TZ: ${TZ:-Europe/Brussels} APP_SECRET: ${APP_SECRET:-replace-in-production} - DEMO_TODAY: ${DEMO_TODAY:-2026-08-01} CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} diff --git a/docs/13-seed-and-demo-scenarios.md b/docs/13-seed-and-demo-scenarios.md index 0b53005..862ef8c 100644 --- a/docs/13-seed-and-demo-scenarios.md +++ b/docs/13-seed-and-demo-scenarios.md @@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures. +## Date anchoring + +The committed CSVs store absolute ISO timestamps authored around a fixed anchor date +(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the +`--anchor` used to generate them). Every seed/reset shifts every seeded booking, +inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so +"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the +environment was (re)seeded instead of decaying as real time passes between resets. Public +refs and entity relationships are untouched by the shift — only datetime columns move. +`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a +`demo_data_seeded` audit event carrying both the resolved anchor and the original +authoring anchor, so the shift applied on any given reset stays traceable. + ## Demo reset Reset must: @@ -49,6 +62,7 @@ Reset must: - require Operations Manager; - rebuild the deterministic dataset; - re-establish scenario references; +- re-anchor scenario dates to the real reset moment (see above); - clear non-seed audit/workflow state; - complete safely and visibly; - be covered by a test. From 728e380d630edbbadfd32b8600ffad35c2e68513 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:10:33 +0200 Subject: [PATCH 03/21] docs: record Unraid deployment evidence for the date-anchoring fix --- PROJECT_STATE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 68fcb7f..5f512e3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -540,5 +540,11 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03). - Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .` clean, `mypy app` clean (46 files, canonical `make` scope). +- Deployed to Unraid (commit `8989ffb`): pushed to Gitea, `git archive` tarball extracted + over `/mnt/user/appdata/mobilityops` preserving `.env`/volumes, `api` rebuilt (`db`/`web` + untouched — no frontend changes this batch), migrations confirmed at `e7b08389f47f + (head)`, reseeded, live-verified via `psql` that `BK-DEMO-RETURN`/`BK-DEMO-NEXT`/overlap + bookings sit at the same real-time-relative positions as local. `curl` to + `http://192.168.10.150:1236/` returns 200. - Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent demo badge (task #30), then the Demo Guide + scenario overview (task #31). From ac427f44270aa5458e04a951b67b453111e7ca07 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:45:55 +0200 Subject: [PATCH 04/21] feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page Adds GET /api/v1/demo/manifest as a single source of truth for the demo's fictional org identity (Northstar Mobility -- surfacing the project's already-locked tenant name), synthetic-data/reset state, and live scenario readiness. Rewrites the login screen in Dutch with an honest, no-password demo entry and a guided-demo entry point, replaces the loud full-width demo banner with a subtle badge + popover, and adds a compact About page explaining what's real vs. synthetic vs. not yet connected. --- .env.example | 7 + PROJECT_STATE.md | 37 +++ backend/app/api/routers/demo.py | 18 +- backend/app/api/routers/integration_status.py | 51 +--- backend/app/core/config.py | 3 + backend/app/schemas.py | 34 +++ backend/app/services/demo_manifest.py | 241 ++++++++++++++++++ backend/app/services/integration_status.py | 55 ++++ backend/tests/test_auth.py | 15 ++ backend/tests/test_demo_manifest.py | 55 ++++ compose.yaml | 3 + docs/05-api-contract.md | 9 +- frontend/e2e/_capture-screenshots.spec.ts | 2 +- frontend/e2e/demo-entry.spec.ts | 56 ++++ frontend/e2e/demo.spec.ts | 6 +- frontend/e2e/interactive-elements.spec.ts | 8 +- frontend/e2e/ui-redesign.spec.ts | 2 +- frontend/src/App.tsx | 55 ++-- frontend/src/api/types.ts | 34 +++ frontend/src/components/DemoBadge.tsx | 81 ++++++ frontend/src/components/Layout.tsx | 7 +- frontend/src/context/DemoManifestContext.tsx | 52 ++++ frontend/src/pages/AboutDemo.tsx | 124 +++++++++ frontend/src/pages/Login.tsx | 52 ++-- frontend/src/styles.css | 20 +- 25 files changed, 919 insertions(+), 108 deletions(-) create mode 100644 backend/app/services/demo_manifest.py create mode 100644 backend/app/services/integration_status.py create mode 100644 backend/tests/test_demo_manifest.py create mode 100644 frontend/e2e/demo-entry.spec.ts create mode 100644 frontend/src/components/DemoBadge.tsx create mode 100644 frontend/src/context/DemoManifestContext.tsx create mode 100644 frontend/src/pages/AboutDemo.tsx diff --git a/.env.example b/.env.example index 9c0a478..bb2d926 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ TZ=Europe/Brussels # otherwise browsers will silently drop the cookie and no one can log in. SESSION_COOKIE_SECURE=false +# Demo presentation (fictional org identity, badge/manifest, reset safety valve). +# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent +# of role -- a safety valve for any environment where the dataset must not be rebuildable. +DEMO_ORGANIZATION_NAME=Northstar Mobility +DEMO_TIMEZONE=Europe/Brussels +DEMO_ALLOW_RESET=true + # n8n N8N_BASE_URL=http://n8n:5678 N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 5f512e3..aa9e1a0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -548,3 +548,40 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem `http://192.168.10.150:1236/` returns 200. - Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent demo badge (task #30), then the Demo Guide + scenario overview (task #31). + +### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete) + +- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org + identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide + availability, and the 5 named scenarios with **live** readiness (queries the actual + `BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge- + provider records — not hardcoded), plus plain-language integration summaries. Backed by + new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of + `integration_status.py` into a shared `services/integration_status.py` so the manifest + and the existing authenticated `/integrations/status` endpoint reuse one implementation. +- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`): + `DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already- + locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug), + `DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST + /api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting + removed in Batch 1 stays removed). +- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced + from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary + CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to + consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary + actions. Added a permanent demo badge (topbar pill + popover: synthetic notice, + "workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the + old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning + bar. New `/about` + page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's + synthetic, honest per-integration labels (via the manifest), and a reset pointer — + reachable from the badge popover, not added to primary nav (preserves the existing Control + Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed** + (37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo + login redirect, badge popover content + About link, and Escape/outside-click close). + Updated stale English login-button aria-labels and login-copy assertions across the + existing specs to match the new Dutch copy. +- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards + on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31. diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 0ba66f7..97c5a78 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -3,7 +3,7 @@ from __future__ import annotations import time import uuid -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager from app.core.config import get_settings from app.core.security import SessionPayload, create_session_token, read_session_token from app.models.user import User -from app.schemas import CurrentUser, DemoLoginRequest +from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut from app.seed_loader import reset_and_seed from app.services.audit import record_audit_event +from app.services.demo_manifest import build_demo_manifest router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) settings = get_settings() +@router.get("/manifest", response_model=DemoManifestOut) +def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: + # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge + # both need this before any session exists. Nothing here is sensitive — it's the same + # honest "what is this demo" summary a logged-in user would see. + return build_demo_manifest(db) + + @router.post("/login", response_model=CurrentUser) def demo_login( body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) @@ -92,6 +101,11 @@ def demo_reset( db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> dict: + if not settings.demo_allow_reset: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Demo reset is disabled on this deployment.", + ) result = reset_and_seed(db) record_audit_event( db, diff --git a/backend/app/api/routers/integration_status.py b/backend/app/api/routers/integration_status.py index b337709..c1f08f8 100644 --- a/backend/app/api/routers/integration_status.py +++ b/backend/app/api/routers/integration_status.py @@ -1,75 +1,28 @@ from __future__ import annotations -from typing import Literal - from fastapi import APIRouter, Depends -from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_db, require_operations_manager from app.core.config import get_settings -from app.models.outbox import OutboxEvent from app.schemas import ( CurrentUser, IntegrationStatusOut, McpHubIntegrationStatus, - N8nIntegrationStatus, ) +from app.services.integration_status import derive_n8n_status router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) settings = get_settings() -def _n8n_status(db: Session) -> N8nIntegrationStatus: - counts: dict[str, int] = dict( - db.execute( - select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) - ).all() # type: ignore[arg-type] - ) - pending = counts.get("pending", 0) - delivering = counts.get("delivering", 0) - failed = counts.get("failed", 0) - succeeded = counts.get("succeeded", 0) - - latest_success_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") - ) - latest_failure_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") - ) - - state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] - if not settings.n8n_dispatch_enabled: - state = "disabled" - elif failed > 0 and succeeded == 0: - state = "unavailable" - elif failed > 0: - state = "degraded" - elif succeeded > 0 or pending > 0 or delivering > 0: - state = "operational" - else: - state = "no_evidence" - - return N8nIntegrationStatus( - configured=bool(settings.n8n_webhook_url), - dispatch_enabled=settings.n8n_dispatch_enabled, - state=state, - pending=pending, - delivering=delivering, - failed=failed, - succeeded=succeeded, - latest_success_at=latest_success_at, - latest_failure_at=latest_failure_at, - ) - - @router.get("/status", response_model=IntegrationStatusOut) def integration_status( db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), ) -> IntegrationStatusOut: return IntegrationStatusOut( - n8n=_n8n_status(db), + n8n=derive_n8n_status(db), mcp_hub=McpHubIntegrationStatus( registration_enabled=settings.mcp_hub_registration_enabled, state="configured" if settings.mcp_hub_registration_enabled else "not_configured", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8f7a49d..62cb07a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,6 +32,9 @@ class Settings(BaseSettings): mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False cors_allow_origins: str = "http://localhost:1228" + demo_organization_name: str = "Northstar Mobility" + demo_timezone: str = "Europe/Brussels" + demo_allow_reset: bool = True @lru_cache diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ad3836b..0bbe2a9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -197,6 +197,40 @@ class IntegrationStatusOut(BaseModel): mcp_hub: McpHubIntegrationStatus +class DemoScenarioOut(BaseModel): + id: str + title: str + operational_problem: str + estimated_minutes: int + required_roles: list[Role] + start_path: str + demonstrates: str + ready: bool + blocked_reason: str | None = None + + +class DemoIntegrationSummaryOut(BaseModel): + key: Literal["n8n", "ragcore", "mcp_hub"] + label: str + status_label: str + detail: str + + +class DemoManifestOut(BaseModel): + demo_mode: bool + organization_name: str + organization_description: str + timezone: str + synthetic_data: bool + allow_reset: bool + last_reset_at: datetime | None + anchor_date: str | None + guide_available: bool + required_roles: list[Role] + scenarios: list[DemoScenarioOut] + integrations: list[DemoIntegrationSummaryOut] + + class VehicleDetailOut(VehicleOut): bookings: list[BookingSummaryOut] = Field(default_factory=list) inspections: list[InspectionOut] = Field(default_factory=list) diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py new file mode 100644 index 0000000..f9b38b2 --- /dev/null +++ b/backend/app/services/demo_manifest.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut +from app.services.integration_status import derive_n8n_status +from app.services.knowledge import get_knowledge_provider + +settings = get_settings() + +# The default name matches the project's locked fictitious tenant (see PROJECT_STATE.md +# "Locked decisions"; the same slug already backs `ragcore_tenant`) — this surfaces that +# existing decision in the UI rather than inventing a new one. Configurable via +# DEMO_ORGANIZATION_NAME so a redeployment can rebrand the fictional org without a code change. +ORGANIZATION_DESCRIPTION = ( + "MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, " + "ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en " + "automatiseert gecontroleerde vervolgstappen." +) + +_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020" + +_N8N_STATE_LABELS = { + "disabled": "Niet gekoppeld", + "unavailable": "Verwerking mislukt", + "degraded": "Opnieuw proberen mogelijk", + "operational": "Operationeel", + "no_evidence": "Voorbereid", +} + + +def _last_reset(db: Session) -> tuple[datetime | None, str | None]: + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + if marker is None: + return None, None + metadata = marker.metadata_json or {} + return marker.occurred_at, metadata.get("anchor_date") + + +def _scenarios(db: Session) -> list[DemoScenarioOut]: + booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) + duplicate_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") + ) + overlap_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") + ) + failed_run = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID) + ) + knowledge_health = get_knowledge_provider().health() + reset_hint = "Reset de demo-data om dit scenario opnieuw beschikbaar te maken." + + return [ + DemoScenarioOut( + id="return-anomaly", + title="Retour met afwijkende kilometerstand", + operational_problem=( + "Een voertuig komt terug met een kilometerstand die lager ligt dan de " + "laatst geregistreerde stand — een teken van een foutieve invoer of een " + "verwisseld voertuig." + ), + estimated_minutes=3, + required_roles=["rental_employee", "operations_manager"], + start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings", + demonstrates=( + "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de " + "audit trail die daaruit ontstaat." + ), + ready=bool( + booking and booking.status == "active" and booking.end_odometer_km is None + ), + blocked_reason=( + None + if booking and booking.status == "active" and booking.end_odometer_km is None + else ( + f"Demoboeking BK-DEMO-RETURN niet gevonden. {reset_hint}" + if booking is None + else f"Deze boeking is al verwerkt sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="duplicate-customer", + title="Mogelijke dubbele klant samenvoegen", + operational_problem=( + "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — " + "waarschijnlijk dezelfde persoon, twee keer geregistreerd." + ), + estimated_minutes=3, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{duplicate_issue.public_ref}" + if duplicate_issue + else "/data-quality" + ), + demonstrates=( + "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail." + ), + ready=bool(duplicate_issue and duplicate_issue.status == "open"), + blocked_reason=( + None + if duplicate_issue and duplicate_issue.status == "open" + else ( + f"Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {reset_hint}" + if duplicate_issue is None + else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="booking-overlap", + title="Overlappende boekingen herstellen", + operational_problem=( + "Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een " + "planningsfout die vóór vertrek moet worden opgelost." + ), + estimated_minutes=2, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality" + ), + demonstrates="Detectie en gecontroleerde oplossing van planningsconflicten.", + ready=bool(overlap_issue and overlap_issue.status == "open"), + blocked_reason=( + None + if overlap_issue and overlap_issue.status == "open" + else ( + f"Demo-issue DQ-DEMO-OVERLAP niet gevonden. {reset_hint}" + if overlap_issue is None + else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="automation-retry", + title="Mislukte automatisering opnieuw proberen", + operational_problem=( + "Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering " + "door een gesimuleerde verbindingsfout." + ), + estimated_minutes=2, + required_roles=["operations_manager"], + start_path="/automation", + demonstrates=( + "Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus." + ), + ready=bool(failed_run and failed_run.delivery_status == "failed"), + blocked_reason=( + None + if failed_run and failed_run.delivery_status == "failed" + else ( + f"Gesimuleerde mislukte gebeurtenis niet gevonden. {reset_hint}" + if failed_run is None + else f"Deze gebeurtenis is al hersteld sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="knowledge-question", + title="Een procedurevraag stellen", + operational_problem=( + "Een medewerker weet niet zeker welke procedure van toepassing is bij een " + "specifieke operationele situatie." + ), + estimated_minutes=2, + required_roles=["rental_employee", "operations_manager"], + start_path="/knowledge", + demonstrates=( + "Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank." + ), + ready=knowledge_health.available, + blocked_reason=( + None + if knowledge_health.available + else "De demokennisbank is momenteel niet beschikbaar." + ), + ), + ] + + +def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: + n8n = derive_n8n_status(db) + knowledge_health = get_knowledge_provider().health() + + return [ + DemoIntegrationSummaryOut( + key="n8n", + label="Automatisering (n8n)", + status_label=_N8N_STATE_LABELS.get(n8n.state, n8n.state), + detail=f"{n8n.succeeded} geslaagd, {n8n.failed} mislukt, {n8n.pending} in wachtrij.", + ), + DemoIntegrationSummaryOut( + key="ragcore", + label="Kennisassistent (RAGcore)", + status_label=( + "Demomodus — lokale kennisprovider" + if knowledge_health.provider != "ragcore" + else "Operationeel" + ), + detail=knowledge_health.detail, + ), + DemoIntegrationSummaryOut( + key="mcp_hub", + label="ITWorx MCP Hub", + status_label=( + "Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld" + ), + detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.", + ), + ] + + +def build_demo_manifest(db: Session) -> DemoManifestOut: + last_reset_at, anchor_date = _last_reset(db) + return DemoManifestOut( + demo_mode=settings.mobilityops_demo_mode, + organization_name=settings.demo_organization_name, + organization_description=ORGANIZATION_DESCRIPTION, + timezone=settings.demo_timezone, + synthetic_data=True, + allow_reset=settings.demo_allow_reset, + last_reset_at=last_reset_at, + anchor_date=anchor_date, + guide_available=True, + required_roles=["operations_manager", "rental_employee"], + scenarios=_scenarios(db), + integrations=_integrations(db), + ) diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py new file mode 100644 index 0000000..d647272 --- /dev/null +++ b/backend/app/services/integration_status.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Literal + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.outbox import OutboxEvent +from app.schemas import N8nIntegrationStatus + +settings = get_settings() + + +def derive_n8n_status(db: Session) -> N8nIntegrationStatus: + counts: dict[str, int] = dict( + db.execute( + select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) + ).all() # type: ignore[arg-type] + ) + pending = counts.get("pending", 0) + delivering = counts.get("delivering", 0) + failed = counts.get("failed", 0) + succeeded = counts.get("succeeded", 0) + + latest_success_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") + ) + latest_failure_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") + ) + + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + if not settings.n8n_dispatch_enabled: + state = "disabled" + elif failed > 0 and succeeded == 0: + state = "unavailable" + elif failed > 0: + state = "degraded" + elif succeeded > 0 or pending > 0 or delivering > 0: + state = "operational" + else: + state = "no_evidence" + + return N8nIntegrationStatus( + configured=bool(settings.n8n_webhook_url), + dispatch_enabled=settings.n8n_dispatch_enabled, + state=state, + pending=pending, + delivering=delivering, + failed=failed, + succeeded=succeeded, + latest_success_at=latest_success_at, + latest_failure_at=latest_failure_at, + ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8bbeeb8..143757f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -18,6 +18,21 @@ def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 assert response.json()["counts"]["vehicles"] == 50 + assert response.json()["anchor_date"] + assert response.json()["seeded_at"] + + +def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): + import app.api.routers.demo as demo_router + + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False) + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 403 + + # Restore real demo data: this test intentionally disabled reset, so a following test + # module must not inherit a database left mid-mutation by an earlier test. + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True) + assert ops_client.post("/api/v1/demo/reset").status_code == 200 def test_session_endpoint_requires_authentication(client): diff --git a/backend/tests/test_demo_manifest.py b/backend/tests/test_demo_manifest.py new file mode 100644 index 0000000..b5877ac --- /dev/null +++ b/backend/tests/test_demo_manifest.py @@ -0,0 +1,55 @@ +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def test_demo_manifest_is_public(client): + # No login call at all -- the demo-entry screen and badge need this before any + # session exists. + response = client.get("/api/v1/demo/manifest") + assert response.status_code == 200 + + +def test_demo_manifest_shape(client): + body = client.get("/api/v1/demo/manifest").json() + assert body["organization_name"] == "Northstar Mobility" + assert body["demo_mode"] is True + assert body["synthetic_data"] is True + assert body["allow_reset"] is True + assert body["timezone"] == "Europe/Brussels" + assert body["guide_available"] is True + assert set(body["required_roles"]) == {"operations_manager", "rental_employee"} + assert body["last_reset_at"] is not None + assert body["anchor_date"] is not None + + scenario_ids = {s["id"] for s in body["scenarios"]} + assert scenario_ids == { + "return-anomaly", + "duplicate-customer", + "booking-overlap", + "automation-retry", + "knowledge-question", + } + integration_keys = {i["key"] for i in body["integrations"]} + assert integration_keys == {"n8n", "ragcore", "mcp_hub"} + + +def test_demo_manifest_scenarios_ready_after_fresh_reset(client): + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + + body = client.get("/api/v1/demo/manifest").json() + scenarios = {s["id"]: s for s in body["scenarios"]} + for scenario_id, scenario in scenarios.items(): + assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset" + assert scenario["blocked_reason"] is None + assert scenario["start_path"] + + +def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client): + body = client.get("/api/v1/demo/manifest").json() + ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore") + assert "Demomodus" in ragcore["status_label"] + assert "RAGcore" not in ragcore["status_label"] diff --git a/compose.yaml b/compose.yaml index a5f7759..5e0eadc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -34,6 +34,9 @@ services: N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token} + DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} + DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} + DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} ports: - "8128:8000" depends_on: diff --git a/docs/05-api-contract.md b/docs/05-api-contract.md index 7f2d194..7cc5216 100644 --- a/docs/05-api-contract.md +++ b/docs/05-api-contract.md @@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu ### System and demo - `GET /health` +- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data + flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with + live readiness derived from actual records, not hardcoded), and plain-language + integration summaries. Single source of truth for the demo-entry screen, the permanent + demo badge, the scenario overview and the About page — avoids duplicating this logic + per surface. - `POST /api/v1/demo/login` - `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store` - `POST /api/v1/demo/logout` — safe to call without a session -- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own session +- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own + session; returns 403 if `DEMO_ALLOW_RESET=false` ### Dashboard diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts index 8e62da6..10ffeb0 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: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).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-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts new file mode 100644 index 0000000..63a97df --- /dev/null +++ b/frontend/e2e/demo-entry.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("demo entry screen names the fictional org and never shows a password", async ({ page }) => { + await page.goto("/login"); + 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.locator('input[type="password"]')).toHaveCount(0); +}); + +test("start guided demo logs in as Operations Manager and marks the guide to start", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page).toHaveURL(/\/dashboard\?guide=start$/); + await expect(page.getByText("Amelie De Ridder")).toBeVisible(); +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + + const trigger = page.getByRole("button", { name: /Synthetische demo/ }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible(); + await expect(page.getByText(/Laatste reset:/)).toBeVisible(); + + await page.getByRole("link", { name: /Over deze demo/ }).click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await expect(page.getByText("Northstar Mobility").first()).toBeVisible(); + await expect(page.getByText("Demomodus — lokale kennisprovider")).toBeVisible(); + await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + + const trigger = page.getByRole("button", { name: /Synthetische demo/ }); + await trigger.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + await trigger.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.mouse.click(10, 10); + await expect(page.getByRole("dialog")).toBeHidden(); +}); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 9b926f7..5afb8f8 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -18,8 +18,8 @@ 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(/Synthetic proof of concept/)).toBeVisible(); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await expect(page.getByText(/Synthetische demo/)).toBeVisible(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -89,7 +89,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("9. verify responsive navigation at mobile width", async () => { await page.setViewportSize({ width: 360, height: 800 }); await page.goto("/dashboard"); - await expect(page.getByText(/Synthetic demo data/).first()).toBeVisible(); + await expect(page.getByText(/Synthetische demo/).first()).toBeVisible(); await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible(); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 143e870..05b0f8b 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -9,7 +9,7 @@ test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -340,7 +340,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa page, }) => { await page.getByRole("button", { name: "Switch role" }).click(); - await page.getByRole("button", { name: "Open as Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // Manager-only nav items are not shown at all, not merely disabled. @@ -374,7 +374,7 @@ test("operations manager can reset demo data and is returned to login", async ({ await expect(page).toHaveURL(/\/login$/); // The reset must not have affected the ability to log back in against fresh data. - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -392,7 +392,7 @@ test("rental employee direct API access to manager-only endpoints is rejected", page, }) => { await page.getByRole("button", { name: "Switch role" }).click(); - await page.getByRole("button", { name: "Open as Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // page.request shares the browser context's cookies, and (via the web container's diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index 00896f7..6d3fe9c 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -8,7 +8,7 @@ async function resetDemoData(request: APIRequestContext) { test.beforeEach(async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f5b257..7ce1051 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./context/AuthContext"; +import { DemoManifestProvider } from "./context/DemoManifestContext"; import { Layout } from "./components/Layout"; import { RequireAuth } from "./components/RequireAuth"; import { Login } from "./pages/Login"; @@ -13,33 +14,37 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail"; import { Automation } from "./pages/Automation"; import { Knowledge } from "./pages/Knowledge"; import { Audit } from "./pages/Audit"; +import { AboutDemo } from "./pages/AboutDemo"; export function App() { return ( - - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - - + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + ); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 715e29b..495150c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -252,6 +252,40 @@ export interface IntegrationStatus { mcp_hub: McpHubIntegrationStatus; } +export interface DemoScenario { + id: string; + title: string; + operational_problem: string; + estimated_minutes: number; + required_roles: Role[]; + start_path: string; + demonstrates: string; + ready: boolean; + blocked_reason: string | null; +} + +export interface DemoIntegrationSummary { + key: "n8n" | "ragcore" | "mcp_hub"; + label: string; + status_label: string; + detail: string; +} + +export interface DemoManifest { + demo_mode: boolean; + organization_name: string; + organization_description: string; + timezone: string; + synthetic_data: boolean; + allow_reset: boolean; + last_reset_at: string | null; + anchor_date: string | null; + guide_available: boolean; + required_roles: Role[]; + scenarios: DemoScenario[]; + integrations: DemoIntegrationSummary[]; +} + export interface AuditEvent { id: string; actor_type: string; diff --git a/frontend/src/components/DemoBadge.tsx b/frontend/src/components/DemoBadge.tsx new file mode 100644 index 0000000..5813234 --- /dev/null +++ b/frontend/src/components/DemoBadge.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "./Icons"; + +function formatDateTime(value: string | null): string { + if (!value) return "onbekend"; + return new Date(value).toLocaleString("nl-BE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Brussels", + }); +} + +export function DemoBadge() { + const { manifest } = useDemoManifest(); + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + useEffect(() => { + function handleOutsideClick(event: MouseEvent) { + if (boxRef.current && !boxRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("keydown", handleEscape); + }; + }, []); + + return ( +
+ + {open && ( +
+ +

+ {manifest ? ( + <> + {manifest.organization_name} is een fictieve organisatie. Alle + namen, voertuigen en boekingen zijn synthetisch. + + ) : ( + "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch." + )} +

+

+ De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de + gegevens zijn verzonnen. +

+ {manifest && ( +

+ Laatste reset: {formatDateTime(manifest.last_reset_at)} · deze + omgeving is op elk moment herstelbaar. +

+ )} + setOpen(false)}> + Over deze demo + +
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fd109eb..ffad620 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client"; import { useAuth } from "../context/AuthContext"; import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; +import { DemoBadge } from "./DemoBadge"; +import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { vehicle: "fleet", @@ -42,6 +44,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ export function Layout() { const { user, logout } = useAuth(); + const { manifest } = useDemoManifest(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -192,7 +195,7 @@ export function Layout() {
Demo environmentSynthetic data only
- {user?.role === "operations_manager" && ( + {user?.role === "operations_manager" && manifest?.allow_reset !== false && (
{resetError &&

{resetError}

} {!resetConfirming ? ( @@ -274,6 +277,7 @@ export function Layout() { )}
+ Europe/Brussels {user && (
@@ -287,7 +291,6 @@ export function Layout() {
-

Synthetic demo data · no real customer or vehicle information

MobilityOps PoCEurope/Brussels · Synthetic demo data
diff --git a/frontend/src/context/DemoManifestContext.tsx b/frontend/src/context/DemoManifestContext.tsx new file mode 100644 index 0000000..74b02a3 --- /dev/null +++ b/frontend/src/context/DemoManifestContext.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { api } from "../api/client"; +import type { DemoManifest } from "../api/types"; + +interface DemoManifestState { + manifest: DemoManifest | null; + loading: boolean; + refresh: () => void; +} + +const DemoManifestContext = createContext(undefined); + +export function DemoManifestProvider({ children }: { children: ReactNode }) { + const [manifest, setManifest] = useState(null); + const [loading, setLoading] = useState(true); + const [version, setVersion] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + // Public endpoint by design: the demo-entry screen needs this before any session + // exists, so it is never gated behind auth. + api + .get("/api/v1/demo/manifest") + .then((result) => { + if (!cancelled) setManifest(result); + }) + .catch(() => { + if (!cancelled) setManifest(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [version]); + + return ( + setVersion((v) => v + 1) }} + > + {children} + + ); +} + +export function useDemoManifest(): DemoManifestState { + const ctx = useContext(DemoManifestContext); + if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider"); + return ctx; +} diff --git a/frontend/src/pages/AboutDemo.tsx b/frontend/src/pages/AboutDemo.tsx new file mode 100644 index 0000000..5c2733e --- /dev/null +++ b/frontend/src/pages/AboutDemo.tsx @@ -0,0 +1,124 @@ +import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "../components/Icons"; +import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; + +function formatDateTime(value: string | null): string { + if (!value) return "onbekend"; + return new Date(value).toLocaleString("nl-BE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Brussels", + }); +} + +const INTEGRATION_ICON: Record = { + n8n: "n8n", + ragcore: "rag", + mcp_hub: "mcp", +}; + +export function AboutDemo() { + const { manifest, loading } = useDemoManifest(); + const { user } = useAuth(); + + return ( +
+ + + {loading && } + + {manifest && ( + <> +
+

Het fictieve probleem

+

+ {manifest.organization_name} verhuurt zo'n 50 campers en bestelwagens vanuit één + hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit + losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, + foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. + MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en + gecontroleerd laat oplossen. +

+
+ +
+

Wat écht werkt

+

+ Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde + toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met + serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen + oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n + met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde + testsuite (backend en Playwright end-to-end). +

+
+ +
+

Wat synthetisch is

+

+ De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, + procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig + verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of + bedrijf; e-mailadressen gebruiken uitsluitend het testdomein .test. +

+
+ +
+ +
+ {manifest.integrations.map((integration) => ( +
+ +
+ {integration.status_label} +

{integration.label}

+

{integration.detail}

+
+
+ ))} +
+
+ +
+

Demo-omgeving herstellen

+

+ De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "} + {formatDateTime(manifest.last_reset_at)}.{" "} + {user?.role === "operations_manager" ? ( + <> + Gebruik Reset demo data in de zijbalk om opnieuw te beginnen. + + ) : ( + <>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk. + )} +

+
+ +
+ +

+ Beperkingen + + Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx + MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, + afgebakende demokennisbank in plaats van een live RAGcore-omgeving. + +

+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index a62355f..c62a73e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,32 +1,41 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; import type { Role } from "../api/types"; import { BrandMark, Icon } from "../components/Icons"; export function Login() { const { loginAs, loading } = useAuth(); + const { manifest } = useDemoManifest(); const navigate = useNavigate(); const [error, setError] = useState(null); - async function handleLogin(role: Role) { + async function handleLogin(role: Role, guided = false) { setError(null); try { await loginAs(role); - navigate("/dashboard"); + navigate(guided ? "/dashboard?guide=start" : "/dashboard"); } catch { - setError("Could not start a demo session. The API may be unavailable."); + setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar."); } } + const orgName = manifest?.organization_name ?? "Northstar Mobility"; + const description = + manifest?.organization_description ?? + "MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " + + "verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " + + "vervolgstappen."; + return (
-
MobilityOpsControl centre
+
MobilityOpsBedieningscentrum
-

Connected mobility operations

-

Every hand-off.
One clear view.

-

Turn fleet state, rental returns, data quality and automation into one calm operational rhythm.

+

Demo-organisatie: {orgName} (fictief)

+

Elke overdracht.
Eén helder overzicht.

+

{description}

-

Synthetic proof of concept · no real customer data

+

Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar

-

Demo access

-

Choose your workspace

-

No password is required. Each role opens a scoped synthetic environment.

+

Demo-toegang

+

Kies hoe je wil starten

+

Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.

{error &&

{error}

} + + +
- -
-

Safe by designAll actions are audited and resettable in this demo.

+

Veilig ontworpenElke actie wordt gelogd en is in deze demo herstelbaar.

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a38409a..c313c7d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -103,8 +103,17 @@ a:hover { color: var(--teal); } .icon-button:hover { background: var(--surface-subtle); border-color: var(--line); } .icon-button svg { width: 18px; height: 18px; } .mobile-menu { display: none; } -.demo-banner { min-height: 32px; margin: 0; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 6px 20px; color: #48566a; background: #eaf0f5; border-bottom: 1px solid #d7e0e8; font-size: .68rem; font-weight: 600; letter-spacing: .02em; } -.demo-banner svg { width: 14px; height: 14px; } +.demo-badge { position: relative; } +.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; } +.demo-badge-trigger:hover { background: #dfe8ef; } +.demo-badge-trigger svg { width: 13px; height: 13px; } +.demo-badge-popover { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(320px, 84vw); padding: 16px; display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); } +.demo-badge-popover p { margin: 0; color: var(--ink-soft); font-size: .74rem; line-height: 1.55; } +.demo-badge-reset { color: var(--muted) !important; font-size: .68rem !important; } +.demo-badge-popover a { display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .74rem; font-weight: 700; } +.demo-badge-popover a svg { width: 13px; } +.demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; } +.demo-badge-close svg { width: 14px; height: 14px; } #main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; } .app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: var(--muted); border-top: 1px solid var(--line); font-size: .68rem; } .mobile-nav, .nav-scrim { display: none; } @@ -219,6 +228,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; } .tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); } .record-surface { padding: 18px; margin-bottom: 18px; } +.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } +.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } +.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } .detail-grid div { min-height: 76px; padding: 13px 14px; background: white; } .detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; } @@ -260,7 +272,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .login-brand { height: auto; padding: 0; border: 0; }.login-brand .brand-mark { width: 36px; height: 36px; }.login-brand strong { font-size: 1.05rem; }.login-message { position: relative; z-index: 2; max-width: 620px; margin: auto 0 22px; }.login-message .eyebrow { color: #5eead4; }.login-message h1 { margin: 0; color: white; font-size: clamp(2.8rem, 5.5vw, 5.4rem); line-height: .98; letter-spacing: -.06em; }.login-message > p:last-child { max-width: 520px; margin: 23px 0 0; color: #a9b7c8; font-size: .98rem; line-height: 1.65; } .control-illustration { position: absolute; width: min(35vw, 500px); aspect-ratio: 1; right: -80px; top: 7vh; opacity: .88; }.illustration-orbit { position: absolute; inset: 10%; border: 1px solid #29435a; border-radius: 50%; }.orbit-two { inset: 28%; border-style: dashed; transform: rotate(35deg); }.illustration-orbit::before, .illustration-orbit::after { content: ""; position: absolute; background: #2dd4bf; border-radius: 50%; }.illustration-orbit::before { width: 7px; height: 7px; top: 13%; left: 16%; }.illustration-orbit::after { width: 5px; height: 5px; right: 2%; bottom: 33%; }.illustration-core { position: absolute; inset: 40%; display: grid; place-items: center; color: white; background: #172a3e; border: 1px solid #2c465e; border-radius: 50%; }.illustration-core svg { width: 45px; }.illustration-node { position: absolute; width: 42px; height: 42px; display: grid; place-items: center; color: #5eead4; background: #152b3f; border: 1px solid #2c465e; border-radius: 50%; }.illustration-node svg { width: 18px; }.node-one { top: 13%; left: 20%; }.node-two { right: 5%; bottom: 27%; }.node-three { left: 15%; bottom: 12%; } .login-footnote { position: relative; z-index: 2; display: flex; align-items: center; gap: 7px; margin: auto 0 0; color: #7f90a4; font-size: .68rem; }.login-footnote svg { width: 14px; } -.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; } +.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-guide-cta { width: 100%; min-height: 48px; margin-bottom: 18px; font-size: .85rem; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; } @media (max-width: 1120px) { .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } @@ -271,7 +283,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details } @media (max-width: 700px) { - #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-banner { min-height: 28px; padding-inline: 10px; font-size: .59rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } + #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } From c63903cc94e4327be3e4045e552e799af0666684 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:48:22 +0200 Subject: [PATCH 05/21] docs: record Unraid deployment evidence for demo entry/manifest/badge batch --- PROJECT_STATE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index aa9e1a0..e031e88 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -583,5 +583,13 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem login redirect, badge popover content + About link, and Escape/outside-click close). Updated stale English login-button aria-labels and login-copy assertions across the existing specs to match the new Dutch copy. +- Deployed to Unraid (commit `ac427f4`): pushed to Gitea, `git archive` tarball extracted + preserving `.env`/volumes, both `api` and `web` rebuilt (frontend changed this batch), + both healthy, migrations unchanged, reseeded. Live-verified: `GET + /api/v1/demo/manifest` returns `organization_name: "Northstar Mobility"`, + `allow_reset: true`, and all 5 scenarios `ready: true` right after reset. Ran + `demo-entry.spec.ts` (4 tests) and the five-minute demo script directly against + `http://192.168.10.150:1236` — **5/5 passed**. Reseeded again afterward to leave the + server demo-ready. - Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31. From 9fff84dc68668b5669e160182d527f9020d730a1 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:15:15 +0200 Subject: [PATCH 06/21] feat(demo): add Demo Guide (8-step guided tour) and scenario overview Adds a compact "Probeer een demonstratiescenario" page listing the 5 named scenarios with live readiness from the manifest, plus a Demo Guide side panel (bottom sheet on mobile) that walks an Operations Manager through all 8 steps with per-step context, live-resolved routes, sessionStorage progress, and a "Demo opnieuw voorbereiden" restart. Login's guided-demo CTA now actually opens the guide. Fixes a real mobile topbar overflow the new guide trigger introduced. --- PROJECT_STATE.md | 38 ++++++ frontend/e2e/demo-entry.spec.ts | 9 +- frontend/e2e/demo-guide.spec.ts | 85 ++++++++++++++ frontend/src/App.tsx | 53 +++++---- frontend/src/components/DemoGuide.tsx | 134 ++++++++++++++++++++++ frontend/src/components/Layout.tsx | 4 + frontend/src/context/DemoGuideContext.tsx | 106 +++++++++++++++++ frontend/src/data/demoGuideSteps.ts | 91 +++++++++++++++ frontend/src/pages/Dashboard.tsx | 37 +++++- frontend/src/pages/Scenarios.tsx | 67 +++++++++++ frontend/src/styles.css | 52 ++++++++- 11 files changed, 647 insertions(+), 29 deletions(-) create mode 100644 frontend/e2e/demo-guide.spec.ts create mode 100644 frontend/src/components/DemoGuide.tsx create mode 100644 frontend/src/context/DemoGuideContext.tsx create mode 100644 frontend/src/data/demoGuideSteps.ts create mode 100644 frontend/src/pages/Scenarios.tsx diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e031e88..8d65d2f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -593,3 +593,41 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem server demo-ready. - Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31. + +### Batch 3 — Demo Guide + scenario overview (complete) + +- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title, + operational problem, duration, required role(s), "toont aan", ready/blocked status + from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets + one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the + existing dashboard uncluttered per the brief) showing readiness count and, for + Operations Managers, a guide resume/start control. +- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed` + set — browser-only, never touches auth or business logic), 8 static steps + (`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome, + resolving live routes from the manifest for the two scenario-backed steps (return, + duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a + fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no + layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps + require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden" + calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors + the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and + passes a one-shot `?guide=start` marker the dashboard consumes once then strips. +- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide + trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide + trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control + remains reachable there. Also fixed a genuine mobile overflow in the new + `.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens). +- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which + the app intentionally strips immediately — changed to assert the guide's actual open + state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the + full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness). +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5 + ready cards after reset, starting a scenario navigates to its fixed record, guide + step navigation/jump/close, progress persists across page navigation, guide hidden + from Rental Employee, restart-from-guide resets data and returns to login). Backend + untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy + clean in Batch 2). +- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 + data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index 63a97df..aedc9d1 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -12,11 +12,16 @@ test("demo entry screen names the fictional org and never shows a password", asy await expect(page.locator('input[type="password"]')).toHaveCount(0); }); -test("start guided demo logs in as Operations Manager and marks the guide to start", async ({ page }) => { +test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => { await page.goto("/login"); await page.getByRole("button", { name: "Start begeleide demo" }).click(); - await expect(page).toHaveURL(/\/dashboard\?guide=start$/); + // The ?guide=start marker is a one-shot signal the dashboard strips immediately after + // consuming it, so assert on its effect (the guide panel opens at step 1) rather than + // the transient URL, which can already be gone by the time this assertion runs. + await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByText("Amelie De Ridder")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); }); test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts new file mode 100644 index 0000000..6482511 --- /dev/null +++ b/frontend/e2e/demo-guide.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post("/api/v1/demo/reset"); + expect(reset.ok()).toBeTruthy(); +} + +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.goto("/scenarios"); + + await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); + const cards = page.locator(".scenario-card"); + await expect(cards).toHaveCount(5); + for (const label of ["Klaar voor demo"]) { + await expect(page.getByText(label).first()).toBeVisible(); + } + await expect(page.getByText("Niet beschikbaar")).toHaveCount(0); +}); + +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.goto("/scenarios"); + + const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); + await duplicateCard.getByRole("link", { name: "Start scenario" }).click(); + await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); +}); + +test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); + + await page.getByRole("button", { name: "Volgende" }).click(); + await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible(); + + await page.getByRole("button", { name: /6\. Stel een vraag/ }).click(); + await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible(); + + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/knowledge$/); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + await page.getByRole("button", { name: "Sluiten" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); +}); + +test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + await page.getByRole("button", { name: "Sluiten" }).click(); + + await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8"); + + await page.goto("/vehicles"); + await page.getByRole("button", { name: /Demo-gids/ }).click(); + await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0); +}); + +test("restarting the demo from the guide resets data and returns to login", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click(); + await expect(page).toHaveURL(/\/login$/, { timeout: 10000 }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ce1051..3cefb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./context/AuthContext"; import { DemoManifestProvider } from "./context/DemoManifestContext"; +import { DemoGuideProvider } from "./context/DemoGuideContext"; import { Layout } from "./components/Layout"; import { RequireAuth } from "./components/RequireAuth"; import { Login } from "./pages/Login"; @@ -15,35 +16,39 @@ import { Automation } from "./pages/Automation"; import { Knowledge } from "./pages/Knowledge"; import { Audit } from "./pages/Audit"; import { AboutDemo } from "./pages/AboutDemo"; +import { Scenarios } from "./pages/Scenarios"; export function App() { return ( - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + ); diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx new file mode 100644 index 0000000..650d87d --- /dev/null +++ b/frontend/src/components/DemoGuide.tsx @@ -0,0 +1,134 @@ +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { api, ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; +import { Icon } from "./Icons"; + +export function DemoGuideTrigger() { + const { user } = useAuth(); + const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); + + if (user?.role !== "operations_manager") return null; + + return ( + + ); +} + +export function DemoGuide() { + const navigate = useNavigate(); + const { logout } = useAuth(); + const { manifest, refresh } = useDemoManifest(); + const { + open, + closeGuide, + currentIndex, + completed, + totalSteps, + goToStep, + completeAndAdvance, + restart, + } = useDemoGuide(); + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(null); + + if (!open) return null; + + const step = DEMO_GUIDE_STEPS[currentIndex]; + const isLastStep = currentIndex === totalSteps - 1; + + function goToStepRoute() { + navigate(step.route(manifest)); + } + + async function handleRestartDemo() { + setResetError(null); + setResetting(true); + try { + await api.post("/api/v1/demo/reset"); + restart(); + refresh(); + closeGuide(); + await logout(); + navigate("/login"); + } catch (err) { + setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden."); + } finally { + setResetting(false); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ffad620..0a833ef 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../context/AuthContext"; import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; import { DemoBadge } from "./DemoBadge"; +import { DemoGuide, DemoGuideTrigger } from "./DemoGuide"; import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { @@ -277,6 +278,7 @@ export function Layout() { )}
+ Europe/Brussels {user && ( @@ -307,6 +309,8 @@ export function Layout() { More + +
); } diff --git a/frontend/src/context/DemoGuideContext.tsx b/frontend/src/context/DemoGuideContext.tsx new file mode 100644 index 0000000..f2742b6 --- /dev/null +++ b/frontend/src/context/DemoGuideContext.tsx @@ -0,0 +1,106 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; + +const STORAGE_KEY = "mobilityops.demo-guide-progress"; + +interface StoredProgress { + currentIndex: number; + completed: string[]; +} + +function readProgress(): StoredProgress { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return { currentIndex: 0, completed: [] }; + const parsed = JSON.parse(raw) as StoredProgress; + return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] }; + } catch { + return { currentIndex: 0, completed: [] }; + } +} + +function writeProgress(progress: StoredProgress) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); +} + +interface DemoGuideState { + open: boolean; + currentIndex: number; + completed: Set; + totalSteps: number; + openGuide: () => void; + closeGuide: () => void; + toggleGuide: () => void; + goToStep: (index: number) => void; + completeAndAdvance: () => void; + restart: () => void; +} + +const DemoGuideContext = createContext(undefined); + +export function DemoGuideProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [completed, setCompleted] = useState>(new Set()); + + useEffect(() => { + const stored = readProgress(); + setCurrentIndex(stored.currentIndex); + setCompleted(new Set(stored.completed)); + }, []); + + const persist = useCallback((index: number, done: Set) => { + writeProgress({ currentIndex: index, completed: Array.from(done) }); + }, []); + + const goToStep = useCallback( + (index: number) => { + const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1)); + setCurrentIndex(clamped); + persist(clamped, completed); + }, + [completed, persist], + ); + + const completeAndAdvance = useCallback(() => { + setCompleted((prev) => { + const next = new Set(prev); + next.add(DEMO_GUIDE_STEPS[currentIndex].id); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + persist(nextIndex, next); + setCurrentIndex(nextIndex); + return next; + }); + }, [currentIndex, persist]); + + const restart = useCallback(() => { + setCurrentIndex(0); + setCompleted(new Set()); + writeProgress({ currentIndex: 0, completed: [] }); + }, []); + + return ( + setOpen(true), + closeGuide: () => setOpen(false), + toggleGuide: () => setOpen((v) => !v), + goToStep, + completeAndAdvance, + restart, + }} + > + {children} + + ); +} + +export function useDemoGuide(): DemoGuideState { + const ctx = useContext(DemoGuideContext); + if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider"); + return ctx; +} diff --git a/frontend/src/data/demoGuideSteps.ts b/frontend/src/data/demoGuideSteps.ts new file mode 100644 index 0000000..22f7f45 --- /dev/null +++ b/frontend/src/data/demoGuideSteps.ts @@ -0,0 +1,91 @@ +import type { DemoManifest } from "../api/types"; + +export interface DemoGuideStep { + id: string; + title: string; + whatYouWillSee: string; + whyItMatters: string; + startAction: string; + expectedOutcome: string; + /** Resolves the route to link to, using live manifest data where a scenario supplies + * a concrete record (booking/issue ref) instead of hardcoding one that could drift. */ + route: (manifest: DemoManifest | null) => string; +} + +export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [ + { + id: "understand-state", + title: "1. Begrijp de operationele status", + whatYouWillSee: "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.", + whyItMatters: "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.", + startAction: "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.", + expectedOutcome: "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen.", + route: () => "/dashboard", + }, + { + id: "open-booking", + title: "2. Open een boeking die aandacht nodig heeft", + whatYouWillSee: "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.", + whyItMatters: "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.", + startAction: "Open de boeking vanuit het dashboard of de boekingenlijst.", + expectedOutcome: "Je ziet de boekingsdetails en de knop om de retour te verwerken.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings", + }, + { + id: "process-return", + title: "3. Verwerk een retour met een afwijkende kilometerstand", + whatYouWillSee: "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.", + whyItMatters: "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.", + startAction: "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.", + expectedOutcome: "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings", + }, + { + id: "handle-quality-issue", + title: "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem", + whatYouWillSee: "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.", + whyItMatters: "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.", + startAction: "Open het datakwaliteitsoverzicht en kies het nieuwste issue.", + expectedOutcome: "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor.", + route: () => "/data-quality", + }, + { + id: "merge-duplicate", + title: "5. Beoordeel en behandel een mogelijke dubbele klant", + whatYouWillSee: "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.", + whyItMatters: "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.", + startAction: "Vergelijk beide profielen en kies welk profiel behouden blijft.", + expectedOutcome: "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality", + }, + { + id: "ask-knowledge", + title: "6. Stel een vraag aan de procedureassistent", + whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.", + whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.", + startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".', + expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.", + route: () => "/knowledge", + }, + { + id: "check-automation-audit", + title: "7. Controleer automatisering en audit trail", + whatYouWillSee: "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.", + whyItMatters: "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.", + startAction: "Open Systemen om de afleverstatus te zien, en Audit trail voor het volledige spoor.", + expectedOutcome: "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties.", + route: () => "/automation", + }, + { + id: "review-real-vs-simulated", + title: "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld", + whatYouWillSee: "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.", + whyItMatters: "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.", + startAction: "Lees de pagina 'Over deze demo'.", + expectedOutcome: "Je kan zelf uitleggen wat MobilityOps wel en niet is, zonder mondelinge toelichting.", + route: () => "/about", + }, +]; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 54751d7..41f61d3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { api } from "../api/client"; import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; import { SeverityBadge, StatusBadge } from "../components/Badge"; import { Icon } from "../components/Icons"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; @@ -26,8 +28,21 @@ function localTime(value: string, withDate = false) { export function Dashboard() { const { user } = useAuth(); + const { manifest } = useDemoManifest(); + const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide(); + const [searchParams, setSearchParams] = useSearchParams(); const canSeeQuality = user?.role === "operations_manager"; const canSeeAutomation = user?.role === "operations_manager"; + + useEffect(() => { + if (searchParams.get("guide") === "start") { + restart(); + openGuide(); + setSearchParams({}, { replace: true }); + } + // Only ever react to the initial `?guide=start` marker set by the login screen's + // "Start begeleide demo" CTA, so this intentionally runs once on mount. + }, []); const [data, setData] = useState(null); const [knowledge, setKnowledge] = useState(null); const [integrationStatus, setIntegrationStatus] = useState(null); @@ -64,6 +79,26 @@ export function Dashboard() {
View fleet} /> +
+
+ +
+ Probeer een demonstratiescenario + + {manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."} + +
+
+
+ {user?.role === "operations_manager" && ( + + )} + Bekijk scenario's +
+
+
diff --git a/frontend/src/pages/Scenarios.tsx b/frontend/src/pages/Scenarios.tsx new file mode 100644 index 0000000..d930cbb --- /dev/null +++ b/frontend/src/pages/Scenarios.tsx @@ -0,0 +1,67 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "../components/Icons"; +import { LoadingState, PageHeader } from "../components/PageChrome"; + +const ROLE_LABEL: Record = { + operations_manager: "Operations Manager", + rental_employee: "Rental Employee", +}; + +export function Scenarios() { + const { manifest, loading } = useDemoManifest(); + const { user } = useAuth(); + + return ( +
+ + + {loading && } + + {manifest && ( +
+ {manifest.scenarios.map((scenario) => { + const canRun = !user || scenario.required_roles.includes(user.role); + return ( +
+
+

{scenario.title}

+ + {scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"} + +
+

{scenario.operational_problem}

+
+
Duur
± {scenario.estimated_minutes} min
+
Rol
{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}
+
+

Toont aan: {scenario.demonstrates}

+ {!scenario.ready && scenario.blocked_reason && ( +

{scenario.blocked_reason}

+ )} + {!canRun && ( +

Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.

+ )} + { + if (!scenario.ready || !canRun) event.preventDefault(); + }} + > + Start scenario + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c313c7d..b90b5a2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -257,6 +257,21 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } +.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.scenario-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.scenario-card h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; } +.scenario-problem, .scenario-demonstrates { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.55; } +.scenario-demonstrates strong { color: var(--ink-soft); } +.scenario-meta { display: flex; gap: 20px; margin: 0; } +.scenario-meta div { display: grid; gap: 2px; } +.scenario-meta dt { color: var(--muted); font-size: .58rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; } +.scenario-meta dd { margin: 0; color: var(--ink-soft); font-size: .76rem; font-weight: 600; } +.scenario-blocked { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 8px 10px; color: #8a5a12; background: #fdf3e2; border: 1px solid #f2ddb0; border-radius: 8px; font-size: .68rem; line-height: 1.45; } +.scenario-blocked svg { width: 14px; flex-shrink: 0; margin-top: 1px; } +.scenario-card > .button { align-self: flex-start; margin-top: auto; } +.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; } + .knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); } .knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; } .knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; } @@ -278,17 +293,50 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } } +.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); } +.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; } +.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; } +.demo-start-panel strong { display: block; color: var(--ink); font-size: .85rem; } +.demo-start-panel span { display: block; margin-top: 2px; color: var(--muted); font-size: .72rem; } +.demo-start-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.demo-guide-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 700; cursor: pointer; } +.demo-guide-trigger:hover { background: #d9f0ea; } +.demo-guide-trigger svg { width: 13px; height: 13px; } +.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; } + +.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; } +.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; } +.demo-guide-progress-bar { display: flex; gap: 4px; } +.demo-guide-progress-bar span { flex: 1; height: 4px; background: var(--line); border-radius: 2px; } +.demo-guide-progress-bar span.is-done { background: var(--teal); } +.demo-guide-progress-bar span.is-current { background: var(--teal-dark); } +.demo-guide-body { display: grid; gap: 12px; } +.demo-guide-body p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } +.demo-guide-body p strong { color: var(--muted); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.demo-guide-steps { display: grid; gap: 3px; padding-top: 12px; border-top: 1px solid var(--line); } +.demo-guide-steps button { display: flex; align-items: center; gap: 7px; padding: 7px 8px; text-align: left; color: var(--muted); background: transparent; border: 0; border-radius: var(--radius); font-size: .68rem; cursor: pointer; } +.demo-guide-steps button svg { width: 12px; color: var(--teal); flex-shrink: 0; } +.demo-guide-steps button:hover { background: var(--surface-subtle); } +.demo-guide-steps button.is-current { color: var(--ink); background: var(--teal-pale); font-weight: 700; } +.demo-guide-footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); } +.demo-guide-restart { width: 100%; padding: 8px; color: var(--muted); background: transparent; border: 1px dashed var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; } +.demo-guide-restart:hover { color: var(--ink-soft); border-color: var(--muted-light); } +.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; } + @media (max-width: 960px) { .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } } @media (max-width: 700px) { - #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } + #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } } @media (max-width: 420px) { - .global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } + .global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } } From 14c2ad3ee8b02d13b625198a9bf80790da17d9c3 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:17:44 +0200 Subject: [PATCH 07/21] fix(demo): wait for post-login redirect before navigating in e2e tests Two demo-guide.spec.ts tests navigated straight to /scenarios right after clicking a login button without waiting for the /dashboard redirect to settle first. This raced harmlessly on localhost but flaked against the higher-latency Unraid deployment, hitting RequireAuth before the session was confirmed. --- frontend/e2e/demo-guide.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts index 6482511..e1074c3 100644 --- a/frontend/e2e/demo-guide.spec.ts +++ b/frontend/e2e/demo-guide.spec.ts @@ -13,6 +13,7 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async await resetDemoData(request); await page.goto("/login"); await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); @@ -27,6 +28,7 @@ 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 expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); From 6b864596e02bc2bbb1360f2bc4807d278be28591 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:18:47 +0200 Subject: [PATCH 08/21] docs: record Unraid deployment evidence for Demo Guide/scenario overview batch --- PROJECT_STATE.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 8d65d2f..0463620 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -629,5 +629,14 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem from Rental Employee, restart-from-guide resets data and returns to login). Backend untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy clean in Batch 2). +- Deployed to Unraid (commits `9fff84d`, then `14c2ad3` for a test-only fix): pushed to + Gitea, tarball extracted, `web` rebuilt (frontend-only batch), healthy. Live-verified: + ran `demo-guide.spec.ts` (6), `demo-entry.spec.ts` (4) and the five-minute demo script + directly against `http://192.168.10.150:1236` — **11/11 passed**. Caught and fixed one + real environment-sensitive test bug in the process: two tests navigated straight to + `/scenarios` right after a login click without waiting for the `/dashboard` redirect, + which raced harmlessly on localhost but flaked against Unraid's higher latency — fixed + by asserting the redirect first, no app-code change needed. Reseeded afterward to leave + the server demo-ready. - Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. From ddc3a98e4bcbdae2a5dfb738126bf64b396df368 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:41:02 +0200 Subject: [PATCH 09/21] feat(demo): make return, data-quality and knowledge flows demo-legible Fixes a real honesty bug in Knowledge.tsx (body copy named "RAGcore" while the active provider is the demo one) and a second real bug discovered while fixing it: the brief's suggested Dutch questions would silently return "insufficient evidence" against the English-only demo knowledge base -- verified empirically and fixed by keeping suggested questions in English. The return flow now pre-fills the odometer-regression scenario's suspicious reading instead of asking a visitor to invent one, and links to automation/audit after committing. Data-quality issues get a shared plain-language "what's wrong / why it matters" explainer per rule type, a post-resolution confirmation with audit/vehicle links, and a "demo scenario's only" list filter. Also fixes a real async race where the odometer pre-fill could clobber text a visitor had already started typing. --- PROJECT_STATE.md | 44 ++++++ frontend/e2e/demo-legibility.spec.ts | 77 +++++++++++ frontend/src/components/ReturnForm.tsx | 48 ++++++- frontend/src/data/demoGuideSteps.ts | 5 +- frontend/src/pages/BookingDetail.tsx | 41 +++++- frontend/src/pages/DataQuality.tsx | 23 +++- frontend/src/pages/DataQualityIssueDetail.tsx | 125 ++++++++++++++++-- frontend/src/pages/Knowledge.tsx | 44 ++++-- frontend/src/styles.css | 16 +++ 9 files changed, 394 insertions(+), 29 deletions(-) create mode 100644 frontend/e2e/demo-legibility.spec.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 0463620..1127b46 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -640,3 +640,47 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem the server demo-ready. - Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. + +### Batch 4 — return/data-quality/knowledge demo legibility (complete) + +- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the + retrieval-flow diagram even though the active provider is the demo TF-IDF one (the + small badge below was already honest, contradicting the prose one line above). Now + derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health + check and uses it everywhere; added an explicit disclosure note when not RAGcore. + Added 4 suggested-question chips. **Discovered and fixed a second real bug in the + process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording) + would have returned "insufficient evidence" against the demo provider, because the + indexed procedures are English-only — verified empirically (Dutch question → + `insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested + questions in English (matching the indexed content) and rewording the Guide step to + explain the knowledge base is English, rather than mistranslating the demo's + centerpiece feature into silently returning wrong answers. +- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario + booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real + canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious + value below it, plus a callout explaining why — the brief explicitly requires the demo + not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one + scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to + Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de + demo" button when the Demo Guide is open (advances the guide and navigates to the next + step). **Fixed a real regression caught by the existing return-review e2e test**: the + async pre-fill could silently overwrite odometer text a visitor had already started + typing, if the vehicle-detail fetch resolved after they began typing — fixed with an + `odometerEditedByUser` ref guard. +- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain + language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic + post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo") + for the 4 rule types that previously just silently flipped their status badge with no + explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation + with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox + filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new + business logic) so the curated issues are easy to find among the full queue. +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why- + suspicious explanation + result links, rule explainer visible, demo-scenario filter + narrows correctly, knowledge suggested question returns grounded evidence with the + correct provider label). Backend untouched this batch. +- Exact next action: plain-language integration-status labels, richer audit narration, + the full "Over deze demo" page content (currently a first pass from Batch 2), and + wiring reset into the guide/About/OM menu narrative — task #34. diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts new file mode 100644 index 0000000..fb9f16d --- /dev/null +++ b/frontend/e2e/demo-legibility.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post("/api/v1/demo/reset"); + expect(reset.ok()).toBeTruthy(); +} + +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 expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/bookings/BK-DEMO-RETURN"); + + await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); + const odometerInput = page.getByLabel("End odometer (km)"); + await expect(odometerInput).not.toHaveValue(""); + const prefilled = Number(await odometerInput.inputValue()); + expect(prefilled).toBeGreaterThan(0); + + await page.getByRole("button", { name: "Review return" }).click(); + await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); + await page.getByRole("button", { name: "Confirm return" }).click(); + await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible(); + await expect(page.getByRole("link", { name: "View audit trail" })).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 expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); + + await expect(page.getByText("What's wrong")).toBeVisible(); + await expect(page.getByText("Why it matters")).toBeVisible(); + await expect(page.getByText(/likely the same person/)).toBeVisible(); +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/data-quality"); + + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const allRows = await page.locator(".data-table tbody tr").count(); + await page.getByRole("checkbox", { name: "Demo scenario's only" }).check(); + const filteredRows = await page.locator(".data-table tbody tr").count(); + expect(filteredRows).toBeGreaterThan(0); + expect(filteredRows).toBeLessThanOrEqual(allRows); + const refs = await page.locator(".data-table tbody tr th a").allTextContents(); + for (const ref of refs) { + expect(ref.startsWith("DQ-DEMO-")).toBeTruthy(); + } +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/knowledge"); + + // The status badge and retrieval-flow diagram must name the actual active provider + // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is + // allowed to mention RAGcore by name when explaining it isn't live yet. + await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base"); + await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base"); + await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore"); + + await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); +}); diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 1ef68bc..3694691 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,8 +1,11 @@ -import { useState, type FormEvent } from "react"; -import { Link } from "react-router-dom"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "./Icons"; import { StatusBadge } from "./Badge"; @@ -14,7 +17,17 @@ function newIdempotencyKey(): string { export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { const { user } = useAuth(); + const navigate = useNavigate(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const canSeeQualityIssue = user?.role === "operations_manager"; + + function continueDemo() { + completeAndAdvance(); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest)); + } + return (

Committed locally

Return registered

@@ -58,9 +71,16 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) opened for review.

)} -

+

View vehicle {result.vehicle_ref} -

+ View automation status + View audit trail + {guideOpen && ( + + )} +
); } @@ -68,11 +88,14 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) export function ReturnForm({ bookingRef, onRegistered, + suggestedOdometerKm, }: { bookingRef: string; onRegistered: (result: RegisterReturnResult) => void; + suggestedOdometerKm?: number; }) { - const [odometer, setOdometer] = useState(""); + const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); + const odometerEditedByUser = useRef(false); const [fuel, setFuel] = useState("50"); const [cleanlinessOk, setCleanlinessOk] = useState(true); const [damageReported, setDamageReported] = useState(false); @@ -85,6 +108,16 @@ export function ReturnForm({ const [step, setStep] = useState<"capture" | "review">("capture"); const [preview, setPreview] = useState(null); + useEffect(() => { + // suggestedOdometerKm arrives asynchronously (BookingDetail fetches the vehicle's + // canonical odometer after this form has already mounted), so the initial useState + // value alone won't catch it -- sync it once it resolves. Never overwrite a value the + // visitor has already started typing. + if (suggestedOdometerKm !== undefined && !odometerEditedByUser.current) { + setOdometer(String(suggestedOdometerKm)); + } + }, [suggestedOdometerKm]); + function currentBody(): RegisterReturnRequest { return { end_odometer_km: Number(odometer), @@ -151,7 +184,10 @@ export function ReturnForm({ required min={0} value={odometer} - onChange={(e) => setOdometer(e.target.value)} + onChange={(e) => { + odometerEditedByUser.current = true; + setOdometer(e.target.value); + }} /> diff --git a/frontend/src/data/demoGuideSteps.ts b/frontend/src/data/demoGuideSteps.ts index 22f7f45..6ba4664 100644 --- a/frontend/src/data/demoGuideSteps.ts +++ b/frontend/src/data/demoGuideSteps.ts @@ -66,7 +66,10 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [ title: "6. Stel een vraag aan de procedureassistent", whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.", whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.", - startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".', + startAction: + 'Klik op één van de voorbeeldvragen (bv. "What must I do when a vehicle returns ' + + 'with damage?"). De geïndexeerde procedures zijn Engelstalig, dus gebruik de ' + + "voorgestelde vragen of stel je eigen vraag in het Engels.", expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.", route: () => "/knowledge", }, diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 4303610..866c8e2 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { api } from "../api/client"; -import type { Booking, RegisterReturnResult } from "../api/types"; +import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types"; +import { useDemoManifest } from "../context/DemoManifestContext"; import { StatusBadge } from "../components/Badge"; import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm"; import { Icon } from "../components/Icons"; @@ -9,9 +10,11 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; export function BookingDetail() { const { publicRef } = useParams<{ publicRef: string }>(); + const { manifest } = useDemoManifest(); const [booking, setBooking] = useState(null); const [error, setError] = useState(null); const [returnResult, setReturnResult] = useState(null); + const [canonicalOdometerKm, setCanonicalOdometerKm] = useState(null); const load = useCallback(() => { if (!publicRef) return; @@ -28,6 +31,21 @@ export function BookingDetail() { load(); }, [load]); + // The odometer-regression demo scenario supplies its own suspicious reading (per the + // brief: never ask a demo visitor to invent one) -- only fetched for that one known + // scenario booking, not for every return. + const isReturnAnomalyScenario = + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`; + + useEffect(() => { + setCanonicalOdometerKm(null); + if (!isReturnAnomalyScenario || !booking) return; + api + .get(`/api/v1/vehicles/${booking.vehicle_ref}`) + .then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km)) + .catch(() => setCanonicalOdometerKm(null)); + }, [isReturnAnomalyScenario, booking]); + function handleRegistered(result: RegisterReturnResult) { setReturnResult(result); load(); @@ -50,9 +68,28 @@ export function BookingDetail() {
Requirements complete
{booking.requirements_complete ? "Yes" : "No"}
+ {isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && ( +
+ +
+ Demonstratiescenario: afwijkende kilometerstand +

+ Dit voertuig staat momenteel op {canonicalOdometerKm.toLocaleString("en-GB")} km. + Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder + ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de + retour om te zien hoe MobilityOps dit detecteert en afhandelt. +

+
+
+ )} + {returnResult && } {!returnResult && booking.status === "active" && ( - + )}
); diff --git a/frontend/src/pages/DataQuality.tsx b/frontend/src/pages/DataQuality.tsx index d85c826..89f8c3e 100644 --- a/frontend/src/pages/DataQuality.tsx +++ b/frontend/src/pages/DataQuality.tsx @@ -24,6 +24,7 @@ export function DataQuality() { const [scanError, setScanError] = useState(null); const [scanResult, setScanResult] = useState(null); const [confirmingScan, setConfirmingScan] = useState(false); + const [demoScenariosOnly, setDemoScenariosOnly] = useState(false); const load = useCallback(() => { if (user?.role !== "operations_manager") return; @@ -67,6 +68,11 @@ export function DataQuality() { } const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; + const visibleIssues = issues + ? demoScenariosOnly + ? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) + : issues + : []; return (
@@ -126,14 +132,25 @@ export function DataQuality() { ))} + {error && } {!error && !issues && } {issues && issues.length === 0 && } + {issues && issues.length > 0 && visibleIssues.length === 0 && ( + + )} - {issues && issues.length > 0 && ( -
{issues.length} issuesEvidence-backed detection
+ {visibleIssues.length > 0 && ( +
{visibleIssues.length} issuesEvidence-backed detection
@@ -145,7 +162,7 @@ export function DataQuality() { - {issues.map((i) => ( + {visibleIssues.map((i) => ( + @@ -98,6 +119,11 @@ export function Audit() { )} +
Data-quality issues
{i.public_ref} diff --git a/frontend/src/pages/DataQualityIssueDetail.tsx b/frontend/src/pages/DataQualityIssueDetail.tsx index 5d017d6..d673873 100644 --- a/frontend/src/pages/DataQualityIssueDetail.tsx +++ b/frontend/src/pages/DataQualityIssueDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { ApplyRecommendedStatusResult, @@ -8,11 +8,62 @@ import type { } from "../api/types"; import { SeverityBadge, StatusBadge } from "../components/Badge"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "../components/Icons"; import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"]; +const RULE_EXPLAINERS: Record = { + possible_duplicate_customer: { + whatIsWrong: + "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.", + whyItMatters: + "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.", + }, + missing_required_field: { + whatIsWrong: + "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).", + whyItMatters: + "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.", + }, + odometer_regression: { + whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.", + whyItMatters: + "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.", + }, + booking_overlap: { + whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.", + whyItMatters: + "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.", + }, + vehicle_status_conflict: { + whatIsWrong: + "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.", + whyItMatters: + "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.", + }, +}; + +function RuleExplainer({ ruleType }: { ruleType: string }) { + const explainer = RULE_EXPLAINERS[ruleType]; + if (!explainer) return null; + return ( +
+
+ What's wrong +

{explainer.whatIsWrong}

+
+
+ Why it matters +

{explainer.whyItMatters}

+
+
+ ); +} + function EvidenceDisclosure({ issue }: { issue: IssueDetail }) { return (
@@ -433,11 +484,20 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso } function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { + const navigate = useNavigate(); + const { manifest } = useDemoManifest(); + const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [submitting, setSubmitting] = useState(false); const [confirming, setConfirming] = useState(false); + function continueDemo() { + completeAndAdvance(); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest)); + } + // Once resolved through this panel, keep showing the "Applied" confirmation even // after the parent's issue.status flips away from "open" -- reloading on success // updates the page's own status badge immediately, but this panel controls its own @@ -472,9 +532,20 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
Current status
{result ? ( -

- Applied — {result.reason} -

+ <> +

+ Applied — {result.reason} +

+
+ View audit trail + View vehicle + {guideOpen && ( + + )} +
+ ) : !confirming ? ( + )} + + + )} + {issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && ( - + )} {issue.status === "open" && issue.rule_type === "missing_required_field" && ( - + )} {issue.status === "open" && issue.rule_type === "odometer_regression" && ( - + )} {issue.status === "open" && issue.rule_type === "booking_overlap" && ( - + )} {issue.rule_type === "vehicle_status_conflict" && ( - + )} {issue.status === "open" && ( diff --git a/frontend/src/pages/Knowledge.tsx b/frontend/src/pages/Knowledge.tsx index e3dcfc3..a4a6d82 100644 --- a/frontend/src/pages/Knowledge.tsx +++ b/frontend/src/pages/Knowledge.tsx @@ -15,6 +15,13 @@ const EVIDENCE_LABEL: Record = { unavailable: "Knowledge service unavailable", }; +const SUGGESTED_QUESTIONS = [ + "What must I do when a vehicle returns with damage?", + "When may a vehicle be made available again?", + "Who reviews an unusual odometer reading?", + "Which checks are required before departure?", +]; + export function Knowledge() { const [status, setStatus] = useState(null); const [question, setQuestion] = useState(""); @@ -29,14 +36,12 @@ export function Knowledge() { .catch(() => setStatus(null)); }, []); - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!question.trim()) return; + async function ask(questionText: string) { setError(null); setSubmitting(true); try { - const answer = await api.post("/api/v1/knowledge/questions", { question }); - setExchanges((prev) => [{ question, answer }, ...prev]); + const answer = await api.post("/api/v1/knowledge/questions", { question: questionText }); + setExchanges((prev) => [{ question: questionText, answer }, ...prev]); setQuestion(""); } catch (err) { setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service."); @@ -45,11 +50,26 @@ export function Knowledge() { } } + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!question.trim()) return; + await ask(question); + } + + const providerLabel = status?.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"; + return (
- + {status && ( -
{status.provider}{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed
{status.collection}
+
{providerLabel}{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed
{status.collection}
+ )} + {status?.provider !== "ragcore" && ( +

+ This demo answers from a small, fixed set of indexed + procedures — not a live RAGcore connection. A live RAGcore backend will later + take over the same interface without changing how this page works. +

)}
@@ -74,10 +94,18 @@ export function Knowledge() {
{error &&

{error}

} +
+ Try one: + {SUGGESTED_QUESTIONS.map((q) => ( + + ))} +
{exchanges.length === 0 && !error && ( -

Evidence before answers

Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.

+

Evidence before answers

Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.

)}
    diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b90b5a2..9ef3c3a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -248,6 +248,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; } .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } +.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); } +.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } +.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } +.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; } +.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; } +.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; } +.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } .duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; } .compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); } @@ -293,6 +301,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } } +.knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; } +.knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); } +.knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; } +.knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; } +.suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; } +.suggestion-chip:hover { background: #d9f0ea; } +.suggestion-chip:disabled { opacity: .6; cursor: not-allowed; } + .demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); } .demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; } .demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; } From cf9a8895477f1d5fcad420b3ada9a6afad04d791 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:42:49 +0200 Subject: [PATCH 10/21] docs: record Unraid deployment evidence for demo-legibility batch --- PROJECT_STATE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1127b46..d09c139 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -681,6 +681,10 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem suspicious explanation + result links, rule explainer visible, demo-scenario filter narrows correctly, knowledge suggested question returns grounded evidence with the correct provider label). Backend untouched this batch. +- Deployed to Unraid (commit `ddc3a98`): pushed to Gitea, tarball extracted, `web` + rebuilt (frontend-only), healthy. Live-verified: ran `demo-legibility.spec.ts` (4) and + the five-minute demo script directly against `http://192.168.10.150:1236` — + **5/5 passed**. Reseeded afterward to leave the server demo-ready. - Exact next action: plain-language integration-status labels, richer audit narration, the full "Over deze demo" page content (currently a first pass from Batch 2), and wiring reset into the guide/About/OM menu narrative — task #34. From 5fa4fe08111d890ac5f98fd5169cab17db44fc08 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:00:11 +0200 Subject: [PATCH 11/21] feat(demo): plain-language integration status, richer audit, reset integrity Integration status badges across Dashboard/Automation now show honest plain-language labels instead of raw backend state strings (and fix a few states that had no matching CSS colour class at all). Audit trail gets a "view related events" action reusing the existing correlation_id filter. About page gains scope/architecture/security/testing sections and a guided-demo entry point. POST /api/v1/demo/reset now runs and records a server-side scenario-integrity check. Also fixes a second real race condition (caught by the return-review e2e test): the odometer scenario pre-fill now resolves before ReturnForm mounts instead of patching its value in after the fact. --- PROJECT_STATE.md | 48 ++++++++++++++++++++++ backend/app/api/routers/demo.py | 5 ++- backend/app/services/demo_manifest.py | 14 +++++++ backend/tests/test_auth.py | 9 ++-- frontend/src/components/Badge.tsx | 4 +- frontend/src/components/ReturnForm.tsx | 18 +------- frontend/src/data/integrationLabels.ts | 18 ++++++++ frontend/src/pages/AboutDemo.tsx | 57 ++++++++++++++++++++++++++ frontend/src/pages/Audit.tsx | 32 +++++++++++++-- frontend/src/pages/Automation.tsx | 49 ++++++++++++++++------ frontend/src/pages/BookingDetail.tsx | 9 +++- frontend/src/pages/Dashboard.tsx | 34 ++++++++++----- frontend/src/styles.css | 5 +++ 13 files changed, 254 insertions(+), 48 deletions(-) create mode 100644 frontend/src/data/integrationLabels.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d09c139..de4e22a 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -688,3 +688,51 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem - Exact next action: plain-language integration-status labels, richer audit narration, the full "Over deze demo" page content (currently a first pass from Batch 2), and wiring reset into the guide/About/OM menu narrative — task #34. + +### Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete) + +- Plain-language integration status: extracted `frontend/src/data/integrationLabels.ts` + (`N8N_STATE_META`/`MCP_STATE_META`) mapping raw backend states to honest labels + ("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while + keeping each mapped onto an existing `.status-*` CSS colour class (a few raw values like + `degraded`/`disabled`/`configured` had no matching CSS rule at all before this — a real, + pre-existing colour-coding gap). `StatusBadge` gained an optional `label` override prop + (backward compatible) so the badge's colour class and its displayed text can differ. + Wired into both `Automation.tsx` and `Dashboard.tsx`'s integration cards; also renamed + the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the + actual active provider (same bug class fixed in Knowledge.tsx in Batch 4). +- Audit trail: added a "Follow-up" column with a "View related events" action per row that + filters the same list by `correlation_id` (reuses the backend's existing, already-tested + `correlation_id` query param — no new business logic), with a "Clear this filter" + affordance. This is how a visitor sees "what else happened as a result of this action" + (e.g. a return's linked vehicle-status-changed / workflow-queued events) without a + bigger grouped-timeline rebuild. +- About page: added target-audience/scope, a short architecture summary, security + principles, and a testing-approach section (previously only covered the fictional + problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for + Operations Managers that opens the Demo Guide directly from this page. +- Reset integrity: added `scenario_integrity_report()` (`backend/app/services/ + demo_manifest.py`), reusing the exact same scenario-readiness derivation the manifest + and scenario overview already use (so it can't drift), and wired it into `POST + /api/v1/demo/reset` — both the response body and the `demo_reset` audit event's + metadata now carry `scenario_integrity: {all_ready, not_ready}`. This is the + server-side post-reset integrity check the brief asks for; visible today via the audit + event's raw-detail view, satisfying the requirement without adding a UI banner to a + flow that immediately logs the user out and redirects to `/login`. +- **Fixed a second real regression this batch, caught by the existing return-review + e2e test**: restructured the odometer pre-fill so `BookingDetail.tsx` withholds + rendering `ReturnForm` until the scenario's canonical odometer has resolved (with a + brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately + and patching its value in asynchronously. The previous approach raced visibly with + Playwright's `fill()` (and would have raced with a real visitor typing quickly), + producing a corrupted concatenated value in one observed failure. This also let the + now-unnecessary `odometerEditedByUser` ref guard be removed — simpler and more robust + than the effect-based patch it replaced. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **51 passed**, + confirmed stable across three consecutive full runs (given how many timing races this + batch and the previous one surfaced, stability was verified deliberately rather than + assumed from a single green run). +- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per + section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ + reduced-motion/console/network checks) — task #35. diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 97c5a78..fd06a68 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -14,7 +14,7 @@ from app.models.user import User from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut from app.seed_loader import reset_and_seed from app.services.audit import record_audit_event -from app.services.demo_manifest import build_demo_manifest +from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) settings = get_settings() @@ -107,6 +107,7 @@ def demo_reset( detail="Demo reset is disabled on this deployment.", ) result = reset_and_seed(db) + integrity = scenario_integrity_report(db) record_audit_event( db, actor_type="user", @@ -116,6 +117,7 @@ def demo_reset( metadata={ "counts": result.counts, "anchor_date": result.anchor_date.isoformat(), + "scenario_integrity": integrity, }, ) db.commit() @@ -125,4 +127,5 @@ def demo_reset( "counts": result.counts, "anchor_date": result.anchor_date.isoformat(), "seeded_at": result.seeded_at.isoformat(), + "scenario_integrity": integrity, } diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index f9b38b2..d3e2106 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -223,6 +223,20 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: ] +def scenario_integrity_report(db: Session) -> dict: + """Server-side scenario-integrity check run after every reset (section 15): confirms + each of the 5 named scenarios is actually present and ready, rather than trusting the + seed loader silently. Reuses the same readiness derivation the manifest/scenario + overview already use, so this can never drift from what a visitor actually sees.""" + scenarios = _scenarios(db) + not_ready = [ + {"id": s.id, "title": s.title, "reason": s.blocked_reason} + for s in scenarios + if not s.ready + ] + return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} + + def build_demo_manifest(db: Session) -> DemoManifestOut: last_reset_at, anchor_date = _last_reset(db) return DemoManifestOut( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 143757f..4cdd169 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -17,9 +17,12 @@ def test_rental_employee_cannot_reset_demo(employee_client): def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 - assert response.json()["counts"]["vehicles"] == 50 - assert response.json()["anchor_date"] - assert response.json()["seeded_at"] + body = response.json() + assert body["counts"]["vehicles"] == 50 + assert body["anchor_date"] + assert body["seeded_at"] + assert body["scenario_integrity"]["all_ready"] is True + assert body["scenario_integrity"]["not_ready"] == [] def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx index 7cbfc09..b0e1eea 100644 --- a/frontend/src/components/Badge.tsx +++ b/frontend/src/components/Badge.tsx @@ -3,6 +3,6 @@ export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high return {label} severity; } -export function StatusBadge({ status }: { status: string }) { - return {status.replace(/_/g, " ")}; +export function StatusBadge({ status, label }: { status: string; label?: string }) { + return {label ?? status.replace(/_/g, " ")}; } diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 3694691..4198061 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { Link, useNavigate } from "react-router-dom"; import { api, ApiError } from "../api/client"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; @@ -95,7 +95,6 @@ export function ReturnForm({ suggestedOdometerKm?: number; }) { const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); - const odometerEditedByUser = useRef(false); const [fuel, setFuel] = useState("50"); const [cleanlinessOk, setCleanlinessOk] = useState(true); const [damageReported, setDamageReported] = useState(false); @@ -108,16 +107,6 @@ export function ReturnForm({ const [step, setStep] = useState<"capture" | "review">("capture"); const [preview, setPreview] = useState(null); - useEffect(() => { - // suggestedOdometerKm arrives asynchronously (BookingDetail fetches the vehicle's - // canonical odometer after this form has already mounted), so the initial useState - // value alone won't catch it -- sync it once it resolves. Never overwrite a value the - // visitor has already started typing. - if (suggestedOdometerKm !== undefined && !odometerEditedByUser.current) { - setOdometer(String(suggestedOdometerKm)); - } - }, [suggestedOdometerKm]); - function currentBody(): RegisterReturnRequest { return { end_odometer_km: Number(odometer), @@ -184,10 +173,7 @@ export function ReturnForm({ required min={0} value={odometer} - onChange={(e) => { - odometerEditedByUser.current = true; - setOdometer(e.target.value); - }} + onChange={(e) => setOdometer(e.target.value)} /> diff --git a/frontend/src/data/integrationLabels.ts b/frontend/src/data/integrationLabels.ts new file mode 100644 index 0000000..3125236 --- /dev/null +++ b/frontend/src/data/integrationLabels.ts @@ -0,0 +1,18 @@ +import type { IntegrationStatus } from "../api/types"; + +// Plain-language status per section 12 of the demo brief: a visitor shouldn't have to +// decode raw backend state strings to tell whether an integration is actually working. +// `statusClass` maps onto an existing `.status-*` CSS modifier so the badge keeps +// correct colour-coding; `label` is the human-readable text shown instead of the raw value. +export const N8N_STATE_META: Record = { + disabled: { statusClass: "not_configured", label: "Not connected" }, + unavailable: { statusClass: "unavailable", label: "Delivery failed" }, + degraded: { statusClass: "needs_attention", label: "Retry available" }, + operational: { statusClass: "available", label: "Operational" }, + no_evidence: { statusClass: "no_events", label: "Prepared" }, +}; + +export const MCP_STATE_META: Record = { + not_configured: { statusClass: "not_configured", label: "Not connected" }, + configured: { statusClass: "no_events", label: "Prepared" }, +}; diff --git a/frontend/src/pages/AboutDemo.tsx b/frontend/src/pages/AboutDemo.tsx index 5c2733e..e38d8f6 100644 --- a/frontend/src/pages/AboutDemo.tsx +++ b/frontend/src/pages/AboutDemo.tsx @@ -1,4 +1,6 @@ +import { Link } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoManifest } from "../context/DemoManifestContext"; import { Icon } from "../components/Icons"; import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; @@ -21,6 +23,7 @@ const INTEGRATION_ICON: Record = { export function AboutDemo() { const { manifest, loading } = useDemoManifest(); const { user } = useAuth(); + const { openGuide } = useDemoGuide(); return (
    @@ -38,6 +41,18 @@ export function AboutDemo() { {manifest && ( <> + {user?.role === "operations_manager" && ( +
    +
    + Liever meteen aan de slag? +

    De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.

    +
    + +
    + )} +

    Het fictieve probleem

    @@ -50,6 +65,17 @@ export function AboutDemo() {

    +
    +

    Voor wie en met welke scope

    +

    + Deze demo is bedoeld voor wie wil zien hoe MobilityOps operationele problemen bij + een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en + iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één + samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke + reservaties, geen volledig CRM of ERP. +

    +
    +

    Wat écht werkt

    @@ -72,6 +98,37 @@ export function AboutDemo() {

    +
    +

    Architectuur in het kort

    +

    + Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via + SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet + in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal + gecommit en pas daarna asynchroon via een outbox-patroon aan n8n afgeleverd, zodat + een tijdelijke storing in de automatisering nooit een operationele actie blokkeert. +

    +
    + +
    +

    Beveiliging en toegang

    +

    + Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol + gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de + navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd + en gelogd — nooit stilzwijgend automatisch gecorrigeerd. +

    +
    + +
    +

    Hoe dit getest is

    +

    + Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten; + een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief + deze demo-ervaring zelf. Elke wijziging wordt bovendien tegen een schone checkout + (lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment. +

    +
    +
    | null, after: Record(null); const [error, setError] = useState(null); - const [action, setAction] = useState(""); + const [action, setAction] = useState(searchParams.get("action") ?? ""); + const correlationId = searchParams.get("correlation_id") ?? ""; useEffect(() => { if (user?.role !== "operations_manager") return; @@ -32,11 +34,20 @@ export function Audit() { setError(null); const params = new URLSearchParams(); if (action) params.set("action", action); + if (correlationId) params.set("correlation_id", correlationId); api .get(`/api/v1/audit?${params.toString()}`) .then(setEvents) .catch(() => setError("Audit trail is unavailable right now.")); - }, [action, user]); + }, [action, correlationId, user]); + + function showRelatedEvents(id: string) { + setSearchParams({ correlation_id: id }); + } + + function clearCorrelationFilter() { + setSearchParams(action ? { action } : {}); + } if (user?.role !== "operations_manager") { return ( @@ -63,6 +74,15 @@ export function Audit() { + {correlationId && ( +

    + Showing only events linked to this action ({events?.length ?? "…"} related events).{" "} + +

    + )} + {error && } {!error && !events && } {events && events.length === 0 && } @@ -77,6 +97,7 @@ export function Audit() {
Action Entity ChangeFollow-up Details
{describeChanges(e.before, e.after)} + +
{e.correlation_id.slice(0, 8)} diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index d47727c..e48ffc7 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -4,14 +4,7 @@ import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/t import { StatusBadge } from "../components/Badge"; import { useAuth } from "../context/AuthContext"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; - -const N8N_STATE_LABEL: Record = { - disabled: "disabled", - unavailable: "unavailable", - degraded: "degraded", - operational: "available", - no_evidence: "no_events", -}; +import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels"; export function Automation() { const { user } = useAuth(); @@ -98,9 +91,38 @@ export function Automation() { : "Return events are committed locally first and then delivered through the outbox."}

- + {(() => { + const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; + return ( + + ); + })()} + +
+ +
+ Knowledge +

Knowledge assistant

+

+ {knowledge + ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.` + : "Health evidence is currently unavailable."} +

+
+
-
Knowledge

RAGcore

{knowledge ? `${knowledge.document_count} procedures indexed in ${knowledge.collection}.` : "Health evidence is currently unavailable."}

@@ -109,10 +131,13 @@ export function Automation() {

{integrationStatus?.mcp_hub.registration_enabled ? "Registration is enabled for this deployment." - : "No active MobilityOps adapter is configured in this proof of concept."} + : "Not yet connected — prepared for future controlled tool calls from the ITWorx MCP Hub."}

- + {(() => { + const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; + return ; + })()}
diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 866c8e2..1b7ec39 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -84,7 +84,14 @@ export function BookingDetail() { )} {returnResult && } - {!returnResult && booking.status === "active" && ( + {/* Wait for the scenario's canonical odometer before mounting the form at all when + this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct + from its very first render -- never updated asynchronously after mount, which + previously raced with anyone already typing into the field. */} + {!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && ( + + )} + {!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && ( = [ { key: "available", label: "Available", tone: "ready" }, @@ -178,24 +179,37 @@ export function Dashboard() { : "No workflow evidence recorded"} + {(() => { + const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; + return ( + + ); + })()} + +
  • + +
    + Knowledge assistant + {knowledge ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed` : "Health check unavailable"} +
  • -
  • RAGcore knowledge{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}
  • MCP Hub - {integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "No active adapter in this PoC"} + {integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}
    - + {(() => { + const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; + return ; + })()}
  • diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9ef3c3a..3bf8ac6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -231,6 +231,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } .about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } .about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; } +.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; } +.about-cta strong { display: block; color: var(--ink); font-size: .85rem; } +.about-cta p { margin: 2px 0 0; } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } .detail-grid div { min-height: 76px; padding: 13px 14px; background: white; } .detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; } @@ -249,6 +252,8 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } .result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.link-button { padding: 0; color: var(--teal-dark); background: transparent; border: 0; font-size: inherit; font-weight: 700; text-decoration: underline; cursor: pointer; } +.link-button:hover { color: var(--teal); } .rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); } .rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; } .rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } From 65835ea40a1293a904de2b0e922d32d610396979 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:02:37 +0200 Subject: [PATCH 12/21] docs: record Unraid deployment evidence for integration/audit/about batch --- PROJECT_STATE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index de4e22a..22490b5 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -733,6 +733,12 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem confirmed stable across three consecutive full runs (given how many timing races this batch and the previous one surfaced, stability was verified deliberately rather than assumed from a single green run). +- Deployed to Unraid (commit `5fa4fe0`): pushed to Gitea, tarball extracted, both `api` + and `web` rebuilt, healthy, migrations unchanged at `e7b08389f47f (head)`, reseeded. + Live-verified: ran `demo-legibility.spec.ts` (4), the five-minute demo script, and the + full `interactive-elements.spec.ts` suite (26) directly against + `http://192.168.10.150:1236` — **31/31 passed**. Reseeded afterward to leave the + server demo-ready. - Exact next action: full guided-demo Playwright test + remaining targeted demo tests per section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ reduced-motion/console/network checks) — task #35. From 07d5605812288113ad52ac353d2b8d2936f9e04a Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:12:29 +0200 Subject: [PATCH 13/21] test(demo): add full guided-demo walkthrough and targeted demo tests Adds one comprehensive Playwright test that walks a fresh Operations Manager session through all 8 Demo Guide steps performing the real action at each one, then restores the environment. Writing it surfaced a real desktop layout bug: the Demo Guide's fixed side panel overlapped main content with no reflow, making the return form's "Review return" button unclickable while the guide was open at ordinary viewport widths. Fixed by reserving layout space via a guide-open class. Also adds mobile bottom-sheet, keyboard-reachability, and console-error checks. --- PROJECT_STATE.md | 36 +++++++++ frontend/e2e/demo-accessibility.spec.ts | 87 ++++++++++++++++++++ frontend/e2e/guided-demo-full.spec.ts | 103 ++++++++++++++++++++++++ frontend/src/components/Layout.tsx | 4 +- frontend/src/styles.css | 5 ++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/demo-accessibility.spec.ts create mode 100644 frontend/e2e/guided-demo-full.spec.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 22490b5..8d58e27 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -742,3 +742,39 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem - Exact next action: full guided-demo Playwright test + remaining targeted demo tests per section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ reduced-motion/console/network checks) — task #35. + +### Batch 6 — full guided-demo test + targeted demo tests (complete) + +- **Found and fixed a real, fairly serious desktop layout bug** while writing the full + guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the + main content area at normal desktop widths with no reflow, so its own step-list buttons + intercepted pointer events meant for the page underneath (concretely: the return form's + "Review return" button was unclickable while the guide was open, at exactly the + viewport size Playwright's default test browser uses — this would have hit real + visitors on ordinary laptop screens too). Fixed by adding a `guide-open` class to + `.app-workspace` that reserves `padding-right: min(400px, 92vw)` while the guide is open + (≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside + instead of sitting underneath the panel. +- Added `frontend/e2e/guided-demo-full.spec.ts`: one comprehensive test walking a fresh + Operations Manager session through all 8 Demo Guide steps in order, performing the + **real** action at each step (not just verifying copy) — processes the actual + odometer-anomaly return, resolves the resulting data-quality issue, merges the + duplicate customer, asks a suggested knowledge question, checks automation + audit, + reviews the About page — using the guide's own progression controls + ("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the + demo data again at the end to restore the environment per the brief's requirement. +- Added `frontend/e2e/demo-accessibility.spec.ts` (4 tests): the guide renders as a + correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow; + the guide never covers the return form's action buttons on desktop (regression test for + the bug above); the demo badge and guide trigger are keyboard-focusable and operable + (Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about, + guide open) load with no unexpected console errors (the one expected benign 401 from + the app's own session-probe on first load is explicitly allow-listed, not silenced + blindly). +- Evidence: full Playwright suite **56 passed** (51 existing + 1 guided-demo-full + 4 + demo-accessibility), confirmed stable across two consecutive full runs. Backend + untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5). +- Exact next action: clean-checkout demo drill, final documentation set (demo-concept/ + demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid + deploy + live evidence with screenshots, `artifacts/demo-release/final-summary.md` — + task #36 (final). diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts new file mode 100644 index 0000000..78237f8 --- /dev/null +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("demo guide is usable as a mobile bottom sheet", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page).toHaveURL(/\/dashboard/); + + const panel = page.getByRole("dialog", { name: "Gegidste demo" }); + await expect(panel).toBeVisible(); + const box = await panel.boundingBox(); + expect(box).not.toBeNull(); + // A bottom sheet: anchored to the bottom of the viewport, not a full-height side panel. + expect(box!.height).toBeLessThan(800); + expect(box!.x).toBeLessThanOrEqual(1); + + await page.getByRole("button", { name: "Volgende" }).click(); + await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible(); + + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); +}); + +test("demo guide does not cover the return form's action buttons on desktop", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page).toHaveURL(/\/dashboard/); + await page.goto("/bookings/BK-DEMO-RETURN"); + + const reviewButton = page.getByRole("button", { name: "Review return" }); + await expect(reviewButton).toBeVisible(); + await reviewButton.click({ timeout: 5000 }); + await expect(page.getByText(/Expected fleet state/)).toBeVisible(); +}); + +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 expect(page).toHaveURL(/\/dashboard$/); + + const guideTrigger = page.getByRole("button", { name: /Demo-gids/ }); + await guideTrigger.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await page.keyboard.press("Escape"); + // The guide panel itself doesn't bind Escape (it's a persistent panel, not a transient + // popover), so close it explicitly the way a keyboard user would: activate its own + // close control. + await page.getByRole("button", { name: "Sluiten" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); + + const badgeTrigger = page.getByRole("button", { name: /Synthetische demo/ }); + await badgeTrigger.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeHidden(); +}); + +test("key demo pages load without console errors", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + // The app deliberately probes GET /demo/session on every load to confirm whether a + // session cookie is still valid (see AuthContext.tsx); a logged-out visitor's very + // first load always logs one benign 401 for this, which the app already handles via + // .catch() -- it is not an application error. + if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return; + errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await page.goto("/scenarios"); + await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); + await page.goto("/about"); + await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await page.getByRole("button", { name: /Demo-gids/ }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]); +}); diff --git a/frontend/e2e/guided-demo-full.spec.ts b/frontend/e2e/guided-demo-full.spec.ts new file mode 100644 index 0000000..72e296d --- /dev/null +++ b/frontend/e2e/guided-demo-full.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post("/api/v1/demo/reset"); + expect(reset.ok()).toBeTruthy(); +} + +test.describe.configure({ mode: "serial" }); + +test("full guided demo walkthrough, start to finish, restoring the environment after", async ({ + page, + request, +}) => { + await resetDemoData(request); + + await test.step("start the guided demo from the login screen", async () => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page).toHaveURL(/\/dashboard/); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + }); + + await test.step("step 1: understand the operational state", async () => { + await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); + await page.getByRole("button", { name: "Volgende" }).click(); + }); + + await test.step("step 2: open the booking needing attention", async () => { + await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible(); + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/); + await page.getByRole("button", { name: "Volgende" }).click(); + }); + + await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => { + await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); + await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); + await page.getByRole("button", { name: "Review return" }).click(); + await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); + await page.getByRole("button", { name: "Confirm return" }).click(); + await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + await page.getByRole("button", { name: "Ga verder met de demo" }).click(); + }); + + await test.step("step 4: handle the newly created data-quality issue", async () => { + await expect(page).toHaveURL(/\/data-quality$/); + await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible(); + const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a"); + await firstIssueLink.click(); + await expect(page.getByText("What's wrong")).toBeVisible(); + // The newest issue is the odometer regression this return just created. + await page.getByRole("radio", { name: /Retain canonical/ }).check(); + await page.getByRole("button", { name: "Resolve issue" }).click(); + await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); + await page.getByRole("button", { name: "Ga verder met de demo" }).click(); + }); + + await test.step("step 5: review and merge the possible duplicate customer", async () => { + await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); + await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible(); + await page.getByRole("button", { name: /^Merge into/ }).click(); + await page.getByRole("button", { name: "Yes, merge" }).click(); + await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); + await page.getByRole("button", { name: "Ga verder met de demo" }).click(); + }); + + await test.step("step 6: ask the procedure assistant a question", async () => { + await expect(page).toHaveURL(/\/knowledge$/); + await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible(); + await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click(); + await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await page.getByRole("button", { name: "Volgende" }).click(); + }); + + await test.step("step 7: check automation and the audit trail", async () => { + await expect(page.getByRole("heading", { name: "7. Controleer automatisering" })).toBeVisible(); + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/automation$/); + // Plain-language status only -- never the raw backend state string (e.g. "degraded"). + await expect(page.locator(".integration-cards")).not.toContainText("degraded"); + await expect(page.locator(".integration-cards")).not.toContainText("no_evidence"); + await page.goto("/audit"); + await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + await page.getByRole("button", { name: /Demo-gids/ }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + }); + + await test.step("step 8: review what's real, simulated, or not yet connected", async () => { + await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible(); + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible(); + await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); + }); + + await test.step("restore the environment", async () => { + await resetDemoData(request); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0a833ef..4f640b5 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -6,6 +6,7 @@ import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; import { DemoBadge } from "./DemoBadge"; import { DemoGuide, DemoGuideTrigger } from "./DemoGuide"; +import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { @@ -46,6 +47,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ export function Layout() { const { user, logout } = useAuth(); const { manifest } = useDemoManifest(); + const { open: guideOpen } = useDemoGuide(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -220,7 +222,7 @@ export function Layout() { {mobileOpen && {open && ( -
    -

    {manifest ? ( - <> - {manifest.organization_name} is een fictieve organisatie. Alle - namen, voertuigen en boekingen zijn synthetisch. - + }} /> ) : ( - "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch." + t("badge.orgIntroFallback") )}

    -

    - De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de - gegevens zijn verzonnen. -

    +

    {t("badge.realWorkflows")}

    {manifest && (

    - Laatste reset: {formatDateTime(manifest.last_reset_at)} · deze - omgeving is op elk moment herstelbaar. + }} + />

    )} setOpen(false)}> - Over deze demo + {t("badge.aboutLink")}
    )} diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx index 650d87d..55a7caf 100644 --- a/frontend/src/components/DemoGuide.tsx +++ b/frontend/src/components/DemoGuide.tsx @@ -1,13 +1,16 @@ -import { useNavigate } from "react-router-dom"; -import { useState } from "react"; +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 { 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"; export function DemoGuideTrigger() { + const { t } = useTranslation("demo"); const { user } = useAuth(); const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); @@ -22,17 +25,35 @@ export function DemoGuideTrigger() { onClick={toggleGuide} > - Demo-gids + {t("guide.trigger")} {completed.size}/{totalSteps} - , huidige stap {currentIndex + 1} + , {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })} ); } +function highlightTarget(selector: string | undefined) { + if (!selector) return; + const el = document.querySelector(selector); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const previousTabIndex = el.getAttribute("tabindex"); + if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1"); + el.focus({ preventScroll: true }); + el.classList.add("demo-guide-highlight"); + window.setTimeout(() => { + el.classList.remove("demo-guide-highlight"); + if (previousTabIndex === null) el.removeAttribute("tabindex"); + }, 2200); +} + export function DemoGuide() { + const { t } = useTranslation("demo"); const navigate = useNavigate(); + const location = useLocation(); const { logout } = useAuth(); const { manifest, refresh } = useDemoManifest(); + const tier = useViewportTier(); const { open, closeGuide, @@ -42,17 +63,48 @@ export function DemoGuide() { goToStep, completeAndAdvance, restart, + collapsedToChip, + setCollapsedToChip, } = useDemoGuide(); const [resetting, setResetting] = useState(false); const [resetError, setResetError] = useState(null); - - if (!open) return null; + const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); + const pendingTarget = useRef(null); const step = DEMO_GUIDE_STEPS[currentIndex]; const isLastStep = currentIndex === totalSteps - 1; + useEffect(() => { + if (!open) return; + function handleKeydown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + if (tier === "standard" && !collapsedToChip) { + setCollapsedToChip(true); + } else if (tier === "mobile" && mobileSheetState !== "collapsed") { + setMobileSheetState("collapsed"); + } else { + closeGuide(); + } + } + document.addEventListener("keydown", handleKeydown); + return () => document.removeEventListener("keydown", handleKeydown); + }, [open, tier, collapsedToChip, mobileSheetState, closeGuide]); + + useEffect(() => { + if (!pendingTarget.current) return; + const target = pendingTarget.current; + pendingTarget.current = null; + const raf = requestAnimationFrame(() => highlightTarget(target)); + return () => cancelAnimationFrame(raf); + }, [location.pathname]); + + if (!open) return null; + function goToStepRoute() { + pendingTarget.current = step.target ?? null; navigate(step.route(manifest)); + if (tier === "standard") setCollapsedToChip(true); + if (tier === "mobile") setMobileSheetState("collapsed"); } async function handleRestartDemo() { @@ -66,69 +118,131 @@ export function DemoGuide() { await logout(); navigate("/login"); } catch (err) { - setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden."); + setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed")); } finally { setResetting(false); } } - return ( -