From 11781e171bc83cbbc540029c8cf96cc37a30dc99 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 1 Nov 2026 15:15:52 +0000 Subject: [PATCH] fix(schedule): move a past-midnight close stamped with the opening date to the next day Some sources write a close after midnight with the date the park opened, so closingTime <= openingTime: Gardaland's 31 Oct 2026 late opening (10:00 to "2026-10-31T00:00"), Busch Gardens Tampa and SeaWorld San Diego event nights (19:00 to "00:00"/"01:00" the same date), Walibi Rhone-Alpes on 24 Oct. Read literally each is an empty window, so anything deciding "open now" from the hours sees the park closed through the whole open day. getSchedules() now moves such a close forward one calendar day for every park, recomputing the offset in the park's timezone. A close a full day or more before the open is left alone. Over every park's published Oct-Nov 2026 schedule (8985 entries) exactly those 30 days change. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/pastMidnightClose.test.ts | 84 +++++++++++++++++++++++++ src/datetime.ts | 30 +++++++++ src/destination.ts | 23 ++++++- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/pastMidnightClose.test.ts 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; }