M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-17 03:17:44 +02:00
parent a9f48d6880
commit ae39a8947f
62 changed files with 5277 additions and 202 deletions
+13 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { brusselsDayStartIso, brusselsNextDayStartIso } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
@@ -78,7 +79,7 @@ export function Audit() {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -90,9 +91,17 @@ export function Audit() {
if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId);
if (from) params.set("occurred_from", `${from}T00:00:00Z`);
if (to) params.set("occurred_to", `${to}T23:59:59Z`);
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
// Date filters are Brussels calendar days, not UTC midnight boundaries.
if (from) params.set("occurred_from", brusselsDayStartIso(from));
if (to) params.set("occurred_to", brusselsNextDayStartIso(to));
const controller = new AbortController();
api
.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`, { signal: controller.signal })
.then(setEvents)
.catch(() => {
if (!controller.signal.aborted) setError(t("unavailable"));
});
return () => controller.abort();
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => {
+10 -5
View File
@@ -27,6 +27,7 @@ export function BookingDetail() {
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [actionErrorSource, setActionErrorSource] = useState<"cancel" | "requirements" | "reschedule" | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
@@ -41,7 +42,7 @@ export function BookingDetail() {
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
}, [publicRef, t]);
useEffect(() => {
setBooking(null);
@@ -54,7 +55,7 @@ export function BookingDetail() {
if (!booking) return;
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
}, [booking?.public_ref]);
}, [booking]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
@@ -87,6 +88,7 @@ export function BookingDetail() {
setCancelReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
setActionErrorSource("cancel");
} finally {
setCancelling(false);
}
@@ -110,6 +112,7 @@ export function BookingDetail() {
setRequirementsConfirmation("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
setActionErrorSource("requirements");
} finally {
setConfirmingRequirements(false);
}
@@ -130,6 +133,7 @@ export function BookingDetail() {
setScheduleReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
setActionErrorSource("reschedule");
} finally {
setRescheduling(false);
}
@@ -158,7 +162,7 @@ export function BookingDetail() {
<h2>{t("detail.requirementsAction")}</h2>
<p>{t("detail.requirementsActionDetail")}</p>
</div>
<ApiErrorNotice error={actionError} />
<ApiErrorNotice error={actionErrorSource === "requirements" ? actionError : null} />
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
</form>
@@ -169,6 +173,7 @@ export function BookingDetail() {
<summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
@@ -179,9 +184,9 @@ export function BookingDetail() {
</details>
) : null}
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
{(booking.status === "reserved" || booking.status === "blocked") && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
<ApiErrorNotice error={actionErrorSource === "cancel" ? actionError : null} />
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
+16 -16
View File
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, Page } from "../api/types";
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
@@ -10,15 +11,9 @@ import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
function localDateString(date = new Date()): string {
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
function nextLocalDay(value: string): string {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + 1);
return localDateString(date);
/** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
function brusselsToday(): string {
return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
}
export function Bookings() {
@@ -41,7 +36,7 @@ export function Bookings() {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -50,14 +45,19 @@ export function Bookings() {
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status);
if (query) params.set("query", query);
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString());
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString());
// Date filters are Brussels calendar days regardless of the browser's own time zone.
if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
if (location) params.set("location", location);
params.set("sort", sort);
const controller = new AbortController();
api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
.then(setBookings)
.catch(() => setError(t("list.unavailable")));
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
return (
@@ -82,8 +82,8 @@ export function Bookings() {
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
</form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: brusselsToday(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div>
+2 -1
View File
@@ -61,6 +61,7 @@ export function Dashboard() {
}
// Only ever react to the initial `?guide=start` marker set by the login screen's
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
@@ -72,7 +73,7 @@ export function Dashboard() {
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
}, [t]);
useEffect(() => {
if (!canSeeAutomation) return;
+8 -4
View File
@@ -51,12 +51,13 @@ export function DataQuality() {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
if (user?.role !== "operations_manager") return;
setIssues(null);
// Keep the current rows visible while a filter change refetches (stale-while-revalidate);
// the initial load still shows the loading state because `issues` starts as null.
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
@@ -64,13 +65,14 @@ export function DataQuality() {
if (severity) params.set("severity", severity);
if (assignee) params.set("assigned_to_ref", assignee);
if (overdueOnly) params.set("overdue", "true");
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
@@ -145,6 +147,8 @@ export function DataQuality() {
}
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
// The demo-scenario filter is applied server-side (so it spans all pages); the client-side
// guard only bridges the moment between toggling the box and the filtered page arriving.
const visibleIssues = issues
? demoScenariosOnly
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
@@ -311,7 +315,7 @@ export function DataQuality() {
</tr>
))}
</tbody>
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
</table>{issues && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
@@ -727,7 +727,7 @@ export function DataQualityIssueDetail() {
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
}, [publicRef, t]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
+7 -3
View File
@@ -28,7 +28,7 @@ export function Vehicles() {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -41,10 +41,14 @@ export function Vehicles() {
if (location) params.set("location", location);
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, attentionOnly, query, location, page, t]);
return (