Skip to content

Commit 18ea059

Browse files
committed
feat(road-conditions): add a time-horizon filter to the incidents overlay
The overlay now defaults to showing only what is in effect right now, with "Next 7 days" and "All" steps beside the existing severity control. The step maps to a `horizonDays` query param that the /events route threads into the aggregation: providers that understand it push it into their own query, and a post-filter guarantees the semantics for those that ignore it. An event with no parseable start counts as already in effect. Omitting the param keeps the route's previous behaviour, which navigation depends on — it evaluates validity at the chosen travel time and must still receive future closures. Events that have not started yet ride `isForecast`/`isPlanned` through the serializer and render de-emphasised: dimmed markers, dashed lines, and a "Starts …" row in the popup.
1 parent b4d5db3 commit 18ea059

19 files changed

Lines changed: 650 additions & 15 deletions

integrations/road-conditions/__tests__/eventsToGeojson.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,12 @@ describe("eventsToFeatureCollection", () => {
2222
const fc = eventsToFeatureCollection([base]);
2323
expect(fc.features[0]?.properties.delaySeconds).toBeNull();
2424
});
25+
26+
it("round-trips the planned/forecast flags, emitting null when unset", () => {
27+
const fc = eventsToFeatureCollection([{ ...base, isForecast: true, isPlanned: true }, base]);
28+
expect(fc.features[0]?.properties.isForecast).toBe(true);
29+
expect(fc.features[0]?.properties.isPlanned).toBe(true);
30+
expect(fc.features[1]?.properties.isForecast).toBeNull();
31+
expect(fc.features[1]?.properties.isPlanned).toBeNull();
32+
});
2533
});

integrations/road-conditions/__tests__/index.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,59 @@
1+
import type { IntegrationContext } from "@openmapx/integration-framework";
12
import { describe, expect, it } from "vitest";
2-
import { parseBbox } from "../index.js";
3+
import { parseBbox, setup } from "../index.js";
4+
5+
type Handler = (
6+
req: { query: Record<string, string | undefined> },
7+
reply: {
8+
status: (code: number) => { send: (body: unknown) => void };
9+
header: (k: string, v: string) => void;
10+
send: (body: unknown) => void;
11+
},
12+
) => Promise<void>;
13+
14+
/**
15+
* Drives the `/events` route with a stub host: records the cache key each call
16+
* derives and the query options the aggregation would run under.
17+
*/
18+
function eventsHarness() {
19+
const cacheKeys: string[] = [];
20+
const routes = new Map<string, Handler>();
21+
const ctx = {
22+
registerRoute(_method: string, path: string, handler: Handler) {
23+
routes.set(path, handler);
24+
},
25+
cache: {
26+
async withCache<T>(key: string, _ttl: number, fn: () => Promise<T>): Promise<T> {
27+
cacheKeys.push(key);
28+
return fn();
29+
},
30+
},
31+
getIntegrationsByDomain: () => [],
32+
log: { warn() {}, error() {}, info() {}, debug() {} },
33+
} as unknown as IntegrationContext;
34+
35+
setup(ctx);
36+
37+
return {
38+
cacheKeys,
39+
async get(query: Record<string, string | undefined>) {
40+
let status = 200;
41+
let body: unknown;
42+
await routes.get("/events")!(
43+
{ query },
44+
{
45+
status: (code) => {
46+
status = code;
47+
return { send: (b: unknown) => (body = b) };
48+
},
49+
header: () => {},
50+
send: (b: unknown) => (body = b),
51+
},
52+
);
53+
return { status, body };
54+
},
55+
};
56+
}
357

