diff --git a/src/__tests__/pastMidnightClose.test.ts b/src/__tests__/pastMidnightClose.test.ts new file mode 100644 index 000000000..54a2a889f --- /dev/null +++ b/src/__tests__/pastMidnightClose.test.ts @@ -0,0 +1,84 @@ +import {describe, test, expect} from 'vitest'; +import type {Entity, LiveData, EntitySchedule} from '@themeparks/typelib'; +import {Destination} from '../destination.js'; +import {rollPastMidnightClose} from '../datetime.js'; + +/** + * Some sources stamp a past-midnight close with the opening date, so the day + * reads closingTime <= openingTime: Gardaland on 31 Oct 2026 (open 10:00, + * close "2026-10-31T00:00:00+01:00"), Busch Gardens Tampa and SeaWorld San + * Diego event nights (19:00 to "00:00"/"01:00" the same date). Read literally + * the day is an empty window and the park looks closed all day. + * getSchedules() moves such a close to the next day for every park. + */ + +describe('rollPastMidnightClose', () => { + test('Gardaland 31 Oct 2026: a "00:00" close on the opening date becomes midnight that night', () => { + expect(rollPastMidnightClose('2026-10-31T10:00:00+01:00', '2026-10-31T00:00:00+01:00', 'Europe/Rome')) + .toBe('2026-11-01T00:00:00+01:00'); + }); + + test('a 01:00 event-night close moves to 01:00 the next morning', () => { + expect(rollPastMidnightClose('2026-10-24T19:00:00-04:00', '2026-10-24T01:00:00-04:00', 'America/New_York')) + .toBe('2026-10-25T01:00:00-04:00'); + }); + + test('the offset is recomputed when the clocks change that night', () => { + // Europe falls back at 03:00 local on 25 Oct 2026. A close at 04:00 the + // next morning is in CET (+01:00), not the opening day's CEST (+02:00). + expect(rollPastMidnightClose('2026-10-24T19:00:00+02:00', '2026-10-24T04:00:00+02:00', 'Europe/Paris')) + .toBe('2026-10-25T04:00:00+01:00'); + }); + + test('with no real zone the string offset is kept', () => { + expect(rollPastMidnightClose('2026-10-31T19:00:00-07:00', '2026-10-31T00:00:00-07:00', 'UTC')) + .toBe('2026-11-01T00:00:00-07:00'); + expect(rollPastMidnightClose('2026-10-31T19:00:00-07:00', '2026-10-31T00:00-07:00')) + .toBe('2026-11-01T00:00:00-07:00'); + }); + + test('leaves normal days, unparseable times, and a close a day or more early alone', () => { + expect(rollPastMidnightClose('2026-10-29T10:00:00+01:00', '2026-10-29T17:00:00+01:00', 'Europe/Rome')).toBeNull(); + expect(rollPastMidnightClose('2026-10-29T10:00:00+01:00', 'soon', 'Europe/Rome')).toBeNull(); + expect(rollPastMidnightClose('2026-10-30T10:00:00+01:00', '2026-10-29T10:00:00+01:00', 'Europe/Rome')).toBeNull(); + }); + + test('a close equal to the opening is read as a 24 h day', () => { + expect(rollPastMidnightClose('2026-10-29T10:00:00Z', '2026-10-29T10:00:00Z', 'UTC')).toBe('2026-10-30T10:00:00+00:00'); + }); +}); + +type Raw = Record; + +class SchedulePark extends Destination { + constructor(private readonly built: Array<{id: string; schedule: Raw[]}>, tz: string) { + super(); + this.timezone = tz; + } + protected async buildEntityList(): Promise { return []; } + protected async buildLiveData(): Promise { return []; } + async getDestinations(): Promise { return []; } + protected async buildSchedules(): Promise { + return this.built as unknown as EntitySchedule[]; + } +} + +describe('getSchedules', () => { + test('rolls a past-midnight close forward and leaves every other entry as built', async () => { + const park = new SchedulePark([{ + id: 'park', + schedule: [ + {date: '2026-10-30', type: 'OPERATING', openingTime: '2026-10-30T17:00:00+01:00', closingTime: '2026-10-30T22:00:00+01:00'}, + {date: '2026-10-31', type: 'OPERATING', openingTime: '2026-10-31T10:00:00+01:00', closingTime: '2026-10-31T00:00:00+01:00'}, + {date: '2026-10-31', type: 'INFO', description: 'no times'}, + ], + }], 'Europe/Rome'); + const [s] = await park.getSchedules(); + expect(s.schedule.map((e: Raw) => e.closingTime)).toEqual([ + '2026-10-30T22:00:00+01:00', + '2026-11-01T00:00:00+01:00', + undefined, + ]); + expect(s.schedule[1].date).toBe('2026-10-31'); + }); +}); diff --git a/src/datetime.ts b/src/datetime.ts index 886711de6..16ccfbabe 100644 --- a/src/datetime.ts +++ b/src/datetime.ts @@ -394,6 +394,36 @@ export function localFromFakeUtc(fakeUtcStr: string, tz: string): string { return constructDateTime(dateStr, timeStr, tz); } +/** + * A closing time at or before its opening time, less than a day before it, is + * a past-midnight close stamped with the opening date: an event night of + * 19:00 to "00:00" or "01:00", or a late opening to midnight, written on the + * date it opened. Returns the same wall-clock time on the next calendar day, or + * null when the pair needs no change (close after open, unparseable, or a close + * a full day or more before the open, which is not this shape). + * + * With a real IANA `tz` the offset is recomputed, so a close on the night the + * clocks change gets the right one. With no zone (or the 'UTC' default while + * the string carries another offset) the string's own offset is kept. + * + * @param openingTime ISO 8601 opening time + * @param closingTime ISO 8601 closing time + * @param tz IANA timezone of the park, if known + */ +export function rollPastMidnightClose(openingTime: string, closingTime: string, tz?: string): string | null { + const openMs = Date.parse(openingTime); + const closeMs = Date.parse(closingTime); + if (!Number.isFinite(openMs) || !Number.isFinite(closeMs)) return null; + if (closeMs > openMs || openMs - closeMs >= 24 * 60 * 60 * 1000) return null; + const m = closingTime.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2})?)(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/i); + if (!m) return null; + const [, date, time, offset] = m; + const next = shiftDateString(date, 1); + const zoneless = !tz || (tz === 'UTC' && !/^(Z|[+-]00:00)$/i.test(offset)); + const rolled = zoneless ? `${next}T${time.length === 5 ? `${time}:00` : time}${offset}` : constructDateTime(next, time, tz); + return Date.parse(rolled) > openMs ? rolled : null; +} + /** * Extract a hostname from a URL string, returning undefined if invalid. * Commonly used in @inject hostname filters for dynamic API base URLs. diff --git a/src/destination.ts b/src/destination.ts index 301a5b09c..8958b47cd 100644 --- a/src/destination.ts +++ b/src/destination.ts @@ -8,7 +8,7 @@ import {cache, CacheLib} from "./cache.js"; import {LIVE_ENTITY_RETIREMENT_FRAGMENT} from "./cacheKeys.js"; import {VQueueBuilder} from "./virtualQueue/builder.js"; import {calculateReturnWindow} from "./virtualQueue/timeWindows.js"; -import {formatInTimezone} from "./datetime.js"; +import {formatInTimezone, rollPastMidnightClose} from "./datetime.js"; import {stripHtmlTags, decodeHtmlEntities} from "./htmlUtils.js"; /** @@ -107,6 +107,26 @@ function stripUndefinedDeep(value: unknown): void { */ const SCHEDULE_TYPES: ReadonlySet = new Set(Object.values(ScheduleTypeEnum)); +/** + * Past-midnight closes stamped with the opening date (closingTime at or before + * openingTime) move to the next day, in place, for every park. Read literally, + * such a day is an empty window: anything downstream deciding "open now" from + * the hours sees the park closed through the whole open day. Parks did this on + * Halloween event nights (19:00 to "00:00"/"01:00") and on a late opening to + * midnight. Each park's own builder could be fixed, but a new source with the + * same shape would bring it back, so the final getSchedules() pass fixes it. + */ +function rollPastMidnightCloses(schedules: EntitySchedule[], tz: string): void { + for (const s of schedules) { + if (!Array.isArray(s.schedule)) continue; + for (const e of s.schedule) { + if (typeof e?.openingTime !== 'string' || typeof e?.closingTime !== 'string') continue; + const rolled = rollPastMidnightClose(e.openingTime, e.closingTime, tz); + if (rolled) e.closingTime = rolled; + } + } +} + /** * Warn about schedule entries whose `type` the schedule definition does not * have. Every entry is kept and nothing is changed. @@ -1572,6 +1592,7 @@ export abstract class Destination { await this.init(); const schedules = await this.buildSchedules(); for (const s of schedules) stripUndefinedDeep(s); + rollPastMidnightCloses(schedules, this.timezone); warnOnUnknownScheduleTypes(this.constructor.name, schedules); return schedules; }