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.
+
Synthetic demo data · no real customer or vehicle information
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.
+
+ 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}
@@ -36,28 +45,39 @@ export function Login() {
-
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.
+ 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.
+
{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 && (
+
+ Ga verder met de demo
+
+ )}
+
+ 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.
+
De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.
+
+
+ Start begeleide demo
+
+
+ )}
+
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.
+
+ showRelatedEvents(e.correlation_id)}>
+ View related events
+
+
{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 (
+
+ );
+ })()}
+
+
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 && setMobileOpen(false)} />}
-
+
setMobileOpen(true)} aria-label="Open navigation">
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 3bf8ac6..930cb8e 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -327,6 +327,11 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.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; }
+@media (min-width: 701px) {
+ /* The guide panel is a fixed right-side overlay at this width -- push page content
+ aside while it's open so it never sits underneath and blocks actionable buttons. */
+ .app-workspace.guide-open { padding-right: min(400px, 92vw); }
+}
.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; }
From f04a81f6c7c4ee9046c2334a522fb37f96882d28 Mon Sep 17 00:00:00 2001
From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:14:20 +0200
Subject: [PATCH 14/21] docs: record Unraid deployment evidence for guided-demo
test batch
---
PROJECT_STATE.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 8d58e27..6ee5abb 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -774,6 +774,12 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
- 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).
+- Deployed to Unraid (commit `07d5605`): pushed to Gitea, tarball extracted, `web`
+ rebuilt (frontend-only), healthy, reseeded. Live-verified: ran
+ `guided-demo-full.spec.ts` and `demo-accessibility.spec.ts` directly against
+ `http://192.168.10.150:1236` — **5/5 passed**, confirming the desktop-overlay layout
+ fix holds on the real deployment too. Reseeded afterward to leave the server
+ demo-ready.
- 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` —
From 38f654b97a0b62b48f6dfc25221af8f62996122d Mon Sep 17 00:00:00 2001
From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:24:35 +0200
Subject: [PATCH 15/21] docs(demo): add demo concept, scenarios, data, guide
and runbook docs
Documents the demo-productization work from this branch: the Northstar
Mobility fictional concept and scope, the five named scenarios and their
fixed records, the seed/date-anchoring strategy (including the real bug
it fixed), the in-app Demo Guide's design and the English-suggested-
questions decision, and an operational runbook covering 5/10-minute demo
flows, reset, Unraid redeploy and rollback. Updates README with current
test counts and pointers to the new docs.
---
README.md | 18 +++++-
docs/17-runbook.md | 4 ++
docs/demo-release/demo-concept.md | 77 +++++++++++++++++++++++
docs/demo-release/demo-data.md | 83 ++++++++++++++++++++++++
docs/demo-release/demo-guide.md | 71 +++++++++++++++++++++
docs/demo-release/demo-runbook.md | 97 +++++++++++++++++++++++++++++
docs/demo-release/demo-scenarios.md | 71 +++++++++++++++++++++
7 files changed, 419 insertions(+), 2 deletions(-)
create mode 100644 docs/demo-release/demo-concept.md
create mode 100644 docs/demo-release/demo-data.md
create mode 100644 docs/demo-release/demo-guide.md
create mode 100644 docs/demo-release/demo-runbook.md
create mode 100644 docs/demo-release/demo-scenarios.md
diff --git a/README.md b/README.md
index c03e484..0a1b3cc 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,17 @@ design decision and visual evidence.
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
+## Demo
+
+The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van
+rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
+guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
+all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
+explicit without any verbal explanation. See `docs/demo-release/` for the full demo
+concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
+design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
+rollback).
+
## Scope
The PoC implements:
@@ -77,6 +88,9 @@ and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
+- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
+ tour, and runbook.
+- `artifacts/demo-release/` — demo-productization acceptance evidence.
## Quickstart
@@ -100,9 +114,9 @@ All defaults are configurable via `.env` (see `.env.example`).
## Quality gates
```bash
-make test # backend: pytest (117 tests)
+make test # backend: pytest (127 tests)
make lint # backend: ruff + mypy (strict, zero errors)
-make e2e # frontend: Playwright end-to-end (37 tests, live stack required)
+make e2e # frontend: Playwright end-to-end (56 tests, live stack required)
```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
diff --git a/docs/17-runbook.md b/docs/17-runbook.md
index 8a56b8c..8314167 100644
--- a/docs/17-runbook.md
+++ b/docs/17-runbook.md
@@ -1,5 +1,9 @@
# PoC runbook
+For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
+redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
+This document covers general environment bootstrap and n8n setup.
+
## Bootstrap (clean checkout)
```bash
diff --git a/docs/demo-release/demo-concept.md b/docs/demo-release/demo-concept.md
new file mode 100644
index 0000000..a46e52b
--- /dev/null
+++ b/docs/demo-release/demo-concept.md
@@ -0,0 +1,77 @@
+# Demo concept
+
+## The fictional problem
+
+**Northstar Mobility** is a fictional Belgian camper/van rental company with roughly 50
+vehicles, one main location, a rental team, an operations manager, and a small workshop.
+Northstar Mobility does not exist — this name and every customer, vehicle, booking and
+procedure in the demo are synthetic. Before MobilityOps, Northstar Mobility's bookings,
+returns, customer records and maintenance history lived in spreadsheets and verbal
+hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked
+vehicles) only surfaced late, after they had already caused friction. MobilityOps shows
+how one connected system detects these problems early and lets an Operations Manager
+resolve them under audit, with automation handling the routine follow-up.
+
+## Target audience
+
+Anyone evaluating how MobilityOps approaches operational data-quality and hand-off
+problems for a small rental fleet: Operations Managers, Rental Employees, and reviewers
+assessing the approach. No account setup or backend knowledge is required to start —
+the login screen explains the fictional context and offers a guided path.
+
+## Demo scope
+
+This is a focused proof of concept, not a full ERP. In scope: vehicle and booking
+management, return processing with server-side evaluation, five data-quality detection
+rules each with one bounded resolution flow, a full audit trail, and orchestration via a
+real n8n instance. Explicitly out of scope: accounting, payments, public reservations, a
+generic CRM, inventory, HR, a second RAG stack, or autonomous write actions from any
+external tool.
+
+## What's really implemented
+
+All of the following is functional code, not a mockup or hardcoded screen:
+
+- Role-based authentication and server-verified sessions (Operations Manager, Rental
+ Employee), enforced both server-side and in the frontend's navigation/route guards.
+- Vehicle and booking management, with return preview/commit sharing one authoritative
+ evaluation function so they can never drift.
+- Five data-quality rules (possible duplicate customer, missing required field, odometer
+ regression, booking overlap, vehicle status conflict), each with exactly one bounded,
+ audited resolution endpoint.
+- A full audit trail with before/after state, actor, correlation IDs, and a
+ "view related events" link reusing that correlation.
+- Reliable outbox-based delivery to a real n8n instance, with bounded retries and
+ stale-delivery recovery.
+- Docker Compose deployment and an automated test suite (backend pytest, Ruff, mypy,
+ and a full Playwright end-to-end suite covering the demo experience itself).
+- A demo manifest (`GET /api/v1/demo/manifest`) as the single source of truth for the
+ demo's identity, synthetic-data status, reset state, scenario readiness, and honest
+ per-integration labels — the frontend never hardcodes what mode it's in.
+
+## What's synthetic
+
+The organisation, all customers, vehicles, bookings, maintenance history, the knowledge
+base's procedures, and the five prepared demo scenarios are entirely fictional. No data
+refers to a real person, vehicle, or company. Every seeded email uses the `.test`
+reserved domain. See [`demo-data.md`](demo-data.md) for exactly how this data is
+generated and kept fresh across resets.
+
+## What's not yet live-connected
+
+- **RAGcore**: not connected. The knowledge assistant uses a local, deterministic
+ keyword-matching "demo knowledge base" built from five procedure documents, honestly
+ labelled as such everywhere in the UI (never presented as RAGcore). A
+ `RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
+ the same interface once a real RAGcore backend is available — swapping providers is a
+ configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
+- **ITWorx MCP Hub**: not connected. Registration is disabled by default
+ (`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
+ never a fabricated successful registration.
+
+## Where to go next
+
+- [`demo-scenarios.md`](demo-scenarios.md) — the five guided scenarios and their fixed records.
+- [`demo-data.md`](demo-data.md) — seed strategy and date-anchoring.
+- [`demo-guide.md`](demo-guide.md) — the in-app 8-step guided tour.
+- [`demo-runbook.md`](demo-runbook.md) — how to run, reset, and redeploy the demo.
diff --git a/docs/demo-release/demo-data.md b/docs/demo-release/demo-data.md
new file mode 100644
index 0000000..c454478
--- /dev/null
+++ b/docs/demo-release/demo-data.md
@@ -0,0 +1,83 @@
+# Demo data strategy
+
+## Three separate concepts
+
+It's important to keep these distinct — they solve different problems:
+
+1. **Deterministic records** — every seeded entity has a stable public reference
+ (`BK-DEMO-RETURN`, `DQ-DEMO-DUPLICATE`, `MO-024`, `CUS-0012`, ...). These references
+ never change between seed generations or resets; they are what the five demo
+ scenarios, the Demo Guide, and `docs/13-seed-and-demo-scenarios.md` all link against.
+2. **Date anchoring** — the mechanism that keeps "today"/"near-future"/"currently
+ overlapping" scenarios true to whenever the environment was actually last reset,
+ described below.
+3. **The reset date** — the real wall-clock moment a reset actually happened. This
+ changes every time someone resets; it's the input to date anchoring, not a fixed
+ record.
+
+## Generating the seed dataset
+
+```bash
+python seed/generate_seed.py --anchor 2026-08-01 --seed 20260801
+```
+
+This produces the committed CSVs in `seed/*.csv` with **absolute ISO timestamps**
+authored relative to a fixed anchor date (`2026-08-01`). Target scale: 50 vehicles, 180
+customers (including three duplicate pairs), ~245 historical/current/future bookings,
+realistic inspections and maintenance history, and the fixed quality/workflow scenarios
+described in [`demo-scenarios.md`](demo-scenarios.md). Names, towns and vehicle makes are
+drawn from believable Flemish/Kempen-region pools; all emails use the `.test` domain.
+
+## Date anchoring
+
+`backend/app/seed_loader.py` defines:
+
+```python
+SEED_AUTHORED_ANCHOR = date(2026, 8, 1) # matches generate_seed.py's --anchor
+```
+
+Every seed/reset computes `shift = today - SEED_AUTHORED_ANCHOR` and applies it to every
+seeded booking, inspection, maintenance and outbox timestamp before insertion. Public
+references and entity relationships are never touched by the shift — only datetime
+columns move. This means:
+
+- `BK-DEMO-RETURN` always ends "today" (or very close to it) relative to whenever you
+ actually reset, not relative to the frozen 2026-08-01 authoring date.
+- `BK-DEMO-NEXT` and the overlap-scenario bookings always read as "near future".
+- The shift is recomputed fresh on every reset, so scenarios never decay as real time
+ passes between resets — this was a real, confirmed bug before this fix (see
+ `docs/demo-release/current-demo-gap-audit.md`, gap #3): the environment would drift
+ further out of sync with every day it wasn't reset, and a reset didn't fix it because
+ nothing re-anchored the underlying stored dates.
+
+`load_seed()` returns the resolved `anchor_date` (real today) and `seeded_at` timestamp,
+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 via
+the audit trail.
+
+`dashboard.py::_today()` uses real wall-clock UTC date (not a frozen setting) to filter
+"today's movements", consistent with the shifted data.
+
+## Reset
+
+`POST /api/v1/demo/reset` (Operations Manager only, and only if `DEMO_ALLOW_RESET=true`)
+clears all MobilityOps tables, reloads the seed with a fresh date shift, re-runs the
+data-quality scan, and runs a server-side **scenario-integrity check**
+(`scenario_integrity_report()` in `backend/app/services/demo_manifest.py`) confirming all
+five named scenarios are actually present and ready — recorded in both the response body
+and the `demo_reset` audit event's metadata. Reset only ever affects MobilityOps's own
+tables; it never touches shared n8n, RAGcore, or MCP data, other containers, or volumes.
+
+## Seed-validation tests
+
+`backend/tests/test_seed.py` proves, after every reset:
+
+- S1 (`BK-DEMO-RETURN`/`MO-024`) is active with no end odometer recorded yet.
+- S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`) is open with matching evidence.
+- S4 (`MO-016`/`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) genuinely overlaps in time.
+- S5 (the seeded failed outbox event) is durably `failed` immediately after reset, not
+ silently auto-healed by the background dispatcher (which only claims `pending` rows).
+- The date-anchoring shift and the `demo_data_seeded` audit marker are both correct.
+
+`backend/tests/test_demo_manifest.py` additionally proves that all five manifest
+scenarios report `ready: true` with no `blocked_reason` right after a fresh reset.
diff --git a/docs/demo-release/demo-guide.md b/docs/demo-release/demo-guide.md
new file mode 100644
index 0000000..39d355f
--- /dev/null
+++ b/docs/demo-release/demo-guide.md
@@ -0,0 +1,71 @@
+# The in-app Demo Guide
+
+## What it is (and isn't)
+
+The Demo Guide is a compact, reusable side panel (a bottom sheet on mobile) that walks an
+Operations Manager through eight fixed steps covering the demo's core functionality. It is
+**not** a generic tutorial engine and **not** a source of business logic — every action it
+prompts is a real action against the real API; the guide only narrates, links, and tracks
+progress. It duplicates no business logic: routes for the two scenario-backed steps
+(return, duplicate-merge) are resolved from the live `GET /api/v1/demo/manifest` response
+rather than hardcoded, so they can never point at a stale or missing record.
+
+## Where it lives
+
+- `frontend/src/data/demoGuideSteps.ts` — the eight step definitions (title, what you'll
+ see, why it matters, the exact start action, the expected outcome, and a `route()`
+ function).
+- `frontend/src/context/DemoGuideContext.tsx` — open/close state and step progress,
+ persisted to `sessionStorage` only (browser-local, never touches auth or business
+ state; a "Demo opnieuw voorbereiden" restart or a new browser session starts fresh).
+- `frontend/src/components/DemoGuide.tsx` — the panel itself and its topbar trigger
+ (`DemoGuideTrigger`, Operations-Manager-only, since all eight steps require that role).
+
+## The eight steps
+
+1. **Understand the operational state** — the dashboard's readiness and attention queue.
+2. **Open the booking needing attention** — `BK-DEMO-RETURN`, resolved from the manifest.
+3. **Process the return with an odometer anomaly** — same booking; the return form is
+ pre-filled with the suspicious reading (see [`demo-scenarios.md`](demo-scenarios.md)
+ scenario 1).
+4. **Handle the created data-quality issue** — the fresh odometer-regression issue that
+ step 3 just created.
+5. **Review and merge the possible duplicate customer** — `DQ-DEMO-DUPLICATE`.
+6. **Ask the procedure assistant a question** — one of the suggested questions.
+7. **Check automation and the audit trail**.
+8. **Review what's real, simulated, or not yet connected** — the About page.
+
+## How progression works
+
+- **"Ga naar deze stap"** navigates to the step's resolved route without marking it done.
+- **"Volgende"** marks the current step complete and advances the index (used for steps
+ with no dedicated in-page continuation, like the knowledge-assistant step).
+- Several real actions (a successful return, a resolved data-quality issue) show their
+ own **"Ga verder met de demo"** button that both completes the current step and
+ navigates straight to the next one — this is how the guide chains through steps 3→4→5
+ without a detour back through the panel's own controls.
+- The step list on the panel lets you jump directly to any step.
+- **"Demo opnieuw voorbereiden"** calls the real reset endpoint, resets the guide's own
+ progress, and returns to the login screen — mirroring the existing sidebar reset
+ control, not a separate implementation.
+
+## A known, deliberate limitation: suggested questions stay in English
+
+The demo knowledge base's five procedure documents (`knowledge/procedures/*.md`) are
+written in English. Verified empirically while building step 6: an equivalent Dutch
+question returned `insufficient` evidence against the demo provider, while the English
+original returned `grounded`. Rather than mistranslate the demo's centerpiece "grounded
+answer" feature into something that silently answers incorrectly, the suggested questions
+on `/knowledge` and this guide's step 6 instructions stay in English, with the guide
+explicitly explaining why in Dutch. Retranslating the procedure documents themselves was
+judged out of scope for a demo-productization pass (see the language-split note in
+`docs/demo-release/current-demo-gap-audit.md`, gap #11).
+
+## Layout note: the panel reserves space, it doesn't overlap content
+
+On desktop the panel is a fixed 400px-wide right-side overlay. `Layout.tsx` adds a
+`guide-open` class to the main workspace while the guide is open, which reserves
+`padding-right` so page content reflows aside instead of sitting underneath the panel —
+this was a real bug found and fixed while building the full guided-demo Playwright test
+(see `PROJECT_STATE.md`, Batch 6): without it, the return form's "Review return" button
+was unclickable while the guide was open at ordinary desktop widths.
diff --git a/docs/demo-release/demo-runbook.md b/docs/demo-release/demo-runbook.md
new file mode 100644
index 0000000..deff09a
--- /dev/null
+++ b/docs/demo-release/demo-runbook.md
@@ -0,0 +1,97 @@
+# Demo runbook
+
+## Starting the demo (any environment)
+
+Open the deployed URL (Unraid review: `http://192.168.10.150:1236`; local:
+`http://localhost:1228`). The login screen names the fictional organisation, states that
+all data is synthetic and all workflows are really implemented, and offers:
+
+- **Start begeleide demo** — logs in as Operations Manager and opens the Demo Guide at
+ step 1.
+- **Verken als Operations Manager** / **Verken als Rental Employee** — free exploration,
+ no guide.
+
+No password is shown or required for either path.
+
+## Five-minute demo (guided)
+
+1. Click **Start begeleide demo**.
+2. Follow steps 1–3: dashboard → the booking needing attention → confirm the pre-filled
+ odometer-anomaly return.
+3. Step 4: resolve the data-quality issue the return just created (any decision is fine
+ for a quick pass — "Retain canonical" is the fastest).
+4. Step 6: ask a suggested knowledge question and show the cited source.
+5. Step 7: show the automation/audit trail link-through.
+6. Close with step 8, the About page's honest real/synthetic/not-connected breakdown.
+
+## Ten-minute demo (guided + one extra scenario)
+
+Do the five-minute path above, then from `/scenarios`:
+
+- Run **scenario 2** (duplicate customer merge) if not already done via the guide's own
+ step 5.
+- Run **scenario 3** (booking overlap) — `/data-quality`, resolve `DQ-DEMO-OVERLAP`.
+- Run **scenario 4** (failed automation retry) — `/automation`, filter to failed, retry.
+
+All five scenarios can be run in any order and are independent of each other.
+
+## Resetting the environment
+
+Any Operations Manager can reset from: the sidebar ("Reset demo data"), the Demo Guide
+panel ("Demo opnieuw voorbereiden"), or the About page (points to the sidebar control).
+Reset requires confirmation, rebuilds the deterministic dataset with a fresh date anchor,
+runs a server-side scenario-integrity check, and signs the acting session out (the server
+invalidates the session as part of reset). It only ever touches MobilityOps's own tables
+— never shared n8n, RAGcore, or MCP data, other containers, or volumes. It can be
+disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be rebuildable.
+
+## Redeploying to Unraid
+
+```bash
+# From a clean local checkout on the target branch/commit:
+git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
+scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-.tar.gz
+ssh unraid "cd /mnt/user/appdata/mobilityops \
+ && tar -xzf .deploy/source-.tar.gz \
+ && echo > .deploy/source-revision"
+
+# Rebuild only what changed (api and/or web); db is never rebuilt:
+ssh unraid "cd /mnt/user/appdata/mobilityops \
+ && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web"
+
+# Confirm migrations and reseed:
+ssh unraid "cd /mnt/user/appdata/mobilityops \
+ && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \
+ && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
+```
+
+Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n
+data) — the tarball never contains `.env` since it's gitignored. Never edit source
+directly on the server; never deploy uncommitted changes.
+
+## Rollback
+
+`.deploy/source-revision` on the server records exactly which commit is live. Prior
+source tarballs remain in `.deploy/` for rollback: extract an earlier
+`source-.tar.gz`, update `source-revision` to match, and re-run the rebuild
+step above. Database rollback is out of scope for this demo (migrations are additive;
+there has been no destructive migration on this branch).
+
+## Server safety (Unraid)
+
+Only touch the `mobilityops` Compose project's own `api`/`web` services (and `db` only
+via migrations, never manually). Never stop other containers, run `docker system prune`,
+delete unrecognised images/networks/volumes, delete the MobilityOps database, overwrite
+the server `.env`, print secrets, start a second permanent n8n instance, or activate
+guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally.
+
+## Troubleshooting
+
+- **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved
+ since the last reset (expected once you've worked through it) — reset to restore it.
+- **Knowledge question returns "insufficient evidence"**: check the question is in
+ English and close to one of the suggested phrasings — the demo knowledge base is
+ English-only (see `demo-guide.md`).
+- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s
+ filter and retry — bounded retries mean it should self-heal within
+ `n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
diff --git a/docs/demo-release/demo-scenarios.md b/docs/demo-release/demo-scenarios.md
new file mode 100644
index 0000000..02cff0e
--- /dev/null
+++ b/docs/demo-release/demo-scenarios.md
@@ -0,0 +1,71 @@
+# Demo scenarios
+
+Five fixed scenarios are surfaced at `/scenarios` and via `GET /api/v1/demo/manifest`'s
+`scenarios` array, which derives each scenario's `ready`/`blocked_reason` from the actual
+underlying records — never hardcoded. All five are re-findable, using the same public
+references, after every demo reset (see [`demo-data.md`](demo-data.md)).
+
+## 1 — Odometer regression on return
+
+- **Fixed records**: booking `BK-DEMO-RETURN` on vehicle `MO-024`.
+- **Role**: Rental Employee or Operations Manager.
+- **Problem demonstrated**: a return is submitted with an odometer reading lower than the
+ vehicle's canonical reading — usually a data-entry mistake or a return logged against
+ the wrong vehicle.
+- **Flow**: open the booking. The return form is pre-filled with a suspicious reading
+ below `MO-024`'s canonical odometer (the demo never asks a visitor to invent one), with
+ a callout explaining why. Review the server-evaluated preview, then confirm. The
+ canonical odometer is never silently lowered; a data-quality issue is opened
+ automatically and the outcome is queued for delivery to n8n.
+- **Continue**: the result panel links to the vehicle, automation status, and audit
+ trail, plus (inside the guided tour) a "Ga verder met de demo" button.
+
+## 2 — Possible duplicate customer
+
+- **Fixed records**: customers `CUS-0012` and `CUS-0178` (same email/phone), issue
+ `DQ-DEMO-DUPLICATE`.
+- **Role**: Operations Manager (merge is a bounded, audited action).
+- **Problem demonstrated**: two customer profiles that are very likely the same person,
+ registered twice — a risk for split booking history and duplicate contact.
+- **Flow**: open the issue, compare both profiles field by field, choose which survives,
+ and merge. The losing profile becomes a tombstone linked to the survivor; its bookings
+ are rewired. The result is recorded in the audit trail.
+
+## 3 — Overlapping bookings
+
+- **Fixed records**: vehicle `MO-016`, bookings `BK-DEMO-OVERLAP-A`/`BK-DEMO-OVERLAP-B`,
+ issue `DQ-DEMO-OVERLAP`.
+- **Role**: Operations Manager.
+- **Problem demonstrated**: the same vehicle committed to two overlapping reservations —
+ a legacy-import-style conflict that a normal booking command would reject outright.
+- **Flow**: open the issue, choose which of the two bookings to block; the other keeps
+ its current status.
+
+## 4 — Failed automation, retried
+
+- **Fixed record**: a seeded outbox event (`00000000-0000-4000-8000-000000000020`,
+ `BK-H-0020`) that is durably `failed` immediately after every reset — a safe, simulated
+ connection error, not a deliberately destabilised n8n configuration.
+- **Role**: Operations Manager.
+- **Problem demonstrated**: bounded retries and visible failure/recovery state for
+ workflow delivery, rather than a silent drop.
+- **Flow**: open Automation, filter to failed deliveries, retry the event; it moves out
+ of the failed filter once delivered.
+
+## 5 — Grounded procedure question
+
+- **Role**: Rental Employee or Operations Manager.
+- **Problem demonstrated**: an operational question gets an answer with a citation from
+ the demo knowledge base — or an honest "insufficient evidence" if nothing indexed
+ answers it — never an invented answer.
+- **Flow**: open Knowledge, click one of the suggested questions (English, matching the
+ indexed procedure content — see the note in [`demo-guide.md`](demo-guide.md) about why
+ suggested questions stay in English even though the rest of the demo narrates in
+ Dutch), and inspect the cited source and evidence state.
+
+## A sixth scenario exists but isn't one of the five requested
+
+`docs/13-seed-and-demo-scenarios.md`'s **S3** ("missing inspection before next booking",
+vehicle `MO-031`) is also seeded and shows up in the attention queue; it isn't surfaced
+on `/scenarios` because the brief names five specific scenarios, but it remains available
+for anyone exploring the attention queue directly.
From a5024f7190db326eaa4b314b1b9ea425f489532c Mon Sep 17 00:00:00 2001
From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:29:53 +0200
Subject: [PATCH 16/21] docs(demo): add demo-release evidence screenshots and
capture tooling
One-off Playwright script (excluded from the regular suite) capturing
the demo entry (desktop+mobile), dashboard with scenarios, Demo Guide,
return preview/result, data-quality resolution, duplicate-customer
merge, knowledge assistant, integration status, automation retry,
audit trail + related-events, About page, demo badge popover, and
reset confirmation -- captured live against the Unraid deployment.
---
.../screenshots/01-demo-entry-desktop.png | Bin 0 -> 81200 bytes
.../screenshots/02-demo-entry-mobile.png | Bin 0 -> 45206 bytes
.../03-dashboard-with-scenarios.png | Bin 0 -> 193669 bytes
.../screenshots/04-demo-guide.png | Bin 0 -> 119959 bytes
.../screenshots/05-return-preview.png | Bin 0 -> 106969 bytes
.../screenshots/06-return-result.png | Bin 0 -> 87316 bytes
.../07-data-quality-resolution.png | Bin 0 -> 109817 bytes
.../08-duplicate-customer-merge.png | Bin 0 -> 138309 bytes
.../screenshots/09-knowledge-assistant.png | Bin 0 -> 114015 bytes
.../screenshots/10-integration-status.png | Bin 0 -> 215835 bytes
.../screenshots/11-automation-retry-after.png | Bin 0 -> 160918 bytes
.../11-automation-retry-before.png | Bin 0 -> 162566 bytes
.../screenshots/12-audit-trail.png | Bin 0 -> 177055 bytes
.../screenshots/13-audit-related-events.png | Bin 0 -> 60120 bytes
.../screenshots/14-about-demo.png | Bin 0 -> 271989 bytes
.../screenshots/15-demo-badge-popover.png | Bin 0 -> 148685 bytes
.../screenshots/16-reset-confirm.png | Bin 0 -> 104670 bytes
.../e2e/_capture-demo-screenshots.spec.ts | 94 ++++++++++++++++++
18 files changed, 94 insertions(+)
create mode 100644 artifacts/demo-release/screenshots/01-demo-entry-desktop.png
create mode 100644 artifacts/demo-release/screenshots/02-demo-entry-mobile.png
create mode 100644 artifacts/demo-release/screenshots/03-dashboard-with-scenarios.png
create mode 100644 artifacts/demo-release/screenshots/04-demo-guide.png
create mode 100644 artifacts/demo-release/screenshots/05-return-preview.png
create mode 100644 artifacts/demo-release/screenshots/06-return-result.png
create mode 100644 artifacts/demo-release/screenshots/07-data-quality-resolution.png
create mode 100644 artifacts/demo-release/screenshots/08-duplicate-customer-merge.png
create mode 100644 artifacts/demo-release/screenshots/09-knowledge-assistant.png
create mode 100644 artifacts/demo-release/screenshots/10-integration-status.png
create mode 100644 artifacts/demo-release/screenshots/11-automation-retry-after.png
create mode 100644 artifacts/demo-release/screenshots/11-automation-retry-before.png
create mode 100644 artifacts/demo-release/screenshots/12-audit-trail.png
create mode 100644 artifacts/demo-release/screenshots/13-audit-related-events.png
create mode 100644 artifacts/demo-release/screenshots/14-about-demo.png
create mode 100644 artifacts/demo-release/screenshots/15-demo-badge-popover.png
create mode 100644 artifacts/demo-release/screenshots/16-reset-confirm.png
create mode 100644 frontend/e2e/_capture-demo-screenshots.spec.ts
diff --git a/artifacts/demo-release/screenshots/01-demo-entry-desktop.png b/artifacts/demo-release/screenshots/01-demo-entry-desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..b5f6a9bf563baa2c9bed3613d3bc2d0459495f12
GIT binary patch
literal 81200
zcmeFZRa;z5um(C12myiw0t8Qh;O;03JkXv|Y5A)AD
z4$y(kE!(E$ziEC2fq(;+gPdLT$2iFsn%@s{PFD$@bvCw`s7K^%fHyvBrR2XY}w^lOJk!&1~!ab&}~3
z(WAc0BfN38270M$PWP>oGa{ebm8vFbQ?QyKaEcq3z`q_|k4rY&p0z%Yf+hKN{z
zrB2iKwB90e*de%TL~D4MX;XWbYYa*dy`8jW6lXeOQ@hJxRj5sAq#Hx-Wsy~1)wTo5
zM~O_OB<8g$A3@`QKL06GY#dq0x#j#vZt4YM75RXOeZx=gdSBHvep3QJ3)P2A
zI!DdA4u~*x@{eW35PK}mG>Z~F1U>Q@aF
zWi#J7&COIQAj@T{zr}2xS`WXg06-B6l0G+21mQ%5jTIJ+ZF>Cozr#N?!9w#7OI*%4
z#kIrWLTjzhsf!|+_o1Tm1v+DzB2?h5jDd;404=Im{e8
z@UEQU1ld+kUawfArIC-^(WR@4m}K*w+}3EGQz@1U9gWS0ifLia1vok;PH?9AZftsO
z_*U|8#XSwAT}jFy4ss?mC2!5BwP$`FsQJ;I_~svZKmN}E7ylXOj@1W6gZn?}foiXf@KWSHUr3GzW6LJQ>AbWi`#Cqz$>{ZeMyLoKN&nj8`rvLesIgf)
z@qQ27sQYfNgU3Pz)M9SEnjzzHQP#6SsX26Z^g>dx&zAvBF7D3lcm~`D8a%xB6)8c1
z8qag;$JTc-9_eGtdOb9+QgBB^y$cv@FPyAtYK4okpA#nyK*qU$jRdeN0G@H?lI~a
z+qyDu@yvPYb`j&d_mRIrp3(nVWY{^+DZ9$1Uv$ur)p?#?4e|)xHeIi;&y3{6fta(>
z8hv$7n)7E%wd>0v+0$Z)ss2UFTIZ~<77gFCbK{Tj5Le-p
z%UC0}nMpiQ{$uSS{WPaOcjeUY#IA$cFpX&zD^t%?>3-CZ>gT<;EbILLDHgIg%whTY
zpY$KSCKC$jUq?mqYHge%iqtLK{?1DgGd|tVMO40wvdh4qUg0nnGdbHq!8DHUn<_QL
znd>pDcpgCxjW8mBmPLI>2aJC|<4{J0tvl66^XGMN|9TkGOCnbckNfKzLFP>Hw00l|
z#_W@ha3W-Tay}Nc^W#`NIr#k(N%z}-+xLt90&tO2D^t)LjFbAhZkP+c(|3{6H8BDH
z5}hmowjJ)f-v*+I@uf3aC^BAZQQrP`=2?HK^KTy~z*n84gCfhHeZR@3*V!JTXZTwF
zHDY+WIrB7Z6LRanCbnMHJQ=NF=~(%u$$a}eabaoC$K3Eg8EpLoEa~fdpZl{!?DG8b
zvUijc`fB&M{qG~-&l{vaL8q6EB4r-COz~Ng%
z^W9odTzz3(yZPk#kr-P?<#L>nN1L?_v658~YTVYSwWGVgJK~pxXx;BNzxkp4!7>`y
z_uDNt&7i>86z3u?t(P2`w(P-pMH+_l$t)waHCl0DD(jOrJfD3IN~2wMj5W*;59>#l
zOvmP!qbe4iZ^`ZWjh9P#V#p)Tc`9Dc-=}OcL4xLvumJ$yg#QhDIDcHpxr=RX6ki`M
zf;WEzCvsWdaTSLa$5Ho^H&V4M7}U(ft7Ilei`3Khxd(6*%4&r%oHaOYVRF>DeoT$X
zoIiJyt*iK)$eZzSw}wtbs%y5k86IreA;oc(Zo}epxlj<^Gfmy3WqwpqUwK#~-|!le
zkv(`-U{51Umk12h22IA1)QpO>n-LTKr(Gaa*(@+9E_KyzAhP4p$B#34jx||OWVSmr
z+V1A;-LCU8HWp1{Y+c9ZvMtf<%7{}>Ovvn=;rRHto3xyh*$XLl|Q!<9&+F1=;^D$lvL>nh_$Y^|jC2@_J
zvhaRC%h9|`-_*Os-9?a=W4Uy5li4!5Cl2+9x8?k&a~rUA8th#4v+G$CU$!)7Bq#?z
z337jTBX>A+ySF{u+;(~MGj;8sxHV~2{2sfq9o29IJhhP{kLQElZsS&xte!BOW{s!R
z9u|h%Sk@5uH*U4x04hv*?*yf(!7G$s>f>fP#2(=B)
z^bN{-4&G=jw4^e+&SG(nV3sw56IB&VjI=rXT&_+#@Vt-fs1I-KIhMJ>E)5E*{XAY4
z#7W5^D>hB;prB2LEG2V!PFBCU8C