Skip to content

Commit 2e8681b

Browse files
committed
feat(routing): evaluate road-closure schedules in their own timezone
Consume OpenConditions' schema.org Schedule (RoadConditionSchedule, carrying its own scheduleTimezone) instead of the zone-less window shape. The routing schedule check now evaluates each closure's recurrence in the closure's own zone — DST-correct, independent of the server's or origin's zone — replacing the prior UTC-component approximation. The chosen departAt/arriveBy wall-clock is resolved to an absolute instant via the route origin's timezone (timeZoneAt). Popup renders the schedule (days + band + date range). New Intl-based tz helpers (no dependency); contract type RoadConditionScheduleWindow → RoadConditionSchedule.
1 parent a4467ef commit 2e8681b

9 files changed

Lines changed: 266 additions & 91 deletions

File tree

integrations/road-conditions/map-layer.tsx

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,22 @@ const POPUP_SPEC: PopupCardSpec = {
5757
],
5858
};
5959

60-
interface ScheduleWindow {
61-
timeStart?: string;
62-
timeEnd?: string;
63-
dateStart?: string;
64-
dateEnd?: string;
60+
interface ScheduleEntry {
61+
startTime?: string;
62+
endTime?: string;
63+
startDate?: string;
64+
endDate?: string;
65+
byDay?: string[];
6566
}
6667

6768
/**
68-
* Format the structured validity for the popup. A recurring `schedule` (e.g. a
69-
* nightly closure) is shown as its band + date range, e.g.
70-
* "20:00–05:00, 29 Jun 2026 – 3 Jul 2026"; otherwise the absolute from–until
71-
* range, e.g. "10 Jul 2026, 22:00 – 13 Jul 2026, 05:00". An open end is shown as
72-
* "…". Returns "" when nothing is known (ongoing / undated) so the row drops.
73-
* Schedule times are the source's local clock (shown as-is).
69+
* Format the structured validity for the popup. A recurring `schedule` (a
70+
* schema.org Schedule, e.g. a nightly closure) is shown as its day(s) + band +
71+
* date range, e.g. "Mo, Tu, We, 08:00–17:00, 29 Jun 2026 – 1 Jul 2026";
72+
* otherwise the absolute from–until range, e.g.
73+
* "10 Jul 2026, 22:00 – 13 Jul 2026, 05:00". An open end is shown as "…".
74+
* Returns "" when nothing is known (ongoing / undated) so the row drops.
75+
* Schedule times are the source's local clock (`scheduleTimezone`), shown as-is.
7476
*/
7577
function formatValidity(
7678
scheduleJson: unknown,
@@ -81,17 +83,24 @@ function formatValidity(
8183
): string {
8284
if (typeof scheduleJson === "string" && scheduleJson) {
8385
try {
84-
const windows = JSON.parse(scheduleJson) as ScheduleWindow[];
86+
const windows = JSON.parse(scheduleJson) as ScheduleEntry[];
87+
const hhmm = (t?: string) => (t ? t.slice(0, 5) : "");
8588
const parts = windows
8689
.map((w) => {
87-
const band = w.timeStart && w.timeEnd ? `${w.timeStart}${w.timeEnd}` : "";
90+
const days = w.byDay && w.byDay.length > 0 ? w.byDay.join(", ") : "";
91+
const band =
92+
w.startTime && w.endTime
93+
? `${hhmm(w.startTime)}${hhmm(w.endTime)}`
94+
: w.startTime
95+
? `from ${hhmm(w.startTime)}`
96+
: "";
8897
const range =
89-
w.dateStart && w.dateEnd
90-
? `${fmtDate(w.dateStart)}${fmtDate(w.dateEnd)}`
91-
: w.dateStart
92-
? fmtDate(w.dateStart)
98+
w.startDate && w.endDate
99+
? `${fmtDate(w.startDate)}${fmtDate(w.endDate)}`
100+
: w.startDate
101+
? fmtDate(w.startDate)
93102
: "";
94-
return [band, range].filter(Boolean).join(", ");
103+
return [days, band, range].filter(Boolean).join(", ");
95104
})
96105
.filter(Boolean);
97106
if (parts.length > 0) return parts.join("; ");

integrations/routing/__tests__/closures.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,8 @@ describe("activeClosuresForBbox", () => {
356356
});
357357

358358
describe("recurring schedule (nightly windows supersede the outer span)", () => {
359-
// Nightly 20:00–05:00 closure over 29 Jun–1 Jul; outer span 29 Jun–2 Jul.
359+
// Nightly 20:00–05:00 Europe/Berlin (CEST +02:00 in summer) over 29 Jun–1 Jul.
360+
// Each occurrence = 20:00 local (18:00Z) for 9h → ends 03:00Z next day.
360361
const nightly = {
361362
id: "nightly:1",
362363
source: "test",
@@ -368,7 +369,14 @@ describe("activeClosuresForBbox", () => {
368369
validFrom: "2026-06-29T18:00:00.000Z",
369370
validTo: "2026-07-02T03:00:00.000Z",
370371
schedule: [
371-
{ dateStart: "2026-06-29", dateEnd: "2026-07-01", timeStart: "20:00", timeEnd: "05:00" },
372+
{
373+
repeatFrequency: "P1D",
374+
startDate: "2026-06-29",
375+
endDate: "2026-07-01",
376+
startTime: "20:00",
377+
duration: "PT9H",
378+
scheduleTimezone: "Europe/Berlin",
379+
},
372380
],
373381
};
374382
const run = (at: string) => {
@@ -378,15 +386,18 @@ describe("activeClosuresForBbox", () => {
378386
};
379387

380388
it("avoids the closure at night (inside a window)", async () => {
389+
// 23:00Z = 01:00 Berlin on Jul 1 — inside the Jun-30 night window.
381390
expect((await run("2026-06-30T23:00:00Z")).points).toEqual([[0.5, 51.5]]);
382391
});
383392

384393
it("does NOT avoid it during the day, even within the outer from–to span", async () => {
394+
// 14:00Z = 16:00 Berlin — between windows.
385395
expect((await run("2026-06-30T14:00:00Z")).points).toHaveLength(0);
386396
});
387397

388398
it("avoids the early-morning tail of an overnight window (attributed to the prior day)", async () => {
389-
expect((await run("2026-07-01T03:00:00Z")).points).toEqual([[0.5, 51.5]]);
399+
// 02:00Z Jul 1 = 04:00 Berlin — still inside the Jun-30 night window (→03:00Z).
400+
expect((await run("2026-07-01T02:00:00Z")).points).toEqual([[0.5, 51.5]]);
390401
});
391402

392403
it("does NOT avoid it on a night outside the window's date range", async () => {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { localDateInZone, zonedWallClockToInstant } from "../timezone";
3+
4+
describe("zonedWallClockToInstant", () => {
5+
it("resolves a summer (CEST, +02:00) Berlin wall-clock to UTC", () => {
6+
expect(zonedWallClockToInstant("Europe/Berlin", "2026-07-10T22:00")?.toISOString()).toBe(
7+
"2026-07-10T20:00:00.000Z",
8+
);
9+
});
10+
it("resolves a winter (CET, +01:00) Berlin wall-clock to UTC (DST-aware)", () => {
11+
expect(zonedWallClockToInstant("Europe/Berlin", "2026-01-10T22:00")?.toISOString()).toBe(
12+
"2026-01-10T21:00:00.000Z",
13+
);
14+
});
15+
it("resolves a Pacific (PDT, -07:00) wall-clock to UTC", () => {
16+
expect(zonedWallClockToInstant("America/Vancouver", "2026-07-10T08:00")?.toISOString()).toBe(
17+
"2026-07-10T15:00:00.000Z",
18+
);
19+
});
20+
it("returns null for an unparseable wall-clock", () => {
21+
expect(zonedWallClockToInstant("Europe/Berlin", "not-a-date")).toBeNull();
22+
});
23+
});
24+
25+
describe("localDateInZone", () => {
26+
it("rolls to the next local date past midnight", () => {
27+
// 23:00Z = 01:00 the next day in Berlin (CEST).
28+
expect(localDateInZone(new Date("2026-06-30T23:00:00Z"), "Europe/Berlin")).toBe("2026-07-01");
29+
});
30+
it("stays on the same local date during the day", () => {
31+
expect(localDateInZone(new Date("2026-06-30T14:00:00Z"), "Europe/Berlin")).toBe("2026-06-30");
32+
});
33+
it("rolls back a day for a western zone", () => {
34+
// 02:00Z = 19:00 the previous day in Vancouver (PDT).
35+
expect(localDateInZone(new Date("2026-07-02T02:00:00Z"), "America/Vancouver")).toBe(
36+
"2026-07-01",
37+
);
38+
});
39+
});

integrations/routing/closures.ts

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { BBox } from "@openmapx/core";
22
import { haversineDistance } from "@openmapx/core";
33
import type {
44
IntegrationContext,
5-
RoadConditionScheduleWindow,
5+
RoadConditionSchedule,
66
RoadConditionsProvider,
77
} from "@openmapx/integration-framework";
8+
import { localDateInZone, zonedWallClockToInstant } from "./timezone.js";
89

910
export type LngLat = [number, number];
1011

@@ -65,63 +66,108 @@ function parseHhMm(s: string | undefined): number | null {
6566
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
6667
}
6768

68-
/**
69-
* Whether the travel instant `at` lands inside a recurring window. Window dates
70-
* and times are LOCAL to the source; we compare against `at`'s UTC components,
71-
* which equal the user's chosen wall-clock for a pinned departure (see
72-
* `isActiveAt`). Overnight bands (timeEnd < timeStart) wrap past midnight — the
73-
* morning tail is attributed to the previous day's window.
74-
*/
75-
function withinWindow(win: RoadConditionScheduleWindow, at: Date): boolean {
76-
const dateOf = (d: Date) => d.toISOString().slice(0, 10);
77-
const inDateRange = (d: Date) =>
78-
(!win.dateStart || dateOf(d) >= win.dateStart) && (!win.dateEnd || dateOf(d) <= win.dateEnd);
79-
const dayOk = (d: Date) =>
80-
!win.dayOfWeek || win.dayOfWeek.length === 0 || win.dayOfWeek.includes(d.getUTCDay());
69+
const ICAL_DAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
8170

82-
const ts = parseHhMm(win.timeStart);
83-
const te = parseHhMm(win.timeEnd);
84-
// Date-only window (no time band) → active any time on an in-range day.
85-
if (ts == null || te == null) return inDateRange(at) && dayOk(at);
71+
/** iCal weekday code for a local "YYYY-MM-DD" date (UTC-parsed → calendar day). */
72+
function iCalDayOf(localDate: string): string | undefined {
73+
const d = new Date(`${localDate}T00:00:00Z`);
74+
return Number.isNaN(d.getTime()) ? undefined : ICAL_DAY[d.getUTCDay()];
75+
}
76+
77+
function addDaysLocal(localDate: string, delta: number): string {
78+
const d = new Date(`${localDate}T00:00:00Z`);
79+
d.setUTCDate(d.getUTCDate() + delta);
80+
return d.toISOString().slice(0, 10);
81+
}
8682

87-
const tod = at.getUTCHours() * 60 + at.getUTCMinutes();
88-
if (ts <= te) return inDateRange(at) && dayOk(at) && tod >= ts && tod <= te;
89-
// Overnight band: evening part on this day; morning part belongs to the
90-
// previous day's window.
91-
const prev = new Date(at.getTime() - 86_400_000);
83+
/** ISO-8601 duration → milliseconds (PnDTnHnMnS subset). */
84+
function durationToMs(iso: string | undefined): number | null {
85+
if (!iso) return null;
86+
const m = iso.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
87+
if (!m) return null;
88+
const [, d, h, mi, s] = m;
9289
return (
93-
(tod >= ts && inDateRange(at) && dayOk(at)) || (tod <= te && inDateRange(prev) && dayOk(prev))
90+
(Number(d ?? 0) * 86_400 + Number(h ?? 0) * 3_600 + Number(mi ?? 0) * 60 + Number(s ?? 0)) *
91+
1_000
9492
);
9593
}
9694

95+
/** Length of each occurrence: explicit `duration`, else endTime−startTime
96+
* (overnight-aware), else the whole day. */
97+
function occurrenceDurationMs(schedule: RoadConditionSchedule): number {
98+
const explicit = durationToMs(schedule.duration);
99+
if (explicit != null) return explicit;
100+
const s = parseHhMm(schedule.startTime);
101+
const e = parseHhMm(schedule.endTime);
102+
if (s != null && e != null) {
103+
let mins = e - s;
104+
if (mins <= 0) mins += 24 * 60;
105+
return mins * 60_000;
106+
}
107+
return 24 * 3_600 * 1_000;
108+
}
109+
110+
/** Whether the recurrence has an occurrence STARTING on local date `d`. */
111+
function occurrenceStartsOn(schedule: RoadConditionSchedule, d: string): boolean {
112+
if (schedule.startDate && d < schedule.startDate.slice(0, 10)) return false;
113+
if (schedule.endDate && d > schedule.endDate.slice(0, 10)) return false;
114+
if (schedule.exceptDate?.some((x) => x.slice(0, 10) === d)) return false;
115+
if (schedule.byDay && schedule.byDay.length > 0) {
116+
const ical = iCalDayOf(d);
117+
if (!ical || !schedule.byDay.includes(ical)) return false;
118+
}
119+
return true;
120+
}
121+
122+
/**
123+
* Whether the instant `at` falls inside an occurrence of a schema.org-shaped
124+
* `Schedule`, evaluated in the schedule's OWN `scheduleTimezone` (DST-correct).
125+
* Each occurrence starts at `startTime` (local) on a qualifying date and lasts
126+
* `occurrenceDurationMs`. We test the occurrence that could contain `at` — one
127+
* starting on `at`'s local date, or the previous local date for a window that
128+
* runs past midnight.
129+
*/
130+
function occursAt(schedule: RoadConditionSchedule, at: Date): boolean {
131+
const tz = schedule.scheduleTimezone;
132+
if (!tz) return true; // zone-less schedule can't be evaluated → don't suppress
133+
const startTime = schedule.startTime ?? "00:00";
134+
const durMs = occurrenceDurationMs(schedule);
135+
const atLocalDate = localDateInZone(at, tz);
136+
for (const startDate of [atLocalDate, addDaysLocal(atLocalDate, -1)]) {
137+
if (!occurrenceStartsOn(schedule, startDate)) continue;
138+
const start = zonedWallClockToInstant(tz, `${startDate}T${startTime}`);
139+
if (!start) continue;
140+
const startMs = start.getTime();
141+
if (at.getTime() >= startMs && at.getTime() < startMs + durMs) return true;
142+
}
143+
return false;
144+
}
145+
97146
/**
98147
* Whether a closure is in effect at the requested travel time `at`. Many feeds
99148
* publish planned closures days ahead, and some (e.g. nightly roadworks) are
100149
* active only inside recurring windows; without this check the router would
101150
* detour around a closure that hasn't started, has ended, or is only active at
102-
* night. `at` is the chosen departure/arrival time, or "now" for an immediate
103-
* trip. A closure with no temporal info is treated as ongoing (always in
104-
* effect).
151+
* night. `at` is the chosen departure/arrival instant, or "now" for an immediate
152+
* trip. A closure with no temporal info is treated as ongoing (always in effect).
105153
*
106-
* When a `schedule` is present it is the precise definition of when the closure
107-
* is in effect and supersedes the coarse `validFrom`/`validTo` span (those are
108-
* just the outer bounds). `validFrom`/`validTo` are absolute instants; the
109-
* comparison is accurate to within the origin's UTC offset for a pinned
110-
* departure — the same approximation already used when handing the time to
111-
* Valhalla, fine for windows measured in hours-to-days.
154+
* A `schedule` is the precise definition of when the closure is in effect and
155+
* supersedes the coarse `validFrom`/`validTo` span (just the outer bounds). It
156+
* is evaluated in its own `scheduleTimezone`, so the result is DST-correct and
157+
* independent of the server's or the route origin's zone.
112158
*/
113159
function isActiveAt(
114160
event: {
115161
validFrom?: string | null;
116162
validTo?: string | null;
117-
schedule?: RoadConditionScheduleWindow[];
163+
schedule?: RoadConditionSchedule[];
118164
},
119165
at: Date,
120166
): boolean {
121167
const t = at.getTime();
122168
if (Number.isNaN(t)) return true; // unparseable travel time → don't suppress
123169
if (event.schedule && event.schedule.length > 0) {
124-
return event.schedule.some((w) => withinWindow(w, at));
170+
return event.schedule.some((s) => occursAt(s, at));
125171
}
126172
if (event.validFrom) {
127173
const from = Date.parse(event.validFrom);

integrations/routing/index.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
import { createHash } from "node:crypto";
2-
import { overpassQuerySafe, type TravelMode } from "@openmapx/core";
2+
import { overpassQuerySafe, type TravelMode, timeZoneAt } from "@openmapx/core";
33
import type { IntegrationContext } from "@openmapx/integration-framework";
44
import { activeClosuresForBbox } from "./closures.js";
55
import { createRoutingOrchestrator } from "./orchestrator.js";
6+
import { zonedWallClockToInstant } from "./timezone.js";
67
import { parseDateTime, parseTravelMode } from "./validation.js";
78

9+
/**
10+
* Resolve the travel instant for closure-time evaluation: the chosen
11+
* departAt/arriveBy — a wall-clock "YYYY-MM-DDTHH:mm" local to the route ORIGIN —
12+
* turned into an absolute instant via the origin's timezone. Returns undefined
13+
* for "leave now" (closures are then evaluated at the current instant). Falls
14+
* back to a naive parse if the origin zone can't be resolved.
15+
*/
16+
function resolveTravelInstant(
17+
waypoints: [number, number][],
18+
departAt: string | undefined,
19+
arriveBy: string | undefined,
20+
): Date | undefined {
21+
const wall = departAt ?? arriveBy;
22+
if (!wall) return undefined;
23+
const origin = waypoints[0];
24+
const tz = origin ? timeZoneAt(origin[1], origin[0]) : null;
25+
return (tz ? zonedWallClockToInstant(tz, wall) : null) ?? new Date(wall);
26+
}
27+
828
/** A raw (un-projected) approach alert from OSM, returned by /navigation/alerts. */
929
interface RawRoadAlert {
1030
id: string;
@@ -243,16 +263,12 @@ export function setup(ctx: IntegrationContext): void {
243263
const wantClosureAvoidance = avoidClosures === "true" || avoidClosures === "1";
244264

245265
// Only avoid closures actually in effect at the chosen travel time: the
246-
// selected departure (or arrival) instant, falling back to "now" for an
247-
// immediate trip. Without this a planned-but-not-yet-active closure would
248-
// wrongly detour a trip planned for before it starts. departAt/arriveBy are
249-
// already part of the route cache key, so time-varied exclusions stay
250-
// correctly cached.
251-
const closureRefTime = departAt
252-
? new Date(departAt)
253-
: arriveBy
254-
? new Date(arriveBy)
255-
: undefined;
266+
// selected departure (or arrival) instant — resolved via the route origin's
267+
// timezone — falling back to "now" for an immediate trip. Without this a
268+
// planned-but-not-yet-active closure would wrongly detour a trip planned for
269+
// before it starts. departAt/arriveBy are already part of the route cache
270+
// key, so time-varied exclusions stay correctly cached.
271+
const closureRefTime = resolveTravelInstant(waypoints, departAt, arriveBy);
256272

257273
const { exclusions, hasExclusions, exclusionsHash } = await applyClosureExclusions(
258274
ctx,
@@ -410,16 +426,12 @@ export function setup(ctx: IntegrationContext): void {
410426
const wantClosureAvoidance = avoidClosures === "true" || avoidClosures === "1";
411427

412428
// Only avoid closures actually in effect at the chosen travel time: the
413-
// selected departure (or arrival) instant, falling back to "now" for an
414-
// immediate trip. Without this a planned-but-not-yet-active closure would
415-
// wrongly detour a trip planned for before it starts. departAt/arriveBy are
416-
// already part of the route cache key, so time-varied exclusions stay
417-
// correctly cached.
418-
const closureRefTime = departAt
419-
? new Date(departAt)
420-
: arriveBy
421-
? new Date(arriveBy)
422-
: undefined;
429+
// selected departure (or arrival) instant — resolved via the route origin's
430+
// timezone — falling back to "now" for an immediate trip. Without this a
431+
// planned-but-not-yet-active closure would wrongly detour a trip planned for
432+
// before it starts. departAt/arriveBy are already part of the route cache
433+
// key, so time-varied exclusions stay correctly cached.
434+
const closureRefTime = resolveTravelInstant(waypoints, departAt, arriveBy);
423435

424436
const { exclusions, hasExclusions, exclusionsHash } = await applyClosureExclusions(
425437
ctx,

0 commit comments

Comments
 (0)