diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 0463620..1127b46 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -640,3 +640,47 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
the server demo-ready.
- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5
data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
+
+### Batch 4 — return/data-quality/knowledge demo legibility (complete)
+
+- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the
+ retrieval-flow diagram even though the active provider is the demo TF-IDF one (the
+ small badge below was already honest, contradicting the prose one line above). Now
+ derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health
+ check and uses it everywhere; added an explicit disclosure note when not RAGcore.
+ Added 4 suggested-question chips. **Discovered and fixed a second real bug in the
+ process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording)
+ would have returned "insufficient evidence" against the demo provider, because the
+ indexed procedures are English-only — verified empirically (Dutch question →
+ `insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested
+ questions in English (matching the indexed content) and rewording the Guide step to
+ explain the knowledge base is English, rather than mistranslating the demo's
+ centerpiece feature into silently returning wrong answers.
+- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario
+ booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real
+ canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious
+ value below it, plus a callout explaining why — the brief explicitly requires the demo
+ not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one
+ scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to
+ Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de
+ demo" button when the Demo Guide is open (advances the guide and navigates to the next
+ step). **Fixed a real regression caught by the existing return-review e2e test**: the
+ async pre-fill could silently overwrite odometer text a visitor had already started
+ typing, if the vehicle-detail fetch resolved after they began typing — fixed with an
+ `odometerEditedByUser` ref guard.
+- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain
+ language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic
+ post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo")
+ for the 4 rule types that previously just silently flipped their status badge with no
+ explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation
+ with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox
+ filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new
+ business logic) so the curated issues are easy to find among the full queue.
+- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
+ **51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why-
+ suspicious explanation + result links, rule explainer visible, demo-scenario filter
+ narrows correctly, knowledge suggested question returns grounded evidence with the
+ correct provider label). Backend untouched this batch.
+- Exact next action: plain-language integration-status labels, richer audit narration,
+ the full "Over deze demo" page content (currently a first pass from Batch 2), and
+ wiring reset into the guide/About/OM menu narrative — task #34.
diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts
new file mode 100644
index 0000000..fb9f16d
--- /dev/null
+++ b/frontend/e2e/demo-legibility.spec.ts
@@ -0,0 +1,77 @@
+import { expect, test, type APIRequestContext } from "@playwright/test";
+
+async function resetDemoData(request: APIRequestContext) {
+ const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
+ expect(login.ok()).toBeTruthy();
+ const reset = await request.post("/api/v1/demo/reset");
+ expect(reset.ok()).toBeTruthy();
+}
+
+test.describe.configure({ mode: "serial" });
+
+test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
+ await resetDemoData(request);
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+ await page.goto("/bookings/BK-DEMO-RETURN");
+
+ await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
+ const odometerInput = page.getByLabel("End odometer (km)");
+ await expect(odometerInput).not.toHaveValue("");
+ const prefilled = Number(await odometerInput.inputValue());
+ expect(prefilled).toBeGreaterThan(0);
+
+ await page.getByRole("button", { name: "Review return" }).click();
+ await expect(page.getByText(/below.*canonical reading/)).toBeVisible();
+ await page.getByRole("button", { name: "Confirm return" }).click();
+ await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible();
+});
+
+test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+ await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
+
+ await expect(page.getByText("What's wrong")).toBeVisible();
+ await expect(page.getByText("Why it matters")).toBeVisible();
+ await expect(page.getByText(/likely the same person/)).toBeVisible();
+});
+
+test("data quality list can filter to demo scenarios only", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+ await page.goto("/data-quality");
+
+ await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
+ const allRows = await page.locator(".data-table tbody tr").count();
+ await page.getByRole("checkbox", { name: "Demo scenario's only" }).check();
+ const filteredRows = await page.locator(".data-table tbody tr").count();
+ expect(filteredRows).toBeGreaterThan(0);
+ expect(filteredRows).toBeLessThanOrEqual(allRows);
+ const refs = await page.locator(".data-table tbody tr th a").allTextContents();
+ for (const ref of refs) {
+ expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
+ }
+});
+
+test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+ await page.goto("/knowledge");
+
+ // The status badge and retrieval-flow diagram must name the actual active provider
+ // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
+ // allowed to mention RAGcore by name when explaining it isn't live yet.
+ await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base");
+ await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base");
+ await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
+
+ await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click();
+ await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
+});
diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx
index 1ef68bc..3694691 100644
--- a/frontend/src/components/ReturnForm.tsx
+++ b/frontend/src/components/ReturnForm.tsx
@@ -1,8 +1,11 @@
-import { useState, type FormEvent } from "react";
-import { Link } from "react-router-dom";
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router-dom";
import { api, ApiError } from "../api/client";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
+import { useDemoGuide } from "../context/DemoGuideContext";
+import { useDemoManifest } from "../context/DemoManifestContext";
+import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
@@ -14,7 +17,17 @@ function newIdempotencyKey(): string {
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { user } = useAuth();
+ const navigate = useNavigate();
+ const { manifest } = useDemoManifest();
+ const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeQualityIssue = user?.role === "operations_manager";
+
+ function continueDemo() {
+ completeAndAdvance();
+ const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
+ navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
+ }
+
return (
Committed locally
Return registered
@@ -58,9 +71,16 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
opened for review.
+ 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;