fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers

Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 17:44:32 +02:00
co-authored by Claude Sonnet 5
parent 3808bbe132
commit 4faac24b5a
21 changed files with 349 additions and 132 deletions
+8 -59
View File
@@ -15,6 +15,8 @@ import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { describeEvidenceSignal } from "../data/evidenceSignals";
import type { EvidenceSignal } from "../api/types";
import { Icon } from "../components/Icons";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
@@ -47,74 +49,21 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
);
}
interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
// The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). This is the one place that
// turns them into the operator's selected language; the legacy `evidence.summary`
// string is a technical fallback only, shown solely inside EvidenceDisclosure above.
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario)
// carry no structured signals -- rather than show nothing, this known placeholder
// summary gets a localized rendering. Any other un-signalled legacy text still falls
// back to the raw string (better than a blank primary evidence area), but only
// "Technical details" is meant to guarantee raw-text visibility.
const SEED_PLACEHOLDER_SUMMARY = "Synthetic deterministic seed issue";
// params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
// one place that turns them into the operator's selected language; the legacy
// `evidence.summary` string is a technical fallback only, shown solely inside
// EvidenceDisclosure above.
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
if (signals.length === 0) {
const rawSummary = String(issue.evidence.summary ?? "");
if (!rawSummary) return null;
const text =
rawSummary === SEED_PLACEHOLDER_SUMMARY
? t("detail.evidence.seedPlaceholder")
: rawSummary;
return (
<ul className="evidence-signal-list">
<li>{text}</li>
</ul>
);
}
function describe(signal: EvidenceSignal): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("detail.evidence.missingField", {
field: t(`detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`detail.evidence.${code}`, { defaultValue: code });
}
}
if (signals.length === 0) return null;
return (
<ul className="evidence-signal-list">
{signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describe(signal)}</li>
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
))}
</ul>
);