@@ -2,9 +2,10 @@ import type { BBox } from "@openmapx/core";
22import { haversineDistance } from "@openmapx/core" ;
33import type {
44 IntegrationContext ,
5- RoadConditionScheduleWindow ,
5+ RoadConditionSchedule ,
66 RoadConditionsProvider ,
77} from "@openmapx/integration-framework" ;
8+ import { localDateInZone , zonedWallClockToInstant } from "./timezone.js" ;
89
910export 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 */
113159function 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 ) ;
0 commit comments