From 1c4327ffa519cd4d67e48e22b91eca49e0bc5c45 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 03:03:31 +0200 Subject: [PATCH 1/9] feat(traffic): explain route evidence without implying congestion causality --- .../panels/directions/RouteCard.test.tsx | 14 ++- .../panels/directions/RouteCard.tsx | 23 +++- .../directions/RouteTrafficStatus.test.tsx | 79 ++++++++++++ .../panels/directions/RouteTrafficStatus.tsx | 46 +++++++ apps/web/src/lib/route-traffic-status.test.ts | 115 ++++++++++++++++++ apps/web/src/lib/route-traffic-status.ts | 69 +++++++++++ packages/i18n/locales/de.json | 21 +++- packages/i18n/locales/en.json | 21 +++- 8 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx create mode 100644 apps/web/src/components/panels/directions/RouteTrafficStatus.tsx create mode 100644 apps/web/src/lib/route-traffic-status.test.ts create mode 100644 apps/web/src/lib/route-traffic-status.ts diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 341d797b7..6cad30c21 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -131,7 +131,9 @@ describe("RouteCard arrival time", () => { renderArrival({ kind: "now", destinationTimeZone: "Europe/Berlin" }); const arrival = screen.getByTestId("route-arrival"); expect(arrival).toHaveTextContent(/Arrives.*1:30\s*PM/); - expect(screen.getByRole("radio").getAttribute("aria-describedby")).toBe(arrival.id); + expect(screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ")).toContain( + arrival.id, + ); }); it("resolves a chosen departure in the origin zone before displaying destination time", () => { @@ -355,6 +357,14 @@ describe("RouteCard keyboard actions", () => { }); describe("RouteCard traffic delta", () => { + it("describes traffic evidence when keyboard users select a route", () => { + renderCard(baseRoute); + const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; + expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( + "Congestion effect on ETA not verified", + ); + }); + it("shows nothing when there is no baseline duration", () => { renderCard(baseRoute); expect(screen.queryByTestId("traffic-delay")).toBeNull(); @@ -377,6 +387,8 @@ describe("RouteCard traffic delta", () => { renderCard({ ...baseRoute, duration: 5220, baselineDuration: 4500 }); const el = screen.getByTestId("traffic-delay"); expect(el.textContent).toContain("12 min"); + expect(el).toHaveTextContent("vs engine baseline"); + expect(el).not.toHaveTextContent(/traffic|normally/i); expect(getComputedStyle(el).color).toBe("var(--omx-traffic-light)"); }); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index 120f43526..cb702d843 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -43,6 +43,7 @@ import { useStartNavigation } from "@/lib/mobile/useStartNavigation"; import { primeSpeechSynthesis } from "@/lib/navigation/useNavigationVoice"; import { requestHeadingPermission } from "@/lib/useHeading"; import { useNow } from "@/lib/useNow"; +import { RouteTrafficStatus } from "./RouteTrafficStatus"; import { type RouteArrivalContext, resolveRouteArrival } from "./routeArrival"; const GROUND_MODES = new Set(["driving", "walking", "cycling", "motorcycle"]); @@ -148,6 +149,14 @@ export function RouteCard({ const [startError, setStartError] = useState(null); const [impactDetailsOpen, setImpactDetailsOpen] = useState(false); const arrivalCaptionId = useId(); + const trafficCaptionId = useId(); + const selectionDescription = + [ + route.mode === "driving" && arrivalContext ? arrivalCaptionId : null, + route.mode === "driving" || route.mode === "motorcycle" ? trafficCaptionId : null, + ] + .filter(Boolean) + .join(" ") || undefined; const roadConditionNotice = roadConditionRouteNotice(roadConditionImpact); const handleStart = async () => { @@ -288,6 +297,12 @@ export function RouteCard({ {t("trafficDelayNormally", { baseline: formatDuration(trafficDelay.baseline) })} )} + {roadConditionNotice && ( @@ -367,9 +380,7 @@ export function RouteCard({ type="radio" name="alternative-route" aria-label={selectionLabel} - aria-describedby={ - route.mode === "driving" && arrivalContext ? arrivalCaptionId : undefined - } + aria-describedby={selectionDescription} checked={active} onChange={onSelect} onClick={() => { diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx new file mode 100644 index 000000000..ad5c8cafc --- /dev/null +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx @@ -0,0 +1,79 @@ +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; +import { en } from "@openmapx/i18n"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { RouteTrafficStatus } from "./RouteTrafficStatus"; + +const now = Date.parse("2026-10-07T02:00:00Z"); +const route: Route = { + mode: "driving", + duration: 2000, + distance: 10000, + geometry: [], + legs: [], + steps: [], + trafficProof: { + schemaVersion: 1, + requestId: "request", + writeId: "write", + graphGeneration: "graph", + engineBootId: "boot", + endpoint: "route", + costing: "auto", + evaluatedAt: new Date(now - 1000).toISOString(), + validUntil: new Date(now + 60000).toISOString(), + }, +}; +const impact: RoadConditionRouteImpact = { + availability: "current", + reasons: [], + evaluatedAt: new Date(now).toISOString(), + validUntil: new Date(now + 30000).toISOString(), +}; +const view = ( + r = route, + provider = "routing-valhalla", + assessment: RoadConditionRouteImpact | undefined = impact, +) => ( + + + +); +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +it("expires held verification at the lease and never claims congestion causality", () => { + render(view()); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "Road-condition application verified", + ); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "Congestion effect on ETA not verified", + ); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "Road-condition evidence expired", + ); + expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); +}); +it("replaces old status on a new provider/route without proof", () => { + const rendered = render(view()); + rendered.rerender(view({ ...route, trafficProof: undefined }, "routing-osrm")); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("OSRM"); + expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); +}); +it("removes a prior successful claim on failed assessment", () => { + const rendered = render(view()); + rendered.rerender(view(route, "routing-valhalla", { ...impact, availability: "unavailable" })); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "Road-condition check unavailable", + ); +}); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx new file mode 100644 index 000000000..39d1d9733 --- /dev/null +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { routeTrafficStatus } from "@/lib/route-traffic-status"; + +export function RouteTrafficStatus({ + route, + impact, + provider, + id, +}: { + route: Route; + impact?: RoadConditionRouteImpact; + provider?: string; + id?: string; +}) { + const t = useTranslations("trafficStatus"); + const [revision, setRevision] = useState(0); + void revision; + const status = routeTrafficStatus(route, impact, provider, Date.now()); + const deadline = status?.deadline; + useEffect(() => { + if (deadline == null) return; + const timer = setTimeout(() => setRevision((v) => v + 1), Math.max(0, deadline - Date.now())); + return () => clearTimeout(timer); + }, [deadline]); + if (!status) return null; + return ( + + {t(`source.${status.source}`)} + {" · "} + {t(`application.${status.application}`)} +
+ {t("congestionUnverified")} +
+ ); +} diff --git a/apps/web/src/lib/route-traffic-status.test.ts b/apps/web/src/lib/route-traffic-status.test.ts new file mode 100644 index 000000000..22472250f --- /dev/null +++ b/apps/web/src/lib/route-traffic-status.test.ts @@ -0,0 +1,115 @@ +import type { RoadConditionRouteImpact, Route, RoutingTrafficProof } from "@openmapx/core"; +import { describe, expect, it } from "vitest"; +import { routeTrafficStatus } from "./route-traffic-status"; + +const now = Date.parse("2026-10-07T02:00:00Z"); +export const proof: RoutingTrafficProof = { + schemaVersion: 1, + requestId: "request-one", + writeId: "write-one", + graphGeneration: "graph-one", + engineBootId: "boot-one", + endpoint: "route", + costing: "auto", + evaluatedAt: new Date(now - 1000).toISOString(), + validUntil: new Date(now + 60000).toISOString(), +}; +export const route: Route = { + mode: "driving", + duration: 2000, + distance: 10000, + geometry: [], + legs: [], + steps: [], + trafficProof: proof, +}; +export const impact: RoadConditionRouteImpact = { + availability: "current", + reasons: [], + evaluatedAt: new Date(now).toISOString(), + validUntil: new Date(now + 30000).toISOString(), +}; + +describe("route traffic evidence", () => { + it("requires the matching server assessment as well as proof", () => { + expect(routeTrafficStatus(route, impact, "routing-valhalla", now)?.application).toBe( + "verified", + ); + expect(routeTrafficStatus(route, undefined, "routing-valhalla", now)?.application).toBe( + "unverified", + ); + expect( + routeTrafficStatus({ ...route, trafficProof: undefined }, impact, "routing-valhalla", now) + ?.application, + ).toBe("unverified"); + }); + it.each([ + { schemaVersion: 2 }, + { requestId: "" }, + { writeId: " " }, + { graphGeneration: "x".repeat(257) }, + { engineBootId: "" }, + { endpoint: "matrix" }, + { costing: "motorcycle" }, + { evaluatedAt: new Date(now + 1).toISOString() }, + { evaluatedAt: "invalid" }, + { validUntil: "invalid" }, + ])("rejects invalid proof %j", (invalid) => { + const trafficProof = { ...proof, ...invalid } as RoutingTrafficProof; + expect( + routeTrafficStatus({ ...route, trafficProof }, impact, "routing-valhalla", now)?.application, + ).toBe("unverified"); + }); + it("expires either lease at its exact deadline", () => { + expect(routeTrafficStatus(route, impact, "routing-valhalla", now + 30000)?.application).toBe( + "expired", + ); + expect( + routeTrafficStatus( + { ...route, trafficProof: { ...proof, validUntil: new Date(now).toISOString() } }, + impact, + "routing-valhalla", + now, + )?.application, + ).toBe("expired"); + }); + it.each([ + { ...impact, evaluatedAt: new Date(now - 2000).toISOString() }, + { ...impact, evaluatedAt: new Date(now + 1).toISOString() }, + { ...impact, validUntil: new Date(now + 61000).toISOString() }, + { ...impact, reasons: ["graph_mismatch"] }, + ])("rejects contradictory assessment %j", (assessment) => { + expect(routeTrafficStatus(route, assessment, "routing-valhalla", now)?.application).toBe( + "unverified", + ); + }); + it("does not carry verification to another provider or hosted route", () => { + expect(routeTrafficStatus(route, impact, "routing-osrm", now)?.application).toBe("unverified"); + expect( + routeTrafficStatus({ ...route, sourceIds: ["stadia-maps"] }, impact, "routing-valhalla", now), + ).toMatchObject({ application: "unverified", source: "hostedValhalla" }); + }); + it("retains limited and failed assessment without verified claim", () => { + expect( + routeTrafficStatus(route, { ...impact, availability: "limited" }, "routing-valhalla", now) + ?.application, + ).toBe("limited"); + expect( + routeTrafficStatus(route, { ...impact, availability: "unavailable" }, "routing-valhalla", now) + ?.application, + ).toBe("unavailable"); + }); + it("only labels motorized routes and matches motorcycle costing", () => { + expect( + routeTrafficStatus({ ...route, mode: "walking" }, impact, "routing-valhalla", now), + ).toBeNull(); + expect( + routeTrafficStatus( + { ...route, mode: "motorcycle", trafficProof: { ...proof, costing: "motorcycle" } }, + impact, + "routing-valhalla", + now, + )?.application, + ).toBe("verified"); + }); +}); diff --git a/apps/web/src/lib/route-traffic-status.ts b/apps/web/src/lib/route-traffic-status.ts new file mode 100644 index 000000000..3ef608576 --- /dev/null +++ b/apps/web/src/lib/route-traffic-status.ts @@ -0,0 +1,69 @@ +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; + +export interface RouteTrafficStatusValue { + application: "verified" | "expired" | "limited" | "unavailable" | "unverified"; + source: "localValhalla" | "hostedValhalla" | "valhalla" | "osrm" | "unknown"; + deadline: number | null; +} + +/** Consumer guard, not an independent check of the engine's active graph. + * The server assessment verifies graph/write/boot identity for this response. + * Neither that assessment nor its proof establishes congestion's effect on ETA. + */ +export function routeTrafficStatus( + route: Route, + impact: RoadConditionRouteImpact | undefined, + provider: string | undefined, + nowMs: number, +): RouteTrafficStatusValue | null { + if (route.mode !== "driving" && route.mode !== "motorcycle") return null; + const hosted = route.sourceIds?.includes("stadia-maps") === true; + const source = + provider === "routing-valhalla" + ? hosted + ? "hostedValhalla" + : "valhalla" + : provider === "routing-osrm" + ? "osrm" + : "unknown"; + const result: RouteTrafficStatusValue = { application: "unverified", source, deadline: null }; + if (impact?.availability === "expired") return { ...result, application: "expired" }; + if (impact?.availability === "limited") return { ...result, application: "limited" }; + if (impact?.availability === "unavailable" || impact?.availability === "unsupported") + return { ...result, application: "unavailable" }; + const p = route.trafficProof; + if (provider !== "routing-valhalla" || hosted || !p || impact?.availability !== "current") + return result; + if ( + p.schemaVersion !== 1 || + (p.endpoint !== "route" && p.endpoint !== "optimized_route") || + p.costing !== (route.mode === "driving" ? "auto" : "motorcycle") || + ![p.requestId, p.writeId, p.graphGeneration, p.engineBootId, p.evaluatedAt, p.validUntil].every( + (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 256, + ) + ) + return result; + const evaluatedAt = Date.parse(p.evaluatedAt); + const proofDeadline = Date.parse(p.validUntil); + const assessmentTime = Date.parse(impact.evaluatedAt); + const assessmentDeadline = impact.validUntil ? Date.parse(impact.validUntil) : NaN; + if ( + ![nowMs, evaluatedAt, proofDeadline, assessmentTime, assessmentDeadline].every(Number.isFinite) + ) + return result; + if ( + evaluatedAt > nowMs || + assessmentTime > nowMs || + assessmentTime < evaluatedAt || + impact.reasons.length > 0 + ) + return result; + if (proofDeadline <= nowMs || assessmentDeadline <= nowMs) + return { ...result, application: "expired" }; + if (assessmentDeadline > proofDeadline) return result; + return { + application: "verified", + source: "localValhalla", + deadline: Math.min(proofDeadline, assessmentDeadline), + }; +} diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index f69dc0b44..55c5d8acb 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -892,8 +892,8 @@ "checkFailed": "Straßenbedingungen für diese Route konnten nicht geprüft werden" }, "localAccessRoad": "Nutzt eine gesperrte Straße (Anlieger frei)", - "trafficDelay": "+{delay} Verkehr", - "trafficDelayNormally": "normalerweise {baseline}", + "trafficDelay": "+{delay} gegenüber der Modell-Basiszeit", + "trafficDelayNormally": "Basiszeit {baseline}", "from": "von", "to": "nach", "origin": "Start", @@ -4291,5 +4291,22 @@ "confirmTitle": "OSM-Suchindex erstellen", "confirmMessage": "Den OSM-Code- und Aliasindex für „{region}“ erstellen und atomar veröffentlichen? Dies kann CPU, Datenträger und Datenbank stark beanspruchen.", "startBuild": "Erstellung starten" + }, + "trafficStatus": { + "source": { + "localValhalla": "Selbst gehostetes Valhalla", + "hostedValhalla": "Gehostetes Valhalla (Stadia Maps)", + "valhalla": "Valhalla", + "osrm": "OSRM", + "unknown": "Routing-Quelle unbekannt" + }, + "application": { + "verified": "Berücksichtigung der Straßenbedingungen verifiziert", + "expired": "Nachweis der Straßenbedingungen abgelaufen", + "limited": "Nachweis der Straßenbedingungen eingeschränkt", + "unavailable": "Prüfung der Straßenbedingungen nicht verfügbar", + "unverified": "Berücksichtigung der Straßenbedingungen nicht verifiziert" + }, + "congestionUnverified": "Einfluss von Staus auf die Ankunftszeit nicht verifiziert" } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 57563c968..31067fd44 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -892,8 +892,8 @@ "checkFailed": "Couldn't check road conditions for this route" }, "localAccessRoad": "Uses a road closed except for local access", - "trafficDelay": "+{delay} traffic", - "trafficDelayNormally": "normally {baseline}", + "trafficDelay": "+{delay} vs engine baseline", + "trafficDelayNormally": "baseline {baseline}", "from": "from", "to": "to", "origin": "Origin", @@ -4291,5 +4291,22 @@ "confirmTitle": "Build OSM search index", "confirmMessage": "Build and atomically publish the OSM code and alias index for \"{region}\"? This can be CPU-, disk-, and database-intensive.", "startBuild": "Start build" + }, + "trafficStatus": { + "source": { + "localValhalla": "Self-hosted Valhalla", + "hostedValhalla": "Hosted Valhalla (Stadia Maps)", + "valhalla": "Valhalla", + "osrm": "OSRM", + "unknown": "Routing source unknown" + }, + "application": { + "verified": "Road-condition application verified", + "expired": "Road-condition evidence expired", + "limited": "Road-condition evidence limited", + "unavailable": "Road-condition check unavailable", + "unverified": "Road-condition application not verified" + }, + "congestionUnverified": "Congestion effect on ETA not verified" } } From e8d96ac6d726fc82f71645a5da88738c4e78227b Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 03:10:25 +0200 Subject: [PATCH 2/9] feat(traffic): distinguish overlay source and routing influence --- .changeset/traffic-evidence-presentation.md | 8 +++++ .../overlay/TrafficOverlayContext.tsx | 34 ++++++++++++++++++ docs/docs/features/directions.md | 23 ++++++++++++ docs/docs/features/map-layers.md | 18 ++++++++++ .../__tests__/legend.test.tsx | 22 ++++++++++-- integrations/overlay-traffic-flow/legend.tsx | 2 ++ .../overlay-traffic-tomtom/legend.test.tsx | 36 +++++++++++++++++++ .../overlay-traffic-tomtom/legend.tsx | 28 +++++++++++++++ .../overlay-traffic-tomtom/manifest.json | 2 +- .../overlay-traffic-tomtom/package.json | 8 +++++ packages/i18n/locales/de.json | 12 ++++++- packages/i18n/locales/en.json | 12 ++++++- pnpm-lock.yaml | 10 ++++++ 13 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 .changeset/traffic-evidence-presentation.md create mode 100644 apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx create mode 100644 integrations/overlay-traffic-tomtom/legend.test.tsx create mode 100644 integrations/overlay-traffic-tomtom/legend.tsx diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md new file mode 100644 index 000000000..57748611e --- /dev/null +++ b/.changeset/traffic-evidence-presentation.md @@ -0,0 +1,8 @@ +--- +"web": patch +"@openmapx/i18n": patch +"@openmapx/integration-overlay-traffic-flow": patch +"@openmapx/integration-overlay-traffic-tomtom": patch +--- + +Explain traffic overlay sources and unknown data age separately from request-bound road-condition verification. Label route-duration differences as engine baseline comparisons without implying congestion causality. diff --git a/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx new file mode 100644 index 000000000..d7771684b --- /dev/null +++ b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import { useTranslations } from "next-intl"; + +/** These tile formats expose no upstream timestamp or extent completeness. */ +export function TrafficOverlayContext({ hosted, visible }: { hosted: boolean; visible: boolean }) { + const t = useTranslations("trafficStatus.overlay"); + return ( + + {t(hosted ? "hostedSource" : "ownedSource")} + {!visible && ( + <> + {" "} + · {t("hidden")} + + )} +
+ {t("ageUnknown")} · {t("coverageUnknown")} + {!hosted && ( + <> +
+ {t("estimates")} + + )} +
+ {t("mapOnly")} +
+ ); +} diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 39d72b08a..b0a9b0bc9 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -122,6 +122,29 @@ selected travel time. The system evaluates these in the closure's local timezone fine-grained recurring schedule windows). This prevents routing detours around future closures that haven't started yet or nightly closures during daytime trips. +### Reading traffic evidence + +Motorized route cards separate **road-condition application** from **congestion's +effect on the ETA**. “Application verified” requires this response's validated +Valhalla proof and the server's matching graph/write/engine assessment. It expires +at the evidence lease; missing, conflicting, limited or failed evidence cannot +produce that claim. A new route/provider replaces the status. Failed directions +queries show recovery instead of reusing a previous successful card. + +This proof concerns applied road conditions such as closures and speed caps. It +does not establish that congestion changed the selected path or added a particular +number of minutes. Cards therefore keep congestion's ETA influence explicitly +unverified. A significant difference from `baselineDuration` is labelled **versus +engine baseline**, not a measured traffic delay or a normal journey time. Valhalla +and hosted Stadia routes are identified separately where their source IDs say so; +a provider name alone does not prove self-hosting. + +The proof's evaluation time is a verification time, not an upstream observation +or publication age. Map colors and freshly downloaded tiles do not substitute for +route evidence. The [traffic legends](./map-layers.md#transportation) explain the separate +map-only sources and their currently unknown source age/coverage. This initial +presentation applies to directions cards, not every navigation/EV surface. + ## Stop times and dwell Any stop on a trip can carry a time of its own. The clock button on a waypoint diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index bed74774c..a5fa06b3e 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -124,6 +124,24 @@ than inventing a speed. These overlays complement the dedicated [public transit](./public-transit.md) and [directions](./directions.md) features rather than replacing them. +Both traffic legends explain their source: owned traffic-flow tiles or hosted +TomTom traffic. They show **source data age unknown** and **coverage unknown for +this view**. The owned vector tiles and TomTom raster tiles expose no usable +upstream/publication timestamp or completeness metadata. Successful tile requests +are not evidence of fresh measurements; typical and estimated flow values can +also color roads. A hidden layer is explicitly labelled hidden. + +Traffic publication/check evidence exists in authenticated admin coverage, but +is not exposed by these public tile contracts. The legends do not fetch admin +diagnostics or borrow check/request times as observation age. A public, bounded +publication metadata contract remains follow-up work. + +**Map colors do not verify traffic influence on a route.** The routing engine can +differ from the displayed overlay: hosted TomTom traffic over a self-hosted +Valhalla route is still a map visualization. Directions cards separately explain +validated road-condition application and unverified congestion influence; see +[Reading traffic evidence](./directions.md#reading-traffic-evidence). + ### Context-aware layers Useful layers follow the task without becoming permanent preferences. Opening a diff --git a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx index 106c68ba9..25a974206 100644 --- a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx +++ b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx @@ -1,11 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@/test"; import { useTrafficFlowStore } from "../store"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); import { TrafficFlowLegend } from "../legend"; +afterEach(cleanup); + describe("TrafficFlowLegend", () => { beforeEach(() => { useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); @@ -19,3 +21,19 @@ describe("TrafficFlowLegend", () => { ); }); }); + +it("keeps source age and coverage unknown and distinguishes map from routing", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); + const view = render(); + expect(screen.getByText("trafficStatus.overlay.ownedSource")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.ageUnknown")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.coverageUnknown")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.estimates")).toBeDefined(); + expect(view.container.querySelector("button button")).toBeNull(); +}); +it("explains hidden layer instead of implying map display", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: false }); + render(); + expect(screen.getByText("trafficStatus.overlay.hidden")).toBeDefined(); +}); diff --git a/integrations/overlay-traffic-flow/legend.tsx b/integrations/overlay-traffic-flow/legend.tsx index 0db0fa425..5f0f3a2d5 100644 --- a/integrations/overlay-traffic-flow/legend.tsx +++ b/integrations/overlay-traffic-flow/legend.tsx @@ -5,6 +5,7 @@ import Typography from "@mui/material/Typography"; import { TRAFFIC_BAND_COLORS, type TrafficBand, useOverlayVisibilitySetter } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { OverlayLegend } from "@/integration-api/overlay/OverlayLegend"; +import { TrafficOverlayContext } from "@/integration-api/overlay/TrafficOverlayContext"; import { useTrafficFlowStore } from "./store"; import { TRAFFIC_FLOW_CONFIDENCE_STEPS } from "./visual-style"; @@ -71,6 +72,7 @@ export function TrafficFlowLegend() { ))} + ); } diff --git a/integrations/overlay-traffic-tomtom/legend.test.tsx b/integrations/overlay-traffic-tomtom/legend.test.tsx new file mode 100644 index 000000000..c83e9e1ac --- /dev/null +++ b/integrations/overlay-traffic-tomtom/legend.test.tsx @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@/test"; +import { TrafficLegend } from "./legend"; +import { useTrafficStore } from "./store"; + +vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); +const setVisible = vi.hoisted(() => vi.fn()); +vi.mock("@openmapx/core", async (original) => ({ + ...(await original()), + useOverlayVisibilitySetter: () => setVisible, +})); +afterEach(cleanup); +beforeEach(() => { + useTrafficStore.setState({ panelOpen: true, layerVisible: true }); + setVisible.mockClear(); +}); +it("explains hosted map-only traffic without inferring age or coverage", () => { + const view = render(); + expect(screen.getByText("layers.trafficTomtom")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.hostedSource")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.ageUnknown")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.coverageUnknown")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); + expect(view.container.querySelector("button button")).toBeNull(); + fireEvent.click(screen.getByRole("switch", { name: "trafficStatus.overlay.toggleHosted" })); + expect(setVisible).toHaveBeenCalledWith(false); +}); +it("renders hidden status only when panel is open", () => { + useTrafficStore.setState({ layerVisible: false }); + const view = render(); + expect(screen.getByText("layers.trafficTomtom")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.hidden")).toBeDefined(); + useTrafficStore.setState({ panelOpen: false }); + view.rerender(); + expect(screen.queryByText("trafficStatus.overlay.hostedSource")).toBeNull(); +}); diff --git a/integrations/overlay-traffic-tomtom/legend.tsx b/integrations/overlay-traffic-tomtom/legend.tsx new file mode 100644 index 000000000..67a6087f2 --- /dev/null +++ b/integrations/overlay-traffic-tomtom/legend.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useOverlayVisibilitySetter } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { OverlayLegend } from "@/integration-api/overlay/OverlayLegend"; +import { TrafficOverlayContext } from "@/integration-api/overlay/TrafficOverlayContext"; +import { useTrafficStore } from "./store"; + +export function TrafficLegend() { + const t = useTranslations("trafficStatus.overlay"); + const tm = useTranslations("layers"); + const panelOpen = useTrafficStore((s) => s.panelOpen); + const layerVisible = useTrafficStore((s) => s.layerVisible); + const setLayerVisible = useOverlayVisibilitySetter("traffic"); + return ( + + + + ); +} diff --git a/integrations/overlay-traffic-tomtom/manifest.json b/integrations/overlay-traffic-tomtom/manifest.json index 5403f3899..7817fd8cf 100644 --- a/integrations/overlay-traffic-tomtom/manifest.json +++ b/integrations/overlay-traffic-tomtom/manifest.json @@ -13,7 +13,7 @@ "domains": ["map-overlay"], "frontend": { "mapLayer": true, - "legend": false, + "legend": true, "panel": false, "layerSelector": { "group": "map-details", diff --git a/integrations/overlay-traffic-tomtom/package.json b/integrations/overlay-traffic-tomtom/package.json index 636041913..934b67478 100644 --- a/integrations/overlay-traffic-tomtom/package.json +++ b/integrations/overlay-traffic-tomtom/package.json @@ -6,5 +6,13 @@ "type": "module", "dependencies": { "@openmapx/core": "workspace:^" + }, + "peerDependencies": { + "react": ">=19" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "react": "19.3.0", + "vitest": "^5.0.1" } } diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 55c5d8acb..3e283d4e4 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4307,6 +4307,16 @@ "unavailable": "Prüfung der Straßenbedingungen nicht verfügbar", "unverified": "Berücksichtigung der Straßenbedingungen nicht verifiziert" }, - "congestionUnverified": "Einfluss von Staus auf die Ankunftszeit nicht verifiziert" + "congestionUnverified": "Einfluss von Staus auf die Ankunftszeit nicht verifiziert", + "overlay": { + "ownedSource": "Selbst gehostete Verkehrsfluss-Kacheln", + "hostedSource": "Gehostete Verkehrsdaten von TomTom", + "ageUnknown": "Alter der Quelldaten unbekannt", + "coverageUnknown": "Abdeckung für diesen Ausschnitt unbekannt", + "mapOnly": "Kartenfarben belegen keinen Verkehrseinfluss auf eine Route", + "estimates": "Typische und geschätzte Werte sind möglicherweise keine aktuellen Messungen", + "hidden": "Ebene ausgeblendet", + "toggleHosted": "TomTom-Verkehr umschalten" + } } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 31067fd44..e4ed6d9ab 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4307,6 +4307,16 @@ "unavailable": "Road-condition check unavailable", "unverified": "Road-condition application not verified" }, - "congestionUnverified": "Congestion effect on ETA not verified" + "congestionUnverified": "Congestion effect on ETA not verified", + "overlay": { + "ownedSource": "Self-hosted traffic-flow tiles", + "hostedSource": "TomTom hosted traffic", + "ageUnknown": "Source data age unknown", + "coverageUnknown": "Coverage unknown for this view", + "mapOnly": "Map colors do not verify traffic influence on a route", + "estimates": "Typical and estimated values may not be current measurements", + "hidden": "Layer hidden", + "toggleHosted": "Toggle TomTom traffic" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c21d37b78..e324d256e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,16 @@ importers: '@openmapx/core': specifier: workspace:^ version: link:../../packages/core + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + react: + specifier: 19.3.0 + version: 19.3.0 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.1)(jsdom@30.1.1(@noble/hashes@2.4.0))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) integrations/overlay-transit: dependencies: From be82d9b4be43c2be86c6d37a4d729c0ca6aaf508 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 03:15:14 +0200 Subject: [PATCH 3/9] fix(traffic): rearm evidence expiry after clock corrections --- .../directions/RouteTrafficStatus.test.tsx | 16 ++++++++++++++++ .../panels/directions/RouteTrafficStatus.tsx | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx index ad5c8cafc..3a8004c6f 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx @@ -77,3 +77,19 @@ it("removes a prior successful claim on failed assessment", () => { "Road-condition check unavailable", ); }); + +it("rearms expiry when a clock correction makes the first callback early", () => { + render(view()); + vi.setSystemTime(now - 1000); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("application verified"); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "Road-condition evidence expired", + ); + expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); +}); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index 39d1d9733..79a4b2f79 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -24,7 +24,14 @@ export function RouteTrafficStatus({ const deadline = status?.deadline; useEffect(() => { if (deadline == null) return; - const timer = setTimeout(() => setRevision((v) => v + 1), Math.max(0, deadline - Date.now())); + let timer: ReturnType | undefined; + const expire = () => { + const remaining = deadline - Date.now(); + // A wall-clock correction can make a monotonic timeout fire early. + if (remaining > 0) timer = setTimeout(expire, remaining); + else setRevision((v) => v + 1); + }; + expire(); return () => clearTimeout(timer); }, [deadline]); if (!status) return null; From 142bb7ce6ad3794e5f6eb20d41d389062d62f847 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 08:07:17 +0200 Subject: [PATCH 4/9] fix(traffic): simplify route and map explanations --- .changeset/traffic-evidence-presentation.md | 2 +- .../panels/directions/RouteCard.test.tsx | 69 +++++++++---------- .../panels/directions/RouteCard.tsx | 42 ++--------- .../directions/RouteTrafficStatus.test.tsx | 57 +++++++++++---- .../panels/directions/RouteTrafficStatus.tsx | 37 ++++++---- .../components/TrafficInfo.test.tsx | 36 ++++++++++ .../components/TrafficInfo.tsx | 59 ++++++++++++++++ .../overlay/TrafficOverlayContext.tsx | 54 +++++++++------ docs/docs/features/directions.md | 46 ++++++------- docs/docs/features/map-layers.md | 18 +++-- .../__tests__/legend.test.tsx | 19 ++++- integrations/overlay-traffic-flow/legend.tsx | 66 +++++++++--------- .../overlay-traffic-tomtom/legend.test.tsx | 12 ++-- packages/i18n/locales/de.json | 37 +++++----- packages/i18n/locales/en.json | 37 +++++----- 15 files changed, 363 insertions(+), 228 deletions(-) create mode 100644 apps/web/src/integration-api/components/TrafficInfo.test.tsx create mode 100644 apps/web/src/integration-api/components/TrafficInfo.tsx diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md index 57748611e..e88a7c150 100644 --- a/.changeset/traffic-evidence-presentation.md +++ b/.changeset/traffic-evidence-presentation.md @@ -5,4 +5,4 @@ "@openmapx/integration-overlay-traffic-tomtom": patch --- -Explain traffic overlay sources and unknown data age separately from request-bound road-condition verification. Label route-duration differences as engine baseline comparisons without implying congestion causality. +Keep route traffic caveats short and offer plain-language explanations on demand. Use a Fast–Slow traffic legend, preserve request-bound road-update verification, and avoid presenting engine-baseline differences as measured traffic delays. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 6cad30c21..37b9bd481 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -356,49 +356,44 @@ describe("RouteCard keyboard actions", () => { }); }); -describe("RouteCard traffic delta", () => { - it("describes traffic evidence when keyboard users select a route", () => { +describe("RouteCard compact traffic explanation", () => { + it("describes the caveat when keyboard users select a route", () => { renderCard(baseRoute); const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( - "Congestion effect on ETA not verified", + "May not include traffic delays", ); }); - - it("shows nothing when there is no baseline duration", () => { - renderCard(baseRoute); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); - }); - - it("shows nothing when the delay is under five minutes", () => { - // 4 min on a 40 min baseline: over 10% but under the absolute floor. - renderCard({ ...baseRoute, duration: 2640, baselineDuration: 2400 }); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); - }); - - it("shows nothing when the delay is under ten percent", () => { - // 6 min on a 90 min baseline: over the absolute floor but under the ratio. - renderCard({ ...baseRoute, duration: 5760, baselineDuration: 5400 }); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); - }); - - it("shows the delay once both thresholds are met", () => { - // 12 min on a 75 min baseline = 16% -> light band. - renderCard({ ...baseRoute, duration: 5220, baselineDuration: 4500 }); - const el = screen.getByTestId("traffic-delay"); - expect(el.textContent).toContain("12 min"); - expect(el).toHaveTextContent("vs engine baseline"); - expect(el).not.toHaveTextContent(/traffic|normally/i); - expect(getComputedStyle(el).color).toBe("var(--omx-traffic-light)"); - }); - - it("escalates the colour with the delay", () => { - // 45 min on a 60 min baseline = 75% -> heavy band. + it("does not advertise an engine-baseline delta as traffic delay", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); - expect(getComputedStyle(screen.getByTestId("traffic-delay")).color).toBe( - "var(--omx-traffic-heavy)", - ); - }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.queryByText(/^\+45 min|baseline/i)).toBeNull(); + }); + it.each(["route", "peek"] as const)( + "opens and closes traffic info without selecting the %s route", + (selectionKind) => { + const onSelect = vi.fn(); + const view = render( + + {}} + units="metric" + /> + , + ); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onSelect).not.toHaveBeenCalled(); + expect(view.container.querySelector("button button, button input, label button")).toBeNull(); + }, + ); }); /** diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index cb702d843..9e2f4f035 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -17,7 +17,6 @@ import type { RouteImpactUnavailableReason, } from "@openmapx/core"; import { - bandForDelayRatio, buildElevationProfile, estimateDrivingCo2Grams, formatDistance, @@ -36,7 +35,7 @@ import { type RouteImpactAssumptions, RouteImpactDetailsDialog, } from "@/components/panels/directions/RouteImpactDetailsDialog"; -import { BRAND, TRAFFIC_TEXT_COLOR } from "@/integration-api/runtime/theme"; +import { BRAND } from "@/integration-api/runtime/theme"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; import { formatCo2Emission } from "@/lib/formatCo2"; import { useStartNavigation } from "@/lib/mobile/useStartNavigation"; @@ -48,9 +47,6 @@ import { type RouteArrivalContext, resolveRouteArrival } from "./routeArrival"; const GROUND_MODES = new Set(["driving", "walking", "cycling", "motorcycle"]); -/** Absolute floor for showing a traffic delay, in seconds. */ -const MIN_TRAFFIC_DELAY_SECONDS = 300; - function RouteArrivalCaption({ context, durationSeconds, @@ -239,19 +235,6 @@ export function RouteCard({ ); - // Only worth surfacing when it clears both an absolute floor and a relative - // one: a 90-second delta on a two-hour drive tells the user nothing, and a - // large ratio on a very short hop is mostly snapping noise. - const trafficDelay = (() => { - const baseline = route.baselineDuration; - if (baseline === undefined || baseline <= 0) return null; - const delaySeconds = route.duration - baseline; - if (delaySeconds < MIN_TRAFFIC_DELAY_SECONDS) return null; - const band = bandForDelayRatio(delaySeconds / baseline); - if (!band) return null; - return { band, delaySeconds, baseline }; - })(); - const selectionLabel = `${route.summary ?? t("bestRoute")}, ${formatDuration(route.duration)}, ${dist}${ascentLabel ? `, ${ascentLabel}` : ""}`; const summaryContent = ( <> @@ -286,23 +269,6 @@ export function RouteCard({ {ascentLabel}
)} - {trafficDelay && ( - - {t("trafficDelay", { delay: formatDuration(trafficDelay.delaySeconds) })} - {" · "} - {t("trafficDelayNormally", { baseline: formatDuration(trafficDelay.baseline) })} - - )} - {roadConditionNotice && ( )} + {impact ? ( { }); it("expires held verification at the lease and never claims congestion causality", () => { render(view()); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "Road-condition application verified", + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used for this route.", ); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "Congestion effect on ETA not verified", + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "This travel time may not include current traffic delays.", ); act(() => { vi.advanceTimersByTime(30000); }); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "Road-condition evidence expired", + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates need to be checked again. Refresh the route.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", ); - expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); }); it("replaces old status on a new provider/route without proof", () => { const rendered = render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); rendered.rerender(view({ ...route, trafficProof: undefined }, "routing-osrm")); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("OSRM"); - expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent("OSRM"); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", + ); }); it("removes a prior successful claim on failed assessment", () => { const rendered = render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); rendered.rerender(view(route, "routing-valhalla", { ...impact, availability: "unavailable" })); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "Road-condition check unavailable", + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates couldn’t be checked.", ); }); it("rearms expiry when a clock correction makes the first callback early", () => { render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); vi.setSystemTime(now - 1000); act(() => { vi.advanceTimersByTime(30000); }); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("application verified"); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used", + ); act(() => { vi.advanceTimersByTime(1000); }); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates need to be checked again. Refresh the route.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", + ); +}); + +it("keeps provider and application details out of the default summary", () => { + render(view()); expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "Road-condition evidence expired", + "May not include traffic delays", + ); + expect(screen.queryByText(/Self-hosted Valhalla/)).toBeNull(); + expect(screen.queryByText("Available road updates were used for this route.")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used for this route.", ); - expect(screen.getByTestId("route-traffic-status")).not.toHaveTextContent("application verified"); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); }); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index 79a4b2f79..d149d970c 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -1,9 +1,11 @@ "use client"; +import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; +import { TrafficInfo } from "@/integration-api/components/TrafficInfo"; import { routeTrafficStatus } from "@/lib/route-traffic-status"; export function RouteTrafficStatus({ @@ -36,18 +38,27 @@ export function RouteTrafficStatus({ }, [deadline]); if (!status) return null; return ( - - {t(`source.${status.source}`)} - {" · "} - {t(`application.${status.application}`)} -
- {t("congestionUnverified")} -
+ + + {t("summary")} + + + + {t("congestionUnverified")} + + + {t(`application.${status.application}`)} + + + {t("provider", { source: t(`source.${status.source}`) })} + + + ); } diff --git a/apps/web/src/integration-api/components/TrafficInfo.test.tsx b/apps/web/src/integration-api/components/TrafficInfo.test.tsx new file mode 100644 index 000000000..658edf3d1 --- /dev/null +++ b/apps/web/src/integration-api/components/TrafficInfo.test.tsx @@ -0,0 +1,36 @@ +import { en } from "@openmapx/i18n"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, expect, it } from "vitest"; +import { cleanup, render, screen, userEvent, waitFor } from "@/test"; +import { TrafficInfo } from "./TrafficInfo"; + +afterEach(cleanup); + +it.each(["Escape", "Close", "outside click"] as const)( + "dismisses traffic info with %s and returns focus to its trigger", + async (dismissal) => { + const user = userEvent.setup(); + render( + + Traffic explanation + , + ); + const trigger = screen.getByRole("button", { name: "About traffic" }); + await user.click(trigger); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic explanation", + ); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + if (dismissal === "Escape") await user.keyboard("{Escape}"); + else if (dismissal === "Close") await user.click(screen.getByRole("button", { name: "Close" })); + else { + // The modal backdrop intentionally has no accessible control role. + const backdrop = document.querySelector(".MuiBackdrop-root"); + expect(backdrop).not.toBeNull(); + await user.click(backdrop as HTMLElement); + } + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveFocus(); + }, +); diff --git a/apps/web/src/integration-api/components/TrafficInfo.tsx b/apps/web/src/integration-api/components/TrafficInfo.tsx new file mode 100644 index 000000000..1e1b50d57 --- /dev/null +++ b/apps/web/src/integration-api/components/TrafficInfo.tsx @@ -0,0 +1,59 @@ +"use client"; + +import CloseIcon from "@mui/icons-material/Close"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import Popover from "@mui/material/Popover"; +import Typography from "@mui/material/Typography"; +import { useTranslations } from "next-intl"; +import { type ReactNode, useId, useState } from "react"; + +/** Optional traffic explanation; never nested inside route selection controls. */ +export function TrafficInfo({ children }: { children: ReactNode }) { + const t = useTranslations("trafficStatus"); + const tc = useTranslations("common"); + const [anchor, setAnchor] = useState(null); + const id = useId(); + return ( + <> + { + event.stopPropagation(); + setAnchor(event.currentTarget); + }} + > + + + setAnchor(null)} + onClick={(event) => event.stopPropagation()} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + slotProps={{ + paper: { + id, + role: "dialog", + "aria-label": t("about"), + sx: { p: 2, width: 300, maxWidth: "calc(100vw - 32px)", overflowWrap: "anywhere" }, + }, + }} + > + + {t("about")} + setAnchor(null)}> + + + + {children} + + + ); +} diff --git a/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx index d7771684b..0edf77cc7 100644 --- a/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx +++ b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx @@ -1,34 +1,44 @@ "use client"; +import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTranslations } from "next-intl"; +import type { ReactNode } from "react"; +import { TrafficInfo } from "../components/TrafficInfo"; /** These tile formats expose no upstream timestamp or extent completeness. */ -export function TrafficOverlayContext({ hosted, visible }: { hosted: boolean; visible: boolean }) { +export function TrafficOverlayContext({ + hosted, + visible, + children, +}: { + hosted: boolean; + visible: boolean; + children?: ReactNode; +}) { const t = useTranslations("trafficStatus.overlay"); return ( - - {t(hosted ? "hostedSource" : "ownedSource")} + {!visible && ( - <> - {" "} - · {t("hidden")} - + + {t("hidden")} + )} -
- {t("ageUnknown")} · {t("coverageUnknown")} - {!hosted && ( - <> -
- {t("estimates")} - - )} -
- {t("mapOnly")} -
+ + + {t(hosted ? "hostedSource" : "ownedSource")} + + + {t("ageUnknown")} {t("coverageUnknown")} + + {!hosted && ( + + {t("estimates")} + + )} + {t("mapOnly")} + {children} + +
); } diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index b0a9b0bc9..112116565 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -122,28 +122,26 @@ selected travel time. The system evaluates these in the closure's local timezone fine-grained recurring schedule windows). This prevents routing detours around future closures that haven't started yet or nightly closures during daytime trips. -### Reading traffic evidence - -Motorized route cards separate **road-condition application** from **congestion's -effect on the ETA**. “Application verified” requires this response's validated -Valhalla proof and the server's matching graph/write/engine assessment. It expires -at the evidence lease; missing, conflicting, limited or failed evidence cannot -produce that claim. A new route/provider replaces the status. Failed directions -queries show recovery instead of reusing a previous successful card. - -This proof concerns applied road conditions such as closures and speed caps. It -does not establish that congestion changed the selected path or added a particular -number of minutes. Cards therefore keep congestion's ETA influence explicitly -unverified. A significant difference from `baselineDuration` is labelled **versus -engine baseline**, not a measured traffic delay or a normal journey time. Valhalla -and hosted Stadia routes are identified separately where their source IDs say so; -a provider name alone does not prove self-hosting. - -The proof's evaluation time is a verification time, not an upstream observation -or publication age. Map colors and freshly downloaded tiles do not substitute for -route evidence. The [traffic legends](./map-layers.md#transportation) explain the separate -map-only sources and their currently unknown source age/coverage. This initial -presentation applies to directions cards, not every navigation/EV surface. +### Reading traffic information + +Driving and motorcycle cards keep the travel time prominent. **May not include +traffic delays** means we cannot confirm that current congestion is reflected +in that estimate. Open **About traffic** using the info button for the route +provider and road-update explanation. These details do not interrupt route +selection or Start. + +Available road updates are described as used only when this route has current, +matching engine and server evidence. That explanation expires automatically; +missing, failed or conflicting checks never carry a previous success forward. +Road updates concern closures and speed restrictions, not a measured number of +minutes lost in traffic. The engine's baseline duration remains available in the +API, but its difference is not displayed as a traffic delay or used to color a +card's travel time. + +Map colors are separate from travel-time estimates. The +[traffic legends](./map-layers.md#transportation) explain their sources and +limitations on demand. This presentation applies to directions cards, not every +navigation/EV surface. ## Stop times and dwell @@ -195,8 +193,8 @@ The directions panel exposes the tuning knobs that map onto the routing API: engine's live-traffic request when one is available. The result is graph- and request-dependent: the engine may return only the primary route when no distinct alternative satisfies its cost and safety filters. Baseline durations - are shown for comparison when supplied; a baseline can legitimately be slower - than the live route. + remain available through the API for comparison; they are not displayed as + measured traffic delays and can legitimately be slower than the live route. - **Stops and optimization** — add intermediate waypoints, and ask the engine to reorder them into the shortest trip while keeping the first and last fixed. diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index a5fa06b3e..b3b606624 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -124,12 +124,16 @@ than inventing a speed. These overlays complement the dedicated [public transit](./public-transit.md) and [directions](./directions.md) features rather than replacing them. -Both traffic legends explain their source: owned traffic-flow tiles or hosted -TomTom traffic. They show **source data age unknown** and **coverage unknown for -this view**. The owned vector tiles and TomTom raster tiles expose no usable -upstream/publication timestamp or completeness metadata. Successful tile requests -are not evidence of fresh measurements; typical and estimated flow values can -also color roads. A hidden layer is explicitly labelled hidden. +The **Traffic flow** legend uses a compact **Fast–Slow** color scale. Open +**About traffic** with the info button in either traffic legend for its source +and limitations. The owned legend also explains measured, estimated and typical +readings there, using the same opacity as the renderer. A hidden layer stays +explicitly labelled hidden. + +The tile formats expose no usable upstream/publication time or completeness +metadata, so the details say the update time is unknown and some roads may be +missing. Downloading a tile does not establish fresh measurements. Road colors +do not establish whether traffic delays are included in a route's travel time. Traffic publication/check evidence exists in authenticated admin coverage, but is not exposed by these public tile contracts. The legends do not fetch admin @@ -140,7 +144,7 @@ publication metadata contract remains follow-up work. differ from the displayed overlay: hosted TomTom traffic over a self-hosted Valhalla route is still a map visualization. Directions cards separately explain validated road-condition application and unverified congestion influence; see -[Reading traffic evidence](./directions.md#reading-traffic-evidence). +[Reading traffic information](./directions.md#reading-traffic-information). ### Context-aware layers diff --git a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx index 25a974206..22aee23e4 100644 --- a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx +++ b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@/test"; +import { cleanup, fireEvent, render, screen } from "@/test"; import { useTrafficFlowStore } from "../store"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); @@ -15,6 +15,7 @@ describe("TrafficFlowLegend", () => { it("uses the renderer opacity for the typical confidence swatch", () => { render(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); expect(getComputedStyle(screen.getByTestId("traffic-flow-confidence-typical")).opacity).toBe( "0.6", @@ -25,9 +26,11 @@ describe("TrafficFlowLegend", () => { it("keeps source age and coverage unknown and distinguishes map from routing", () => { useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); const view = render(); + expect(screen.queryByText("trafficStatus.overlay.ownedSource")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); expect(screen.getByText("trafficStatus.overlay.ownedSource")).toBeDefined(); - expect(screen.getByText("trafficStatus.overlay.ageUnknown")).toBeDefined(); - expect(screen.getByText("trafficStatus.overlay.coverageUnknown")).toBeDefined(); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.ageUnknown"); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.coverageUnknown"); expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); expect(screen.getByText("trafficStatus.overlay.estimates")).toBeDefined(); expect(view.container.querySelector("button button")).toBeNull(); @@ -37,3 +40,13 @@ it("explains hidden layer instead of implying map display", () => { render(); expect(screen.getByText("trafficStatus.overlay.hidden")).toBeDefined(); }); + +it("shows a compact speed scale while confidence details remain optional", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); + render(); + expect(screen.getByText("trafficStatus.overlay.fast")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.slow")).toBeDefined(); + expect(screen.queryByTestId("traffic-flow-confidence-typical")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); + expect(screen.getByTestId("traffic-flow-confidence-typical")).toBeDefined(); +}); diff --git a/integrations/overlay-traffic-flow/legend.tsx b/integrations/overlay-traffic-flow/legend.tsx index 5f0f3a2d5..b7209bc49 100644 --- a/integrations/overlay-traffic-flow/legend.tsx +++ b/integrations/overlay-traffic-flow/legend.tsx @@ -16,6 +16,7 @@ export const BANDS: { color: string; key: TrafficBand }[] = ( export function TrafficFlowLegend() { const t = useTranslations("trafficFlow"); + const ts = useTranslations("trafficStatus.overlay"); const panelOpen = useTrafficFlowStore((s) => s.panelOpen); const layerVisible = useTrafficFlowStore((s) => s.layerVisible); const setLayerVisible = useOverlayVisibilitySetter("traffic-flow"); @@ -36,43 +37,42 @@ export function TrafficFlowLegend() { ))} - - {BANDS.map((band) => ( - - {t(`band.${band.key}`)} - - ))} + + + {ts("fast")} + + + {ts("slow")} + - - - {t("confidence.label")} - - - {TRAFFIC_FLOW_CONFIDENCE_STEPS.map((step) => ( - - - - {t(`confidence.${step.key}`)} - - - ))} + + + + {t("confidence.label")} + + + {TRAFFIC_FLOW_CONFIDENCE_STEPS.map((step) => ( + + + + {t(`confidence.${step.key}`)} + + + ))} + - - + ); } diff --git a/integrations/overlay-traffic-tomtom/legend.test.tsx b/integrations/overlay-traffic-tomtom/legend.test.tsx index c83e9e1ac..6e0b7b205 100644 --- a/integrations/overlay-traffic-tomtom/legend.test.tsx +++ b/integrations/overlay-traffic-tomtom/legend.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@/test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@/test"; import { TrafficLegend } from "./legend"; import { useTrafficStore } from "./store"; @@ -14,14 +14,18 @@ beforeEach(() => { useTrafficStore.setState({ panelOpen: true, layerVisible: true }); setVisible.mockClear(); }); -it("explains hosted map-only traffic without inferring age or coverage", () => { +it("explains hosted map-only traffic without inferring age or coverage", async () => { const view = render(); expect(screen.getByText("layers.trafficTomtom")).toBeDefined(); + expect(screen.queryByText("trafficStatus.overlay.hostedSource")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); expect(screen.getByText("trafficStatus.overlay.hostedSource")).toBeDefined(); - expect(screen.getByText("trafficStatus.overlay.ageUnknown")).toBeDefined(); - expect(screen.getByText("trafficStatus.overlay.coverageUnknown")).toBeDefined(); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.ageUnknown"); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.coverageUnknown"); expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); expect(view.container.querySelector("button button")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "common.close" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); fireEvent.click(screen.getByRole("switch", { name: "trafficStatus.overlay.toggleHosted" })); expect(setVisible).toHaveBeenCalledWith(false); }); diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 3e283d4e4..26803fd98 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -892,8 +892,6 @@ "checkFailed": "Straßenbedingungen für diese Route konnten nicht geprüft werden" }, "localAccessRoad": "Nutzt eine gesperrte Straße (Anlieger frei)", - "trafficDelay": "+{delay} gegenüber der Modell-Basiszeit", - "trafficDelayNormally": "Basiszeit {baseline}", "from": "von", "to": "nach", "origin": "Start", @@ -4301,22 +4299,27 @@ "unknown": "Routing-Quelle unbekannt" }, "application": { - "verified": "Berücksichtigung der Straßenbedingungen verifiziert", - "expired": "Nachweis der Straßenbedingungen abgelaufen", - "limited": "Nachweis der Straßenbedingungen eingeschränkt", - "unavailable": "Prüfung der Straßenbedingungen nicht verfügbar", - "unverified": "Berücksichtigung der Straßenbedingungen nicht verifiziert" + "verified": "Verfügbare Straßenmeldungen wurden für diese Route berücksichtigt.", + "expired": "Straßenmeldungen müssen erneut geprüft werden. Berechne die Route erneut.", + "limited": "Einige gemeldete Straßenänderungen fehlen möglicherweise.", + "unavailable": "Straßenmeldungen konnten nicht geprüft werden.", + "unverified": "Es ist unklar, welche Straßenmeldungen berücksichtigt wurden." }, - "congestionUnverified": "Einfluss von Staus auf die Ankunftszeit nicht verifiziert", + "congestionUnverified": "Die Fahrzeit berücksichtigt möglicherweise keine aktuellen Verzögerungen durch Stau.", "overlay": { - "ownedSource": "Selbst gehostete Verkehrsfluss-Kacheln", - "hostedSource": "Gehostete Verkehrsdaten von TomTom", - "ageUnknown": "Alter der Quelldaten unbekannt", - "coverageUnknown": "Abdeckung für diesen Ausschnitt unbekannt", - "mapOnly": "Kartenfarben belegen keinen Verkehrseinfluss auf eine Route", - "estimates": "Typische und geschätzte Werte sind möglicherweise keine aktuellen Messungen", - "hidden": "Ebene ausgeblendet", - "toggleHosted": "TomTom-Verkehr umschalten" - } + "ownedSource": "Verkehrsinformationen von diesem Server.", + "hostedSource": "Verkehrsinformationen von TomTom.", + "ageUnknown": "Der Aktualisierungszeitpunkt ist unbekannt.", + "coverageUnknown": "Einige Straßen fehlen möglicherweise.", + "mapOnly": "Straßenfarben zeigen nicht, ob Verzögerungen in der Fahrzeit berücksichtigt sind.", + "estimates": "Einige Farben basieren auf üblichen oder geschätzten Geschwindigkeiten statt aktuellen Messungen.", + "hidden": "Ausgeblendet", + "toggleHosted": "TomTom-Verkehr umschalten", + "fast": "Schnell", + "slow": "Langsam" + }, + "about": "Verkehrsinformationen", + "summary": "Stau eventuell nicht berücksichtigt", + "provider": "Routenanbieter: {source}" } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index e4ed6d9ab..877e21f00 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -892,8 +892,6 @@ "checkFailed": "Couldn't check road conditions for this route" }, "localAccessRoad": "Uses a road closed except for local access", - "trafficDelay": "+{delay} vs engine baseline", - "trafficDelayNormally": "baseline {baseline}", "from": "from", "to": "to", "origin": "Origin", @@ -4301,22 +4299,27 @@ "unknown": "Routing source unknown" }, "application": { - "verified": "Road-condition application verified", - "expired": "Road-condition evidence expired", - "limited": "Road-condition evidence limited", - "unavailable": "Road-condition check unavailable", - "unverified": "Road-condition application not verified" + "verified": "Available road updates were used for this route.", + "expired": "Road updates need to be checked again. Refresh the route.", + "limited": "Some reported road changes may be missing.", + "unavailable": "Road updates couldn’t be checked.", + "unverified": "We couldn’t confirm which road updates were used." }, - "congestionUnverified": "Congestion effect on ETA not verified", + "congestionUnverified": "This travel time may not include current traffic delays.", "overlay": { - "ownedSource": "Self-hosted traffic-flow tiles", - "hostedSource": "TomTom hosted traffic", - "ageUnknown": "Source data age unknown", - "coverageUnknown": "Coverage unknown for this view", - "mapOnly": "Map colors do not verify traffic influence on a route", - "estimates": "Typical and estimated values may not be current measurements", - "hidden": "Layer hidden", - "toggleHosted": "Toggle TomTom traffic" - } + "ownedSource": "Traffic information from this server.", + "hostedSource": "Traffic information from TomTom.", + "ageUnknown": "The update time is unknown.", + "coverageUnknown": "Some roads may be missing.", + "mapOnly": "Road colors don’t tell you whether delays are included in your travel time.", + "estimates": "Some colors use typical or estimated speeds rather than current measurements.", + "hidden": "Hidden", + "toggleHosted": "Toggle TomTom traffic", + "fast": "Fast", + "slow": "Slow" + }, + "about": "About traffic", + "summary": "May not include traffic delays", + "provider": "Route provider: {source}" } } From cfe1e88e3e7a210778c58b5da3bc223e7098c89c Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 08:18:01 +0200 Subject: [PATCH 5/9] test(traffic): clarify baseline absence assertions --- apps/web/src/components/panels/directions/RouteCard.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 37b9bd481..5739c7edf 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -367,7 +367,8 @@ describe("RouteCard compact traffic explanation", () => { it("does not advertise an engine-baseline delta as traffic delay", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.queryByText(/^\+45 min|baseline/i)).toBeNull(); + expect(screen.queryByText(/^\+45 min/i)).toBeNull(); + expect(screen.queryByText(/baseline/i)).toBeNull(); }); it.each(["route", "peek"] as const)( "opens and closes traffic info without selecting the %s route", From c0c5ad27601920964ef6e0208e8406e44188275d Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 08:35:30 +0200 Subject: [PATCH 6/9] fix(traffic): restore compact delay estimates and color travel times --- .changeset/traffic-evidence-presentation.md | 2 +- .../panels/directions/RouteCard.test.tsx | 70 ++++++++++++++++++- .../panels/directions/RouteCard.tsx | 16 +++-- .../directions/RouteTrafficStatus.test.tsx | 18 +++++ .../panels/directions/RouteTrafficStatus.tsx | 35 +++++++--- apps/web/src/lib/route-traffic-delay.ts | 22 ++++++ docs/docs/features/directions.md | 37 ++++++---- docs/docs/features/map-layers.md | 5 +- packages/i18n/locales/de.json | 4 +- packages/i18n/locales/en.json | 4 +- 10 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/lib/route-traffic-delay.ts diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md index e88a7c150..94fcc2fc9 100644 --- a/.changeset/traffic-evidence-presentation.md +++ b/.changeset/traffic-evidence-presentation.md @@ -5,4 +5,4 @@ "@openmapx/integration-overlay-traffic-tomtom": patch --- -Keep route traffic caveats short and offer plain-language explanations on demand. Use a Fast–Slow traffic legend, preserve request-bound road-update verification, and avoid presenting engine-baseline differences as measured traffic delays. +Show compact estimated traffic delays and color route travel times using the existing severity bands. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 5739c7edf..65ff6c83e 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -364,11 +364,75 @@ describe("RouteCard compact traffic explanation", () => { "May not include traffic delays", ); }); - it("does not advertise an engine-baseline delta as traffic delay", () => { + it("shows a compact delay and describes it to keyboard users", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.queryByText(/^\+45 min/i)).toBeNull(); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min traffic"); expect(screen.queryByText(/baseline/i)).toBeNull(); + expect(screen.queryByText("May not include traffic delays")).toBeNull(); + const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; + expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( + "+45 min traffic", + ); + }); + it.each([ + [3960, "light"], + [4500, "moderate"], + [5400, "heavy"], + [7200, "severe"], + ] as const)( + "colors the travel time for a %s-second route using the %s delay band", + (duration, band) => { + renderCard({ ...baseRoute, duration, baselineDuration: 3600 }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + `var(--omx-traffic-${band})`, + ); + }, + ); + it.each([ + [2640, 2400], // Relative threshold reached, less than five minutes extra. + [5760, 5400], // More than five minutes extra, below ten percent. + [3600, 3600], // Same estimate without current traffic speeds. + [3500, 3600], // Current conditions can be quicker than the comparison. + ])("does not advertise a significant delay for %s / %s seconds", (duration, baselineDuration) => { + renderCard({ ...baseRoute, duration, baselineDuration }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.queryByText("May not include traffic delays")).toBeNull(); + expect(screen.getByRole("button", { name: "About traffic" })).toBeInTheDocument(); + }); + it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "does not fabricate a delay without a usable comparison (%s)", + (baselineDuration) => { + renderCard({ ...baseRoute, baselineDuration }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByText("May not include traffic delays")).toBeInTheDocument(); + }, + ); + it.each(["walking", "cycling"] as const)("does not show traffic delays for %s", (mode) => { + renderCard({ ...baseRoute, mode, duration: 6300, baselineDuration: 3600 }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.queryByRole("button", { name: "About traffic" })).toBeNull(); + }); + it("keeps severity coloring on an unselected motorcycle alternative", () => { + render( + + {}} + onDetails={() => {}} + units="metric" + /> + , + ); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "var(--omx-traffic-heavy)", + ); + expect(screen.getByText("+45 min traffic")).toBeInTheDocument(); + }); + it("localizes the compact delay in German", () => { + renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }, "metric", "de"); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min durch Verkehr"); }); it.each(["route", "peek"] as const)( "opens and closes traffic info without selecting the %s route", diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index 9e2f4f035..955927049 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -35,11 +35,12 @@ import { type RouteImpactAssumptions, RouteImpactDetailsDialog, } from "@/components/panels/directions/RouteImpactDetailsDialog"; -import { BRAND } from "@/integration-api/runtime/theme"; +import { BRAND, TRAFFIC_TEXT_COLOR } from "@/integration-api/runtime/theme"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; import { formatCo2Emission } from "@/lib/formatCo2"; import { useStartNavigation } from "@/lib/mobile/useStartNavigation"; import { primeSpeechSynthesis } from "@/lib/navigation/useNavigationVoice"; +import { routeTrafficDelay } from "@/lib/route-traffic-delay"; import { requestHeadingPermission } from "@/lib/useHeading"; import { useNow } from "@/lib/useNow"; import { RouteTrafficStatus } from "./RouteTrafficStatus"; @@ -146,10 +147,13 @@ export function RouteCard({ const [impactDetailsOpen, setImpactDetailsOpen] = useState(false); const arrivalCaptionId = useId(); const trafficCaptionId = useId(); + const traffic = routeTrafficDelay(route); const selectionDescription = [ route.mode === "driving" && arrivalContext ? arrivalCaptionId : null, - route.mode === "driving" || route.mode === "motorcycle" ? trafficCaptionId : null, + (route.mode === "driving" || route.mode === "motorcycle") && (!traffic || traffic.band) + ? trafficCaptionId + : null, ] .filter(Boolean) .join(" ") || undefined; @@ -240,8 +244,12 @@ export function RouteCard({ <> {formatDuration(route.duration)} diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx index e35b5b952..0debec884 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.test.tsx @@ -120,3 +120,21 @@ it("keeps provider and application details out of the default summary", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); expect(screen.queryByRole("dialog")).toBeNull(); }); + +it("shows the estimate when comparison data exists and replaces it on another route", () => { + const r = { ...route, duration: 6300, baselineDuration: 3600 }; + const rendered = render(view(r)); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min traffic"); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "The extra time is estimated by comparing this route with and without current traffic speeds.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "This travel time may not include current traffic delays.", + ); + rendered.rerender(view(route)); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( + "May not include traffic delays", + ); +}); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index d149d970c..a53c0c469 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -2,10 +2,12 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; +import { formatDuration, type RoadConditionRouteImpact, type Route } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { TrafficInfo } from "@/integration-api/components/TrafficInfo"; +import { TRAFFIC_TEXT_COLOR } from "@/integration-api/runtime/theme"; +import { routeTrafficDelay } from "@/lib/route-traffic-delay"; import { routeTrafficStatus } from "@/lib/route-traffic-status"; export function RouteTrafficStatus({ @@ -37,20 +39,31 @@ export function RouteTrafficStatus({ return () => clearTimeout(timer); }, [deadline]); if (!status) return null; + const traffic = routeTrafficDelay(route); + const caption = traffic?.band + ? t("delay", { delay: formatDuration(traffic.seconds) }) + : !traffic + ? t("summary") + : null; return ( - - {t("summary")} - + {caption && ( + + {caption} + + )} - {t("congestionUnverified")} + {t(traffic ? "estimateExplanation" : "congestionUnverified")} {t(`application.${status.application}`)} diff --git a/apps/web/src/lib/route-traffic-delay.ts b/apps/web/src/lib/route-traffic-delay.ts new file mode 100644 index 000000000..485a168c0 --- /dev/null +++ b/apps/web/src/lib/route-traffic-delay.ts @@ -0,0 +1,22 @@ +import { bandForDelayRatio, type DelayBand, type Route } from "@openmapx/core"; + +/** Same-path estimate against the engine recosting that excludes current speeds. */ +export function routeTrafficDelay( + route: Route, +): { seconds: number; band: DelayBand | null } | null { + const baseline = route.baselineDuration; + if ( + (route.mode !== "driving" && route.mode !== "motorcycle") || + typeof baseline !== "number" || + !Number.isFinite(baseline) || + baseline <= 0 || + !Number.isFinite(route.duration) || + route.duration < 0 + ) + return null; + const seconds = route.duration - baseline; + return { + seconds, + band: seconds >= 300 ? bandForDelayRatio(seconds / baseline) : null, + }; +} diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 112116565..009687dca 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -124,19 +124,26 @@ future closures that haven't started yet or nightly closures during daytime trip ### Reading traffic information -Driving and motorcycle cards keep the travel time prominent. **May not include -traffic delays** means we cannot confirm that current congestion is reflected -in that estimate. Open **About traffic** using the info button for the route -provider and road-update explanation. These details do not interrupt route -selection or Start. - -Available road updates are described as used only when this route has current, -matching engine and server evidence. That explanation expires automatically; -missing, failed or conflicting checks never carry a previous success forward. -Road updates concern closures and speed restrictions, not a measured number of -minutes lost in traffic. The engine's baseline duration remains available in the -API, but its difference is not displayed as a traffic delay or used to color a -card's travel time. +Driving and motorcycle cards color the travel time by the estimated traffic +delay and show a short caption such as **+12 min traffic**. The estimate compares +the same route with and without current traffic speeds. It appears when the +extra time is at least five minutes and at least 10% of the comparison duration; +the existing severity bands distinguish light, moderate, heavy and severe +delays. The text supplies the amount so color is not the only signal. + +**May not include traffic delays** is shown only when the route has no usable +comparison duration. A comparison below the display thresholds does not produce +an “all clear” claim. Open **About traffic** with the info button for a +plain-language explanation of the estimate, road updates and the route provider. +These details do not interrupt route selection or Start. + +Road updates are described as used only when this route has current, matching +engine and server evidence. That explanation expires automatically; missing, +failed or conflicting checks never carry a previous success forward. This +road-update verification is separate from the engine's estimated delay. The +estimate reflects the selected path at calculation time, can include applied +speed restrictions, and does not establish a fresh congestion measurement for +every road or the extra time versus a different traffic-free route. Map colors are separate from travel-time estimates. The [traffic legends](./map-layers.md#transportation) explain their sources and @@ -193,8 +200,8 @@ The directions panel exposes the tuning knobs that map onto the routing API: engine's live-traffic request when one is available. The result is graph- and request-dependent: the engine may return only the primary route when no distinct alternative satisfies its cost and safety filters. Baseline durations - remain available through the API for comparison; they are not displayed as - measured traffic delays and can legitimately be slower than the live route. + supply the same-path estimated traffic-delay comparison shown on route cards; + they can legitimately be slower than the live route. - **Stops and optimization** — add intermediate waypoints, and ask the engine to reorder them into the shortest trip while keeping the first and last fixed. diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index b3b606624..ed943e5c3 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -142,8 +142,9 @@ publication metadata contract remains follow-up work. **Map colors do not verify traffic influence on a route.** The routing engine can differ from the displayed overlay: hosted TomTom traffic over a self-hosted -Valhalla route is still a map visualization. Directions cards separately explain -validated road-condition application and unverified congestion influence; see +Valhalla route is still a map visualization. Directions cards show estimated +traffic delays when comparison data is available and explain road-update +verification separately; see [Reading traffic information](./directions.md#reading-traffic-information). ### Context-aware layers diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 26803fd98..50dbd7652 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4320,6 +4320,8 @@ }, "about": "Verkehrsinformationen", "summary": "Stau eventuell nicht berücksichtigt", - "provider": "Routenanbieter: {source}" + "provider": "Routenanbieter: {source}", + "delay": "+{delay} durch Verkehr", + "estimateExplanation": "Die zusätzliche Zeit wird geschätzt, indem diese Route mit und ohne aktuelle Verkehrsgeschwindigkeiten verglichen wird. Tatsächliche Verzögerungen können abweichen." } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 877e21f00..f91990d1b 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4320,6 +4320,8 @@ }, "about": "About traffic", "summary": "May not include traffic delays", - "provider": "Route provider: {source}" + "provider": "Route provider: {source}", + "delay": "+{delay} traffic", + "estimateExplanation": "The extra time is estimated by comparing this route with and without current traffic speeds. Actual delays can vary." } } From 5ea1562aba43c04c790d20d10318b74555a93d46 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 18:23:48 +0200 Subject: [PATCH 7/9] fix(traffic): distinguish unknown data from fresh low-delay estimates --- .changeset/traffic-evidence-presentation.md | 3 +- .../panels/directions/RouteCard.test.tsx | 148 +++++++++++++++++- .../panels/directions/RouteCard.tsx | 17 +- .../directions/RouteTrafficStatus.test.tsx | 10 +- .../panels/directions/RouteTrafficStatus.tsx | 27 ++-- apps/web/src/integration-api/runtime/theme.ts | 5 +- apps/web/src/lib/route-traffic-delay.ts | 32 ++++ .../src/lib/useRouteTrafficPresentation.ts | 25 +++ docs/docs/features/directions.md | 27 +++- packages/core/src/types/routing.ts | 12 ++ packages/i18n/locales/de.json | 5 +- packages/i18n/locales/en.json | 5 +- 12 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/lib/useRouteTrafficPresentation.ts diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md index 94fcc2fc9..82e6c2a52 100644 --- a/.changeset/traffic-evidence-presentation.md +++ b/.changeset/traffic-evidence-presentation.md @@ -1,8 +1,9 @@ --- "web": patch "@openmapx/i18n": patch +"@openmapx/core": patch "@openmapx/integration-overlay-traffic-flow": patch "@openmapx/integration-overlay-traffic-tomtom": patch --- -Show compact estimated traffic delays and color route travel times using the existing severity bands. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. +Show compact estimated traffic delays and color route travel times using the existing severity bands. Use normal text and an unavailable-data note for unknown congestion coverage; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 65ff6c83e..4c0751cd5 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -361,17 +361,17 @@ describe("RouteCard compact traffic explanation", () => { renderCard(baseRoute); const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( - "May not include traffic delays", + "Traffic data unavailable", ); }); it("shows a compact delay and describes it to keyboard users", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min traffic"); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); expect(screen.queryByText(/baseline/i)).toBeNull(); - expect(screen.queryByText("May not include traffic delays")).toBeNull(); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( - "+45 min traffic", + "+45 min", ); }); it.each([ @@ -396,7 +396,10 @@ describe("RouteCard compact traffic explanation", () => { ])("does not advertise a significant delay for %s / %s seconds", (duration, baselineDuration) => { renderCard({ ...baseRoute, duration, baselineDuration }); expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.queryByText("May not include traffic delays")).toBeNull(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); expect(screen.getByRole("button", { name: "About traffic" })).toBeInTheDocument(); }); it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])( @@ -404,7 +407,7 @@ describe("RouteCard compact traffic explanation", () => { (baselineDuration) => { renderCard({ ...baseRoute, baselineDuration }); expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.getByText("May not include traffic delays")).toBeInTheDocument(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); }, ); it.each(["walking", "cycling"] as const)("does not show traffic delays for %s", (mode) => { @@ -428,11 +431,11 @@ describe("RouteCard compact traffic explanation", () => { expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( "var(--omx-traffic-heavy)", ); - expect(screen.getByText("+45 min traffic")).toBeInTheDocument(); + expect(screen.getByText("+45 min")).toBeInTheDocument(); }); it("localizes the compact delay in German", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }, "metric", "de"); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min durch Verkehr"); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); }); it.each(["route", "peek"] as const)( "opens and closes traffic info without selecting the %s route", @@ -996,3 +999,132 @@ describe("RouteCard impact integration", () => { expect(screen.getByText("Fastest route")).toBeDefined(); }); }); + +describe("RouteCard current congestion coverage", () => { + const now = Date.parse("2026-10-07T12:00:00Z"); + const freshCoverage = { + complete: true, + evaluatedAt: "2026-10-07T11:59:59Z", + validUntil: "2026-10-07T12:00:30Z", + }; + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => vi.useRealTimers()); + + it.each([3600, 3500, 3840, 3899])( + "uses green without a hint for a fresh %s-second comparison", + (duration) => { + renderCard({ + ...baseRoute, + duration, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "var(--omx-brand)", + ); + expect(screen.queryByTestId("route-traffic-status")).toBeNull(); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + const describedBy = screen.getByRole("radio").getAttribute("aria-describedby"); + expect(describedBy).toBeNull(); + }, + ); + + it.each([ + undefined, + { ...freshCoverage, complete: false }, + { ...freshCoverage, evaluatedAt: "2026-10-07T12:00:01Z" }, + { ...freshCoverage, validUntil: "2026-10-07T12:00:00Z" }, + { ...freshCoverage, validUntil: "invalid" }, + { ...freshCoverage, evaluatedAt: "invalid" }, + { ...freshCoverage, validUntil: "2026-10-07T12:05:00Z" }, + ])("does not turn a zero delay green with insufficient evidence (%j)", (trafficCoverage) => { + renderCard({ ...baseRoute, duration: 3600, baselineDuration: 3600, trafficCoverage }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("expires green and updates both the duration and its accessible note", () => { + renderCard({ + ...baseRoute, + duration: 3600, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + act(() => vi.advanceTimersByTime(30000)); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + const id = screen.getByRole("radio").getAttribute("aria-describedby"); + expect(id && document.getElementById(id)).toHaveTextContent("Traffic data unavailable"); + }); + + it("rearms freshness expiration when the wall clock moves backwards", () => { + renderCard({ + ...baseRoute, + duration: 3600, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + vi.setSystemTime(now - 10000); + act(() => vi.advanceTimersByTime(30000)); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + act(() => vi.advanceTimersByTime(10000)); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("still requires a usable comparison even with complete fresh coverage", () => { + renderCard({ ...baseRoute, trafficCoverage: freshCoverage }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("keeps significant delays colored even with complete coverage", () => { + renderCard({ + ...baseRoute, + duration: 6300, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "var(--omx-traffic-heavy)", + ); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + }); +}); + +describe("RouteCard expired congestion estimates", () => { + const now = Date.parse("2026-10-07T12:00:00Z"); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => vi.useRealTimers()); + it("drops the delay color and caption when supplied congestion evidence expires", () => { + renderCard({ + ...baseRoute, + duration: 6300, + baselineDuration: 3600, + trafficCoverage: { + complete: true, + evaluatedAt: "2026-10-07T11:59:59Z", + validUntil: "2026-10-07T12:00:30Z", + }, + }); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + act(() => vi.advanceTimersByTime(30000)); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + }); +}); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index 955927049..cda5fa843 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -40,9 +40,9 @@ import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; import { formatCo2Emission } from "@/lib/formatCo2"; import { useStartNavigation } from "@/lib/mobile/useStartNavigation"; import { primeSpeechSynthesis } from "@/lib/navigation/useNavigationVoice"; -import { routeTrafficDelay } from "@/lib/route-traffic-delay"; import { requestHeadingPermission } from "@/lib/useHeading"; import { useNow } from "@/lib/useNow"; +import { useRouteTrafficPresentation } from "@/lib/useRouteTrafficPresentation"; import { RouteTrafficStatus } from "./RouteTrafficStatus"; import { type RouteArrivalContext, resolveRouteArrival } from "./routeArrival"; @@ -147,11 +147,11 @@ export function RouteCard({ const [impactDetailsOpen, setImpactDetailsOpen] = useState(false); const arrivalCaptionId = useId(); const trafficCaptionId = useId(); - const traffic = routeTrafficDelay(route); + const traffic = useRouteTrafficPresentation(route); const selectionDescription = [ route.mode === "driving" && arrivalContext ? arrivalCaptionId : null, - (route.mode === "driving" || route.mode === "motorcycle") && (!traffic || traffic.band) + (route.mode === "driving" || route.mode === "motorcycle") && traffic.kind !== "clear" ? trafficCaptionId : null, ] @@ -245,7 +245,16 @@ export function RouteCard({ it("keeps provider and application details out of the default summary", () => { render(view()); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "May not include traffic delays", - ); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("Traffic data unavailable"); expect(screen.queryByText(/Self-hosted Valhalla/)).toBeNull(); expect(screen.queryByText("Available road updates were used for this route.")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "About traffic" })); @@ -124,7 +122,7 @@ it("keeps provider and application details out of the default summary", () => { it("shows the estimate when comparison data exists and replaces it on another route", () => { const r = { ...route, duration: 6300, baselineDuration: 3600 }; const rendered = render(view(r)); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min traffic"); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min"); fireEvent.click(screen.getByRole("button", { name: "About traffic" })); expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( "The extra time is estimated by comparing this route with and without current traffic speeds.", @@ -134,7 +132,5 @@ it("shows the estimate when comparison data exists and replaces it on another ro ); rendered.rerender(view(route)); expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent( - "May not include traffic delays", - ); + expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("Traffic data unavailable"); }); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index a53c0c469..0ada2160c 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -7,8 +7,8 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { TrafficInfo } from "@/integration-api/components/TrafficInfo"; import { TRAFFIC_TEXT_COLOR } from "@/integration-api/runtime/theme"; -import { routeTrafficDelay } from "@/lib/route-traffic-delay"; import { routeTrafficStatus } from "@/lib/route-traffic-status"; +import { useRouteTrafficPresentation } from "@/lib/useRouteTrafficPresentation"; export function RouteTrafficStatus({ route, @@ -38,13 +38,14 @@ export function RouteTrafficStatus({ expire(); return () => clearTimeout(timer); }, [deadline]); + const traffic = useRouteTrafficPresentation(route); if (!status) return null; - const traffic = routeTrafficDelay(route); - const caption = traffic?.band - ? t("delay", { delay: formatDuration(traffic.seconds) }) - : !traffic - ? t("summary") - : null; + const caption = + traffic.kind === "delay" + ? t("delay", { delay: formatDuration(traffic.seconds) }) + : traffic.kind === "unavailable" + ? t("summary") + : null; return ( {caption && ( @@ -52,9 +53,9 @@ export function RouteTrafficStatus({ id={id} component="div" variant="caption" - data-testid={traffic?.band ? "traffic-delay" : "route-traffic-status"} + data-testid={traffic.kind === "delay" ? "traffic-delay" : "route-traffic-status"} sx={{ - color: traffic?.band ? TRAFFIC_TEXT_COLOR[traffic.band] : "text.secondary", + color: traffic.kind === "delay" ? TRAFFIC_TEXT_COLOR[traffic.band] : "text.secondary", overflowWrap: "anywhere", }} > @@ -63,7 +64,13 @@ export function RouteTrafficStatus({ )} - {t(traffic ? "estimateExplanation" : "congestionUnverified")} + {t( + traffic.kind === "delay" + ? "estimateExplanation" + : traffic.kind === "clear" + ? "coverageExplanation" + : "congestionUnverified", + )} {t(`application.${status.application}`)} diff --git a/apps/web/src/integration-api/runtime/theme.ts b/apps/web/src/integration-api/runtime/theme.ts index 7d697713e..9f8d762f1 100644 --- a/apps/web/src/integration-api/runtime/theme.ts +++ b/apps/web/src/integration-api/runtime/theme.ts @@ -17,9 +17,8 @@ export const BRAND_HEX = "#207E23"; /** * Traffic-delay text colours, keyed by the shared `TrafficBand` names. CSS * variables rather than hexes so light/dark switching stays in CSS, matching - * BRAND above. `freeFlow` is absent by design: a delay below the display - * threshold is not shown at all, and a green ETA would imply we had verified - * the route is clear. + * BRAND above. Green ETAs use BRAND separately and require complete, fresh + * congestion coverage; a small recosting difference alone is insufficient. */ export const TRAFFIC_TEXT_COLOR: Record = { light: "var(--omx-traffic-light)", diff --git a/apps/web/src/lib/route-traffic-delay.ts b/apps/web/src/lib/route-traffic-delay.ts index 485a168c0..7d474859c 100644 --- a/apps/web/src/lib/route-traffic-delay.ts +++ b/apps/web/src/lib/route-traffic-delay.ts @@ -20,3 +20,35 @@ export function routeTrafficDelay( band: seconds >= 300 ? bandForDelayRatio(seconds / baseline) : null, }; } + +export type RouteTrafficPresentation = + | { kind: "unavailable"; deadline: null } + | { kind: "clear"; deadline: number } + | { kind: "delay"; seconds: number; band: DelayBand; deadline: number | null }; + +/** A zero recosting difference cannot establish fresh congestion coverage. */ +export function routeTrafficPresentation(route: Route, now: number): RouteTrafficPresentation { + const delay = routeTrafficDelay(route); + const coverage = route.trafficCoverage; + const evaluatedAt = + typeof coverage?.evaluatedAt === "string" ? Date.parse(coverage.evaluatedAt) : NaN; + const validUntil = + typeof coverage?.validUntil === "string" ? Date.parse(coverage.validUntil) : NaN; + const fresh = + [now, evaluatedAt, validUntil].every(Number.isFinite) && + evaluatedAt <= now && + now < validUntil && + validUntil - evaluatedAt <= 120_000; + // Supplied evidence must not keep an expired or malformed estimate alive. + if (coverage !== undefined && !fresh) return { kind: "unavailable", deadline: null }; + // A positive estimate remains useful even without route-wide coverage. + if (delay?.band) + return { + kind: "delay", + seconds: delay.seconds, + band: delay.band, + deadline: fresh ? validUntil : null, + }; + if (delay && coverage?.complete === true && fresh) return { kind: "clear", deadline: validUntil }; + return { kind: "unavailable", deadline: null }; +} diff --git a/apps/web/src/lib/useRouteTrafficPresentation.ts b/apps/web/src/lib/useRouteTrafficPresentation.ts new file mode 100644 index 000000000..23cfa1726 --- /dev/null +++ b/apps/web/src/lib/useRouteTrafficPresentation.ts @@ -0,0 +1,25 @@ +"use client"; + +import type { Route } from "@openmapx/core"; +import { useEffect, useState } from "react"; +import { routeTrafficPresentation } from "./route-traffic-delay"; + +/** Re-evaluate a held route when its congestion evidence expires. */ +export function useRouteTrafficPresentation(route: Route) { + const [, refresh] = useState(0); + const presentation = routeTrafficPresentation(route, Date.now()); + const deadline = presentation.deadline; + useEffect(() => { + if (deadline === null) return; + let timer: ReturnType | undefined; + const expire = () => { + const remaining = deadline - Date.now(); + // A backwards clock correction must not expire the lease early. + if (remaining > 0) timer = setTimeout(expire, remaining); + else refresh((revision) => revision + 1); + }; + expire(); + return () => clearTimeout(timer); + }, [deadline]); + return presentation; +} diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 009687dca..5cf289c1b 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -125,17 +125,32 @@ future closures that haven't started yet or nightly closures during daytime trip ### Reading traffic information Driving and motorcycle cards color the travel time by the estimated traffic -delay and show a short caption such as **+12 min traffic**. The estimate compares +delay and show a short caption such as **+12 min**. The estimate compares the same route with and without current traffic speeds. It appears when the extra time is at least five minutes and at least 10% of the comparison duration; the existing severity bands distinguish light, moderate, heavy and severe delays. The text supplies the amount so color is not the only signal. -**May not include traffic delays** is shown only when the route has no usable -comparison duration. A comparison below the display thresholds does not produce -an “all clear” claim. Open **About traffic** with the info button for a -plain-language explanation of the estimate, road updates and the route provider. -These details do not interrupt route selection or Start. +When there is no usable delay estimate or congestion data is missing, +incomplete or stale, the duration uses the normal text color and the card shows +**Traffic data unavailable**. A route with fresh, complete congestion coverage +and a comparison below the display thresholds has a **green duration** and no +additional caption. Significant estimated delays retain their severity color +and the short **+12 min** caption. + +A zero delay comparison alone cannot establish fresh congestion coverage. +The route contract's optional `trafficCoverage` evidence must confirm that +current congestion speeds cover the exact route and were used in its duration; +its lease expires within two minutes of evaluation. Current routing providers +**do not yet emit this evidence**, so low-delay comparisons currently use the +unavailable state rather than green. Adding trustworthy engine-side congestion +coverage remains part of the traffic follow-up; closure proofs and map-layer +colors cannot substitute for it. Positive delay estimates remain useful even +without complete coverage of every road. + +Open **About traffic** with the info button for a plain-language explanation of +the estimate, road updates and the route provider. These details do not interrupt +route selection or Start. Road updates are described as used only when this route has current, matching engine and server evidence. That explanation expires automatically; missing, diff --git a/packages/core/src/types/routing.ts b/packages/core/src/types/routing.ts index 10dcc39a9..644b8f77f 100644 --- a/packages/core/src/types/routing.ts +++ b/packages/core/src/types/routing.ts @@ -147,6 +147,18 @@ export interface Route { * delay attributable to current conditions. */ baselineDuration?: number; + /** + * Provider evidence that fresh congestion speeds cover this exact route and + * were used in its duration. A comparison duration or road-closure proof is + * insufficient. Only complete coverage permits a green, low-delay ETA. + * Leases must be at most two minutes from evaluatedAt. Current providers do + * not yet emit this evidence; absent/partial/expired evidence means unknown. + */ + trafficCoverage?: { + complete: boolean; + evaluatedAt: string; + validUntil: string; + }; /** * The route drives along a road closed to all but local access ("Anlieger * frei"): it starts or ends on one, or can be reached only through one. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 50dbd7652..3adc74d40 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4319,9 +4319,10 @@ "slow": "Langsam" }, "about": "Verkehrsinformationen", - "summary": "Stau eventuell nicht berücksichtigt", + "summary": "Keine Verkehrsdaten verfügbar", "provider": "Routenanbieter: {source}", - "delay": "+{delay} durch Verkehr", + "delay": "+{delay}", + "coverageExplanation": "Die Fahrzeit berücksichtigt den aktuellen Verkehr.", "estimateExplanation": "Die zusätzliche Zeit wird geschätzt, indem diese Route mit und ohne aktuelle Verkehrsgeschwindigkeiten verglichen wird. Tatsächliche Verzögerungen können abweichen." } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index f91990d1b..6872d1060 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4319,9 +4319,10 @@ "slow": "Slow" }, "about": "About traffic", - "summary": "May not include traffic delays", + "summary": "Traffic data unavailable", "provider": "Route provider: {source}", - "delay": "+{delay} traffic", + "delay": "+{delay}", + "coverageExplanation": "Current traffic is included in this travel time.", "estimateExplanation": "The extra time is estimated by comparing this route with and without current traffic speeds. Actual delays can vary." } } From 322bfa52c66ceb18bb9e81e61c85fe032c0ec1ee Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 18:58:01 +0200 Subject: [PATCH 8/9] fix(traffic): compact route headers and align info controls --- .changeset/traffic-evidence-presentation.md | 2 +- .../panels/directions/RouteCard.test.tsx | 47 +++++++- .../panels/directions/RouteCard.tsx | 109 ++++++++++++------ .../directions/RouteTrafficStatus.test.tsx | 9 +- .../panels/directions/RouteTrafficStatus.tsx | 62 +++------- docs/docs/features/directions.md | 6 +- 6 files changed, 142 insertions(+), 93 deletions(-) diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md index 82e6c2a52..3925a18ad 100644 --- a/.changeset/traffic-evidence-presentation.md +++ b/.changeset/traffic-evidence-presentation.md @@ -6,4 +6,4 @@ "@openmapx/integration-overlay-traffic-tomtom": patch --- -Show compact estimated traffic delays and color route travel times using the existing severity bands. Use normal text and an unavailable-data note for unknown congestion coverage; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. +Show parenthesized estimated traffic delays inline with the duration, align the info button above the distance, and color route travel times using the existing severity bands. Use normal text and an unavailable-data note for unknown congestion coverage; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 4c0751cd5..991911e29 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -366,7 +366,7 @@ describe("RouteCard compact traffic explanation", () => { }); it("shows a compact delay and describes it to keyboard users", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); expect(screen.queryByText(/baseline/i)).toBeNull(); expect(screen.queryByText("Traffic data unavailable")).toBeNull(); const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; @@ -431,11 +431,11 @@ describe("RouteCard compact traffic explanation", () => { expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( "var(--omx-traffic-heavy)", ); - expect(screen.getByText("+45 min")).toBeInTheDocument(); + expect(screen.getByText("(+45 min)")).toBeInTheDocument(); }); it("localizes the compact delay in German", () => { renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }, "metric", "de"); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); }); it.each(["route", "peek"] as const)( "opens and closes traffic info without selecting the %s route", @@ -1096,7 +1096,7 @@ describe("RouteCard current congestion coverage", () => { expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( "var(--omx-traffic-heavy)", ); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); expect(screen.queryByText("Traffic data unavailable")).toBeNull(); }); }); @@ -1119,7 +1119,7 @@ describe("RouteCard expired congestion estimates", () => { validUntil: "2026-10-07T12:00:30Z", }, }); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\+45 min$/); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); act(() => vi.advanceTimersByTime(30000)); expect(screen.queryByTestId("traffic-delay")).toBeNull(); expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); @@ -1128,3 +1128,40 @@ describe("RouteCard expired congestion estimates", () => { ); }); }); + +describe("RouteCard compact traffic header", () => { + it("shows the parenthesized delay directly in the duration heading", () => { + renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); + expect(screen.getByRole("heading", { level: 6 })).toHaveTextContent("1 h 45 min (+45 min)"); + expect( + screen.getByRole("heading", { level: 6 }).contains(screen.getByTestId("traffic-delay")), + ).toBe(true); + expect(screen.getAllByTestId("traffic-delay")).toHaveLength(1); + }); + + it.each(["route", "peek"] as const)( + "keeps the info button outside the %s selection control", + (selectionKind) => { + const onSelect = vi.fn(); + render( + + {}} + units="metric" + /> + , + ); + const button = screen.getByRole("button", { name: "About traffic" }); + expect(button.closest("label")).toBeNull(); + expect(button.parentElement?.closest("button")).toBeNull(); + fireEvent.click(button); + expect(screen.getByRole("dialog", { name: "About traffic" })).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index cda5fa843..e40196554 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -133,6 +133,7 @@ export function RouteCard({ const t = useTranslations("directions"); const tc = useTranslations("common"); const tNav = useTranslations("navigation"); + const tTraffic = useTranslations("trafficStatus"); const locale = useLocale(); const { startGround } = useStartNavigation(); const waypoints = useDirectionsStore((s) => s.waypoints); @@ -258,9 +259,27 @@ export function RouteCard({ fontWeight: 700, lineHeight: 1.25, fontVariantNumeric: "tabular-nums", + display: "flex", + alignItems: "baseline", + flexWrap: "wrap", + columnGap: 0.5, + pr: route.mode === "driving" || route.mode === "motorcycle" ? 4 : 0, + py: route.mode === "driving" || route.mode === "motorcycle" ? 0.5 : 0, }} > - {formatDuration(route.duration)} + {formatDuration(route.duration)} + {traffic.kind === "delay" && ( + + {" "} + ({tTraffic("delay", { delay: formatDuration(traffic.seconds) })}) + + )} @@ -343,46 +362,64 @@ export function RouteCard({ transition: "background-color 0.15s", }} > - {modeIcon} + + {modeIcon} + - {selectionKind === "peek" ? ( - - {summaryContent} - - ) : ( - + + {selectionKind === "peek" ? ( { - if (active) onSelect(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") onSelect(); - }} - sx={{ position: "absolute", opacity: 0, width: "1px", height: "1px", m: 0 }} - /> - {summaryContent} + onClick={onSelect} + sx={selectionSx} + > + {summaryContent} + + ) : ( + + { + if (active) onSelect(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onSelect(); + }} + sx={{ position: "absolute", opacity: 0, width: "1px", height: "1px", m: 0 }} + /> + {summaryContent} + + )} + + - )} - + + {(route.mode === "driving" || route.mode === "motorcycle") && + traffic.kind === "unavailable" && ( + + {tTraffic("summary")} + + )} {impact ? ( it("keeps provider and application details out of the default summary", () => { render(view()); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("Traffic data unavailable"); expect(screen.queryByText(/Self-hosted Valhalla/)).toBeNull(); expect(screen.queryByText("Available road updates were used for this route.")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "About traffic" })); @@ -119,10 +118,9 @@ it("keeps provider and application details out of the default summary", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); -it("shows the estimate when comparison data exists and replaces it on another route", () => { +it("updates the explanation when a new route has no estimate", () => { const r = { ...route, duration: 6300, baselineDuration: 3600 }; const rendered = render(view(r)); - expect(screen.getByTestId("traffic-delay")).toHaveTextContent("+45 min"); fireEvent.click(screen.getByRole("button", { name: "About traffic" })); expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( "The extra time is estimated by comparing this route with and without current traffic speeds.", @@ -131,6 +129,7 @@ it("shows the estimate when comparison data exists and replaces it on another ro "This travel time may not include current traffic delays.", ); rendered.rerender(view(route)); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); - expect(screen.getByTestId("route-traffic-status")).toHaveTextContent("Traffic data unavailable"); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "This travel time may not include current traffic delays.", + ); }); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index 0ada2160c..168ad1211 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -1,12 +1,10 @@ "use client"; -import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { formatDuration, type RoadConditionRouteImpact, type Route } from "@openmapx/core"; +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { TrafficInfo } from "@/integration-api/components/TrafficInfo"; -import { TRAFFIC_TEXT_COLOR } from "@/integration-api/runtime/theme"; import { routeTrafficStatus } from "@/lib/route-traffic-status"; import { useRouteTrafficPresentation } from "@/lib/useRouteTrafficPresentation"; @@ -14,12 +12,10 @@ export function RouteTrafficStatus({ route, impact, provider, - id, }: { route: Route; impact?: RoadConditionRouteImpact; provider?: string; - id?: string; }) { const t = useTranslations("trafficStatus"); const [revision, setRevision] = useState(0); @@ -40,45 +36,23 @@ export function RouteTrafficStatus({ }, [deadline]); const traffic = useRouteTrafficPresentation(route); if (!status) return null; - const caption = - traffic.kind === "delay" - ? t("delay", { delay: formatDuration(traffic.seconds) }) - : traffic.kind === "unavailable" - ? t("summary") - : null; return ( - - {caption && ( - - {caption} - - )} - - - {t( - traffic.kind === "delay" - ? "estimateExplanation" - : traffic.kind === "clear" - ? "coverageExplanation" - : "congestionUnverified", - )} - - - {t(`application.${status.application}`)} - - - {t("provider", { source: t(`source.${status.source}`) })} - - - + + + {t( + traffic.kind === "delay" + ? "estimateExplanation" + : traffic.kind === "clear" + ? "coverageExplanation" + : "congestionUnverified", + )} + + + {t(`application.${status.application}`)} + + + {t("provider", { source: t(`source.${status.source}`) })} + + ); } diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 5cf289c1b..744391921 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -125,7 +125,7 @@ future closures that haven't started yet or nightly closures during daytime trip ### Reading traffic information Driving and motorcycle cards color the travel time by the estimated traffic -delay and show a short caption such as **+12 min**. The estimate compares +delay and show a short caption such as **(+12 min)** directly after the duration. The estimate compares the same route with and without current traffic speeds. It appears when the extra time is at least five minutes and at least 10% of the comparison duration; the existing severity bands distinguish light, moderate, heavy and severe @@ -136,7 +136,9 @@ incomplete or stale, the duration uses the normal text color and the card shows **Traffic data unavailable**. A route with fresh, complete congestion coverage and a comparison below the display thresholds has a **green duration** and no additional caption. Significant estimated delays retain their severity color -and the short **+12 min** caption. +and the inline **(+12 min)** amount. The **About traffic** button is aligned +with the duration, above the distance, for all traffic states. The unavailable +note sits directly below the route summary without a separate icon row. A zero delay comparison alone cannot establish fresh congestion coverage. The route contract's optional `trafficCoverage` evidence must confirm that From 2769a875e0249986177f11cc6f8c0ab3396da311 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 19:39:02 +0200 Subject: [PATCH 9/9] fix(traffic): hide unavailable-data note from route cards --- .changeset/traffic-evidence-presentation.md | 2 +- .../panels/directions/RouteCard.test.tsx | 32 +++++++++++++++++++ .../panels/directions/RouteCard.tsx | 19 ++++++++--- .../directions/RouteTrafficStatus.test.tsx | 9 ++++++ .../panels/directions/RouteTrafficStatus.tsx | 5 +++ docs/docs/features/directions.md | 8 ++--- 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md index 3925a18ad..88fd6fdf4 100644 --- a/.changeset/traffic-evidence-presentation.md +++ b/.changeset/traffic-evidence-presentation.md @@ -6,4 +6,4 @@ "@openmapx/integration-overlay-traffic-tomtom": patch --- -Show parenthesized estimated traffic delays inline with the duration, align the info button above the distance, and color route travel times using the existing severity bands. Use normal text and an unavailable-data note for unknown congestion coverage; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. +Show parenthesized estimated traffic delays inline with the duration, align the info button above the distance, and color route travel times using the existing severity bands. Use normal text for unknown congestion coverage, explaining unavailable data in the optional info popover and accessible route description; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 991911e29..747c90ce6 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -1165,3 +1165,35 @@ describe("RouteCard compact traffic header", () => { }, ); }); + +it.each(["route", "peek"] as const)( + "keeps unknown traffic available to assistive technology without a visible row (%s)", + (selectionKind) => { + render( + + {}} + onDetails={() => {}} + units="metric" + /> + , + ); + const selection = + selectionKind === "route" + ? screen.getByRole("radio") + : screen.getByRole("button", { name: /via A46/ }); + const id = selection.getAttribute("aria-describedby"); + const description = id ? document.getElementById(id) : null; + expect(description).toHaveTextContent("Traffic data unavailable"); + expect(description && getComputedStyle(description).position).toBe("absolute"); + expect(description && getComputedStyle(description).width).toBe("1px"); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic data unavailable", + ); + }, +); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index e40196554..2cc83c844 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -410,15 +410,24 @@ export function RouteCard({ {(route.mode === "driving" || route.mode === "motorcycle") && traffic.kind === "unavailable" && ( - {tTraffic("summary")} - + )} {impact ? ( { "This travel time may not include current traffic delays.", ); }); + +it("states that traffic data is unavailable inside the optional explanation", () => { + render(view()); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic data unavailable", + ); +}); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx index 168ad1211..5d1cfd481 100644 --- a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -38,6 +38,11 @@ export function RouteTrafficStatus({ if (!status) return null; return ( + {traffic.kind === "unavailable" && ( + + {t("summary")} + + )} {t( traffic.kind === "delay" diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 744391921..9458eb5f1 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -132,13 +132,13 @@ the existing severity bands distinguish light, moderate, heavy and severe delays. The text supplies the amount so color is not the only signal. When there is no usable delay estimate or congestion data is missing, -incomplete or stale, the duration uses the normal text color and the card shows -**Traffic data unavailable**. A route with fresh, complete congestion coverage +incomplete or stale, the duration uses the normal text color without an extra +visible note. **Traffic data unavailable** remains in **About traffic** and the +route's screen-reader description. A route with fresh, complete congestion coverage and a comparison below the display thresholds has a **green duration** and no additional caption. Significant estimated delays retain their severity color and the inline **(+12 min)** amount. The **About traffic** button is aligned -with the duration, above the distance, for all traffic states. The unavailable -note sits directly below the route summary without a separate icon row. +with the duration, above the distance, for all traffic states. A zero delay comparison alone cannot establish fresh congestion coverage. The route contract's optional `trafficCoverage` evidence must confirm that