458
describe("parseBbox", () => {
559
it("parses a valid bbox", () => {
@@ -37,3 +91,33 @@ describe("parseBbox", () => {
3791
expect(parseBbox("170,10,-170,20")).toBeNull();
3892
});
3993
});
94+
95+
describe("GET /events horizonDays", () => {
96+
const BBOX = "13.39,52.49,13.41,52.51";
97+
98+
it("gives each horizon its own cache key", async () => {
99+
const h = eventsHarness();
100+
await h.get({ bbox: BBOX });
101+
await h.get({ bbox: BBOX, horizonDays: "0" });
102+
await h.get({ bbox: BBOX, horizonDays: "7" });
103+
expect(new Set(h.cacheKeys).size).toBe(3);
104+
expect(h.cacheKeys[1]).toMatch(/:0$/);
105+
expect(h.cacheKeys[2]).toMatch(/:7$/);
106+
});
107+
108+
it("treats a non-integer or negative horizon as absent, not as 0", async () => {
109+
const h = eventsHarness();
110+
await h.get({ bbox: BBOX });
111+
const noParam = h.cacheKeys[0];
112+
await h.get({ bbox: BBOX, horizonDays: "abc" });
113+
await h.get({ bbox: BBOX, horizonDays: "-1" });
114+
await h.get({ bbox: BBOX, horizonDays: "1.5" });
115+
expect(h.cacheKeys).toEqual([noParam, noParam, noParam, noParam]);
116+
});
117+
118+
it("still rejects a malformed bbox", async () => {
119+
const h = eventsHarness();
120+
const res = await h.get({ bbox: "1,,3,4", horizonDays: "0" });
121+
expect(res.status).toBe(400);
122+
});
123+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { render, screen, userEvent } from "@/test";
3+
import { useRoadConditionsStore } from "../store";
4+
5+
vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
6+
7+
// `t(key)` under the mock returns "roadConditions.<key>", so the assertions
8+
// below read against stable keys rather than copies of the message catalog.
9+
import { RoadConditionsLegend } from "../legend";
10+
11+
describe("RoadConditionsLegend time-horizon control", () => {
12+
beforeEach(() => {
13+
useRoadConditionsStore.setState({ panelOpen: true, layerVisible: true });
14+
useRoadConditionsStore.getState().resetFilters();
15+
});
16+
17+
it("renders the three horizon steps with the active one selected by default", () => {
18+
render(<RoadConditionsLegend />);
19+
20+
for (const key of ["horizon.active", "horizon.week", "horizon.all"]) {
21+
expect(screen.getByRole("button", { name: `roadConditions.${key}` })).toBeTruthy();
22+
}
23+
expect(
24+
screen
25+
.getByRole("button", { name: "roadConditions.horizon.active" })
26+
.getAttribute("aria-pressed"),
27+
).toBe("true");
28+
});
29+
30+
it("dispatches setHorizon when another step is clicked", async () => {
31+
render(<RoadConditionsLegend />);
32+
33+
await userEvent.click(screen.getByRole("button", { name: "roadConditions.horizon.week" }));
34+
expect(useRoadConditionsStore.getState().horizon).toBe("week");
35+
36+
await userEvent.click(screen.getByRole("button", { name: "roadConditions.horizon.all" }));
37+
expect(useRoadConditionsStore.getState().horizon).toBe("all");
38+
});
39+
40+
it("offers the reset chip once the horizon moves off the default", async () => {
41+
render(<RoadConditionsLegend />);
42+
expect(screen.queryByText("roadConditions.reset")).toBeNull();
43+
44+
await userEvent.click(screen.getByRole("button", { name: "roadConditions.horizon.all" }));
45+
expect(screen.getByText("roadConditions.reset")).toBeTruthy();
46+
});
47+
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { createFakeMap, type FakeMap, render, waitFor } from "@/test";
3+
import { useRoadConditionsStore } from "../store";
4+
5+
let fake: FakeMap;
6+
7+
vi.mock("@/lib/MapContext", () => ({
8+
useMap: () => ({ mapRef: { current: fake.map }, mapReady: true, styleVersion: 0 }),
9+
}));
10+
11+
vi.mock("@/lib/EnvProvider", () => ({
12+
useEnv: () => ({ apiUrl: "https://api.test" }),
13+
}));
14+
15+
vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
16+
17+
vi.mock("maplibre-gl", () => ({
18+
default: {
19+
Popup: class FakePopup {
20+
setLngLat() {
21+
return this;
22+
}
23+
setHTML() {
24+
return this;
25+
}
26+
addTo() {
27+
return this;
28+
}
29+
remove() {
30+
return this;
31+
}
32+
},
33+
},
34+
}));
35+
36+
import { RoadConditionsLayer } from "../map-layer";
37+
38+
const MARKER_SOURCE = "omx-road-conditions-markers";
39+
const LINE_SOURCE = "omx-road-conditions-lines";
40+
const MARKER_LAYER = "omx-road-conditions-markers";
41+
const LINE_LAYER = "omx-road-conditions-line";
42+
43+
const fetchMock = vi.fn();
44+
45+
function inDays(d: number): string {
46+
return new Date(Date.now() + d * 86_400_000).toISOString();
47+
}
48+
49+
function respondWith(features: unknown[]) {
50+
fetchMock.mockResolvedValue({
51+
ok: true,
52+
json: async () => ({ type: "FeatureCollection", features }),
53+
});
54+
}
55+
56+
/** The URL of the most recent /events fetch. */
57+
function lastUrl(): string {
58+
return String(fetchMock.mock.calls.at(-1)?.[0] ?? "");
59+
}
60+
61+
beforeEach(() => {
62+
fake = createFakeMap();
63+
fetchMock.mockReset();
64+
respondWith([]);
65+
vi.stubGlobal("fetch", fetchMock);
66+
useRoadConditionsStore.setState({ panelOpen: true, layerVisible: true });
67+
useRoadConditionsStore.getState().resetFilters();
68+
});
69+
70+
describe("RoadConditionsLayer horizon query", () => {
71+
it("requests horizonDays=0 under the default Active horizon", async () => {
72+
render(<RoadConditionsLayer />);
73+
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
74+
expect(lastUrl()).toContain("horizonDays=0");
75+
});
76+
77+
it("requests horizonDays=7 for the week step and omits the param for all", async () => {
78+
useRoadConditionsStore.setState({ horizon: "week" });
79+
render(<RoadConditionsLayer />);
80+
await waitFor(() => expect(lastUrl()).toContain("horizonDays=7"));
81+
82+
fetchMock.mockClear();
83+
useRoadConditionsStore.setState({ horizon: "all" });
84+
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
85+
expect(lastUrl()).not.toContain("horizonDays");
86+
});
87+
});
88+
89+
describe("RoadConditionsLayer future styling", () => {
90+
it("stamps a `future` flag from either isForecast or a future validFrom", async () => {
91+
respondWith([
92+
{
93+
geometry: { type: "Point", coordinates: [13.4, 52.5] },
94+
properties: { id: "flagged", type: "roadworks", severity: "low", isForecast: true },
95+
},
96+
{
97+
geometry: { type: "Point", coordinates: [13.41, 52.51] },
98+
properties: {
99+
id: "dated",
100+
type: "roadworks",
101+
severity: "low",
102+
validFrom: inDays(3),
103+
},
104+
},
105+
{
106+
geometry: { type: "Point", coordinates: [13.42, 52.52] },
107+
properties: {
108+
id: "current",
109+
type: "roadworks",
110+
severity: "low",
111+
validFrom: inDays(-3),
112+
},
113+
},
114+
]);
115+
116+
render(<RoadConditionsLayer />);
117+
await waitFor(() => expect(fake.state.sources.get(MARKER_SOURCE)?.data).toBeDefined());
118+
119+
const data = fake.state.sources.get(MARKER_SOURCE)?.data as {
120+
features: { properties: Record<string, unknown> }[];
121+
};
122+
const byId = new Map(data.features.map((f) => [f.properties._id, f.properties.future]));
123+
expect(byId.get("flagged")).toBe(true);
124+
expect(byId.get("dated")).toBe(true);
125+
expect(byId.get("current")).toBe(false);
126+
});
127+
128+
it("carries the future flag onto line features too", async () => {
129+
respondWith([
130+
{
131+
geometry: {
132+
type: "LineString",
133+
coordinates: [
134+
[13.4, 52.5],
135+
[13.41, 52.51],
136+
],
137+
},
138+
properties: { id: "line", type: "roadworks", severity: "low", validFrom: inDays(3) },
139+
},
140+
]);
141+
142+
render(<RoadConditionsLayer />);
143+
await waitFor(() => expect(fake.state.sources.get(LINE_SOURCE)?.data).toBeDefined());
144+
145+
const data = fake.state.sources.get(LINE_SOURCE)?.data as {
146+
features: { properties: Record<string, unknown> }[];
147+
};
148+
expect(data.features[0]?.properties.future).toBe(true);
149+
});
150+
151+
it("de-emphasises future features in the layer paint expressions", async () => {
152+
render(<RoadConditionsLayer />);
153+
await waitFor(() => expect(fake.state.layers.has(MARKER_LAYER)).toBe(true));
154+
155+
const markerPaint = fake.state.layers.get(MARKER_LAYER)?.paint as Record<string, unknown>;
156+
expect(markerPaint["icon-opacity"]).toEqual(["case", ["get", "future"], 0.55, 1]);
157+
158+
const linePaint = fake.state.layers.get(LINE_LAYER)?.paint as Record<string, unknown>;
159+
expect(linePaint["line-opacity"]).toEqual(["case", ["get", "future"], 0.45, 0.7]);
160+
expect(linePaint["line-dasharray"]).toEqual([
161+
"case",
162+
["get", "future"],
163+
["literal", [2, 1.5]],
164+
["literal", [1]],
165+
]);
166+
});
167+
});

integrations/road-conditions/__tests__/orchestrator.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ function provider(
2727
return { id, getEvents };
2828
}
2929

30+
/** Distinct points so the cross-provider dedupe doesn't collapse the fixtures. */
31+
function pt(lon: number): RoadConditionEvent["geometry"] {
32+
return { type: "Point", coordinates: [lon, 52.5] };
33+
}
34+
3035
function seg(over: Partial<RoadFlowSegment> & Pick<RoadFlowSegment, "id">): RoadFlowSegment {
3136
return {
3237
geometry: {
@@ -156,6 +161,52 @@ describe("aggregateRoadConditions", () => {
156161
expect(called).toBe(false);
157162
expect(out).toEqual([]);
158163
});
164+
165+
it("post-filters events outside the horizon, for providers that ignore the option", async () => {
166+
const inDays = (d: number) => new Date(Date.now() + d * 86_400_000).toISOString();
167+
// This provider deliberately ignores `opts` — the guarantee has to hold
168+
// regardless of whether a provider pushed the filter down.
169+
const ctx = ctxWith([
170+
provider("ignores-opts", async () => [
171+
ev({ id: "now", validFrom: inDays(-1), geometry: pt(13.4) }),
172+
ev({ id: "soon", validFrom: inDays(2), geometry: pt(13.401) }),
173+
ev({ id: "later", validFrom: inDays(10), geometry: pt(13.402) }),
174+
]),
175+
]);
176+
177+
const week = await aggregateRoadConditions(ctx, BBOX, { horizonDays: 7 });
178+
expect(week.map((e) => e.id).sort()).toEqual(["now", "soon"]);
179+
180+
const activeOnly = await aggregateRoadConditions(ctx, BBOX, { horizonDays: 0 });
181+
expect(activeOnly.map((e) => e.id)).toEqual(["now"]);
182+
183+
const unfiltered = await aggregateRoadConditions(ctx, BBOX);
184+
expect(unfiltered.map((e) => e.id).sort()).toEqual(["later", "now", "soon"]);
185+
});
186+
187+
it("keeps events with a missing or unparseable validFrom at any horizon", async () => {
188+
const ctx = ctxWith([
189+
provider("p", async () => [
190+
ev({ id: "no-start", geometry: pt(13.4) }),
191+
ev({ id: "null-start", validFrom: null, geometry: pt(13.401) }),
192+
ev({ id: "junk-start", validFrom: "not a date", geometry: pt(13.402) }),
193+
]),
194+
]);
195+
const out = await aggregateRoadConditions(ctx, BBOX, { horizonDays: 0 });
196+
expect(out.map((e) => e.id).sort()).toEqual(["junk-start", "no-start", "null-start"]);
197+
});
198+
199+
it("forwards horizonDays to providers that do accept it", async () => {
200+
let seen: number | undefined | "unset" = "unset";
201+
const ctx = ctxWith([
202+
provider("p", async (_bbox, opts) => {
203+
seen = opts?.horizonDays;
204+
return [];
205+
}),
206+
]);
207+
await aggregateRoadConditions(ctx, BBOX, { horizonDays: 7 });
208+
expect(seen).toBe(7);
209+
});
159210
});
160211

161212
describe("aggregateRoadFlow", () => {

0 commit comments

Comments
 (0)