Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/__tests__/pastMidnightClose.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

class SchedulePark extends Destination {
constructor(private readonly built: Array<{id: string; schedule: Raw[]}>, tz: string) {
super();
this.timezone = tz;
}
protected async buildEntityList(): Promise<Entity[]> { return []; }
protected async buildLiveData(): Promise<LiveData[]> { return []; }
async getDestinations(): Promise<Entity[]> { return []; }
protected async buildSchedules(): Promise<EntitySchedule[]> {
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');
});
});
30 changes: 30 additions & 0 deletions src/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion src/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -107,6 +107,26 @@ function stripUndefinedDeep(value: unknown): void {
*/
const SCHEDULE_TYPES: ReadonlySet<string> = 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.
Expand Down Expand Up @@ -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;
}
Expand Down
Loading