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:
co-authored by
Claude Sonnet 5
parent
3808bbe132
commit
4faac24b5a
@@ -85,11 +85,16 @@ export interface DashboardMetrics {
|
||||
pending_or_failed_workflows: number;
|
||||
}
|
||||
|
||||
export interface EvidenceSignal {
|
||||
code: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
rule_type: string;
|
||||
detail: string;
|
||||
evidence_signals: EvidenceSignal[];
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
issue_ref: string | null;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { EvidenceSignal } from "../api/types";
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// The backend never emits prose for data-quality evidence -- only stable signal codes
|
||||
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
|
||||
// the one place that turns them into the operator's selected language, shared by the
|
||||
// issue detail page and the dashboard attention queue so the same code always reads the
|
||||
// same way everywhere it appears.
|
||||
export function describeEvidenceSignal(
|
||||
t: TFunction,
|
||||
formatNumber: (value: number) => string,
|
||||
signal: EvidenceSignal,
|
||||
): string {
|
||||
const { code, params = {} } = signal;
|
||||
if (code.startsWith("vehicle.")) {
|
||||
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
|
||||
}
|
||||
switch (code) {
|
||||
case "missing_field":
|
||||
return t("quality:detail.evidence.missingField", {
|
||||
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
|
||||
});
|
||||
case "overlap.reserved_bookings":
|
||||
return t("quality:detail.evidence.overlapReservedBookings", {
|
||||
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
|
||||
});
|
||||
case "odometer.regression":
|
||||
return t("quality: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("quality:detail.evidence.duplicateSimilarName", { score: params.score });
|
||||
case "attention.upcoming_booking_missing_inspection":
|
||||
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
|
||||
default:
|
||||
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Ends",
|
||||
"startOdometer": "Start odometer",
|
||||
"endOdometer": "End odometer",
|
||||
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||
"requirementsComplete": "Requirements complete",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Missing field: {{field}}",
|
||||
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
|
||||
"seedPlaceholder": "Synthetic seed data with no further detail."
|
||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Compare and merge",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Fin",
|
||||
"startOdometer": "Kilométrage de départ",
|
||||
"endOdometer": "Kilométrage de retour",
|
||||
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
|
||||
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
|
||||
"requirementsComplete": "Exigences complètes",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Champ manquant : {{field}}",
|
||||
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
|
||||
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante.",
|
||||
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
|
||||
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Comparer et fusionner",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Einde",
|
||||
"startOdometer": "Startkilometerstand",
|
||||
"endOdometer": "Eindkilometerstand",
|
||||
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
|
||||
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
|
||||
"requirementsComplete": "Vereisten volledig",
|
||||
"yes": "Ja",
|
||||
"no": "Nee"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Ontbrekend veld: {{field}}",
|
||||
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
|
||||
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog.",
|
||||
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
|
||||
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Vergelijken en samenvoegen",
|
||||
|
||||
@@ -68,8 +68,8 @@ export function BookingDetail() {
|
||||
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
||||
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
|
||||
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
|
||||
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div>
|
||||
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div>
|
||||
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : t("detail.startOdometerPending")}</dd></div>
|
||||
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : t("detail.endOdometerPending")}</dd></div>
|
||||
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
|
||||
</dl></section>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
||||
import { describeEvidenceSignal } from "../data/evidenceSignals";
|
||||
import type { AttentionItem } from "../api/types";
|
||||
|
||||
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
|
||||
{ key: "available", labelKey: "readiness.available", tone: "ready" },
|
||||
@@ -29,9 +31,20 @@ function attentionItemTitle(
|
||||
return `${ruleLabel} — ${item.link_ref}`;
|
||||
}
|
||||
|
||||
// The backend never emits prose here either -- only stable signal codes + params, exactly
|
||||
// like the data-quality issue detail page. Multiple signals on one item (e.g. the
|
||||
// duplicate-customer match) are joined into one readable line for this compact row.
|
||||
function attentionItemDetail(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
formatNumber: (value: number) => string,
|
||||
item: Pick<AttentionItem, "evidence_signals">,
|
||||
): string {
|
||||
return item.evidence_signals.map((signal) => describeEvidenceSignal(t, formatNumber, signal)).join(" · ");
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
|
||||
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
|
||||
const greetingPeriod = useGreetingPeriod();
|
||||
const { user } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
@@ -71,9 +84,9 @@ export function Dashboard() {
|
||||
|
||||
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
||||
const matchesSeverity = severity === "all" || item.severity === severity;
|
||||
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
|
||||
const haystack = `${attentionItemTitle(t, item)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase();
|
||||
return matchesSeverity && haystack.includes(query.toLowerCase());
|
||||
}) ?? [], [data, query, severity, t]);
|
||||
}) ?? [], [data, query, severity, t, formatNumber]);
|
||||
|
||||
const isUnfiltered = severity === "all" && query === "";
|
||||
const attentionTiers = useMemo(() => {
|
||||
@@ -157,7 +170,7 @@ export function Dashboard() {
|
||||
<SeverityBadge severity={item.severity} />
|
||||
<div className="queue-copy">
|
||||
<p className="attention-title">{title}</p>
|
||||
<p className="attention-detail">{item.detail}</p>
|
||||
<p className="attention-detail">{attentionItemDetail(t, formatNumber, item)}</p>
|
||||
</div>
|
||||
<span className="queue-ref">{item.link_ref}</span>
|
||||
<Icon name="chevron" className="row-chevron" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user