diff --git a/TODO.MD b/TODO.MD index cd199b6f..ca228654 100644 --- a/TODO.MD +++ b/TODO.MD @@ -27,8 +27,9 @@ - Universal Studios Singapore (USS) - Lotte World - Chimelong: Guangzhou + Zhuhai (2 destinations) +- Ocean Park Hong Kong: Auth token, coordinate affine transform, showtimes, park schedule -**Totals: ~118 destinations, 1068 tests, 45 test files** +**Totals: ~119 destinations, 1068 tests, 45 test files** ## Remaining Migrations diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts new file mode 100644 index 00000000..8753cc50 --- /dev/null +++ b/src/parks/oceanpark/oceanpark.ts @@ -0,0 +1,722 @@ +/** + * Ocean Park Hong Kong + * + * Single destination with one park. Attractions, shows, and dining are fetched + * from a mobile API (sop.oceanpark.com.hk) that requires a short-lived bearer + * token ("optoken") in each request header. + * + * Coordinate data: The park app exposes a map at map.oceanpark.com.hk with + * entity pixel positions. Reference points (pixel → lat/lng anchors) are + * fetched and used to compute an affine transform so all entity coordinates + * can be derived from their pixel positions. + */ + +import crypto from 'crypto'; +import {Destination, DestinationConstructor} from '../../destination.js'; +import config from '../../config.js'; +import {cache} from '../../cache.js'; +import {http, HTTPObj} from '../../http.js'; +import {inject} from '../../injector.js'; +import {destinationController} from '../../destinationRegistry.js'; +import {hostnameFromUrl, formatInTimezone, formatDate} from '../../datetime.js'; +import {TagBuilder} from '../../tags/index.js'; +import type {Entity, LiveData, EntitySchedule} from '@themeparks/typelib'; +import {AttractionTypeEnum} from '@themeparks/typelib'; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const TIMEZONE = 'Asia/Hong_Kong'; +const DESTINATION_ID = 'oceanparkresort'; +const PARK_ID = 'oceanpark'; +const DEFAULT_LAT = 22.2465; +const DEFAULT_LNG = 114.1748; + +/** Ocean Park entity sort IDs */ +const SORT_ID = { + TRANSPORT: 7, + RIDES: 8, + SHOWS: 15, + DINING: 17, +} as const; + +/** Map category slugs that contain entity pixel positions */ +const MAP_CATEGORIES = ['attractions', 'animals', 'dining', 'transportations', 'shows', 'shops'] as const; + +// ── API Interfaces ────────────────────────────────────────────────────────── + +interface OceanParkTokenResponse { + data?: { + token?: string; + tokenExpire?: number; // Unix ms expiry + }; +} + +interface OceanParkCondition { + conditionDesc?: string; + description?: string; +} + +interface OceanParkOperatingHour { + openDate: string; // 'YYYY-MM-DD' + openTime?: number; // Unix ms + closeTime?: number; // Unix ms +} + +interface OceanParkPflowInfo { + entityStatus?: string; // 'open' | 'close' | etc. + entityWaitTime?: number | null; + operatingHourList?: OceanParkOperatingHour[]; +} + +interface OceanParkEntity { + id: number; + name: string; + typeId?: number; + extEntityCode?: string | number; + conditionList?: Array; + raFacilityType?: string; + pflowInfo?: OceanParkPflowInfo; +} + +interface OceanParkEntityListResponse { + data?: { + data?: OceanParkEntity[]; + }; +} + +interface OceanParkTimeSlot { + startTime: number; // Unix ms + endTime: number; // Unix ms +} + +interface OceanParkActivity { + timeList?: OceanParkTimeSlot[]; +} + +interface OceanParkEntityDetail { + relateList?: Array<{type: string; [key: string]: unknown}>; + activityList?: OceanParkActivity[]; +} + +interface OceanParkEntityDetailResponse { + data?: OceanParkEntityDetail; +} + +interface OceanParkParkDay { + openDate: string; // 'YYYY-MM-DD' + parkStatus: string; // 'open' | 'close' | etc. + parkOpenTime?: string; // Unix ms as string + parkCloseTime?: string; // Unix ms as string + parkingOpenTime?: string; + parkingCloseTime?: string; + summitStaus?: string; // Note: typo in API + summitCloseTime?: string; +} + +interface OceanParkScheduleResponse { + data?: { + parkOperatingHourList?: OceanParkParkDay[]; + }; +} + +interface OceanParkReferencePoint { + pixelX: number; + pixelY: number; + latitude: number; + longitude: number; +} + +interface OceanParkMapEntity { + api_key?: string | number; + x?: number; + y?: number; +} + +interface AffineCoeffs { + a: number; b: number; c: number; // lat = a*x + b*y + c + d: number; e: number; f: number; // lng = d*x + e*y + f +} + +// ── Pure Functions ────────────────────────────────────────────────────────── + +/** + * Compute affine transform coefficients from a set of reference points. + * Solves lat = a*x + b*y + c and lng = d*x + e*y + f using least-squares + * normal equations (Cramer's rule on the 3×3 system). + */ +function computeAffineTransform(refPoints: OceanParkReferencePoint[]): AffineCoeffs | null { + let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, sumYY = 0; + let sumLat = 0, sumXLat = 0, sumYLat = 0; + let sumLng = 0, sumXLng = 0, sumYLng = 0; + const n = refPoints.length; + + for (const p of refPoints) { + const {pixelX: x, pixelY: y, latitude: lat, longitude: lng} = p; + sumX += x; sumY += y; + sumXX += x * x; sumXY += x * y; sumYY += y * y; + sumLat += lat; sumXLat += x * lat; sumYLat += y * lat; + sumLng += lng; sumXLng += x * lng; sumYLng += y * lng; + } + + const M: [number, number, number][] = [ + [sumXX, sumXY, sumX], + [sumXY, sumYY, sumY], + [sumX, sumY, n], + ]; + + const det = (m: [number, number, number][]) => + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - + m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + + const D = det(M); + if (!Number.isFinite(D) || Math.abs(D) < 1e-10) return null; + + const cramer = (rhs: number[]): [number, number, number] => { + const M0: [number, number, number][] = [[rhs[0], M[0][1], M[0][2]], [rhs[1], M[1][1], M[1][2]], [rhs[2], M[2][1], M[2][2]]]; + const M1: [number, number, number][] = [[M[0][0], rhs[0], M[0][2]], [M[1][0], rhs[1], M[1][2]], [M[2][0], rhs[2], M[2][2]]]; + const M2: [number, number, number][] = [[M[0][0], M[0][1], rhs[0]], [M[1][0], M[1][1], rhs[1]], [M[2][0], M[2][1], rhs[2]]]; + return [det(M0) / D, det(M1) / D, det(M2) / D]; + }; + + const [a, b, c] = cramer([sumXLat, sumYLat, sumLat]); + const [d, e, f] = cramer([sumXLng, sumYLng, sumLng]); + return {a, b, c, d, e, f}; +} + +/** + * Parse height restriction values from a conditionList. + * Supports patterns: "Height: 140cm" (min) and "Between 100cm and 140cm" (max). + */ +function parseHeightTag(conditionList: Array): {min: number | null; max: number | null} { + let min: number | null = null; + let max: number | null = null; + + for (const cond of conditionList) { + const text = typeof cond === 'string' ? cond : (cond.conditionDesc ?? cond.description ?? ''); + + const minMatch = text.match(/Height:\s*(\d+)\s*cm/i); + if (minMatch) min = parseInt(minMatch[1], 10); + + const maxMatch = text.match(/Between\s*\d+\s*cm.*?and\s*(\d+)\s*cm/i); + if (maxMatch) max = parseInt(maxMatch[1], 10); + } + + return {min, max}; +} + +// ── Implementation ────────────────────────────────────────────────────────── + +@destinationController({category: 'Ocean Park'}) +export class OceanParkHongKong extends Destination { + @config baseURL: string = ''; + @config mapURL: string = ''; + @config parkId: number = 1; + + timezone = TIMEZONE; + + constructor(options?: DestinationConstructor) { + super(options); + this.addConfigPrefix('OCEANPARK'); + } + + getCacheKeyPrefix(): string { + return 'oceanpark'; + } + + // ── Initialisation ──────────────────────────────────────────────────────── + + /** Pre-warm the token cache before entity/live data calls fire in parallel. */ + protected async _init(): Promise { + await this.getToken(); + } + + // ── Authentication ──────────────────────────────────────────────────────── + + /** + * Stable device UUID — generated once, persisted in SQLite for 3 months. + * Ocean Park's API uses this to associate tokens with a logical device. + */ + @cache({ttlSeconds: 60 * 60 * 24 * 90}) + async getDeviceId(): Promise { + return crypto.randomUUID(); + } + + /** Raw HTTP call to the token endpoint — tagged 'auth' to exclude from injection. */ + // `tags` isn't in the @http decorator's option type yet but flows through + // to the request object — cast is the localised escape. + @http({tags: ['auth']} as any) + async fetchToken(): Promise { + const deviceId = await this.getDeviceId(); + return { + method: 'POST', + url: `${this.baseURL}/api/common/user/token`, + body: JSON.stringify({pId: this.parkId, lang: 'en', deviceId}), + headers: {'content-type': 'application/json'}, + options: {json: false}, + tags: ['auth'], + } as any as HTTPObj; + } + + /** + * Auth token with dynamic TTL. + * Returns an object with `token` + `ttl` so @cache can read the expiry. + * Use getToken() to obtain just the token string. + */ + @cache({callback: (result: {token: string; ttl: number}) => result.ttl}) + async getTokenData(): Promise<{token: string; ttl: number}> { + const resp = await this.fetchToken(); + const body: OceanParkTokenResponse = await resp.json(); + const token = body?.data?.token; + const tokenExpire = body?.data?.tokenExpire; + + if (!token) throw new Error('OceanPark: failed to obtain auth token'); + + const ttl = tokenExpire + ? Math.max((tokenExpire - Date.now()) / 1000, 60) + : 60 * 60 * 23; + + return {token, ttl}; + } + + /** Returns the current valid auth token. */ + async getToken(): Promise { + return (await this.getTokenData()).token; + } + + /** + * Inject the optoken header into every request to the main API domain, + * except for the token endpoint itself (excluded via tags filter). + */ + @inject({ + eventName: 'httpRequest', + hostname: function(this: OceanParkHongKong) { return hostnameFromUrl(this.baseURL); }, + tags: {$nin: ['auth']}, + }) + async injectToken(req: HTTPObj): Promise { + const token = await this.getToken(); + req.headers = { + ...req.headers, + 'optoken': token, + 'content-type': 'application/json', + }; + } + + // ── HTTP Fetch Methods ──────────────────────────────────────────────────── + + /** + * Fetch the entity list for a given sortId. + * sortId 7 = transport, 8 = rides, 15 = shows, 17 = dining. + * Short cache (60s) since this also carries live wait-time data. + */ + @http({cacheSeconds: 60}) + async fetchEntityList(sortId: number): Promise { + return { + method: 'POST', + url: `${this.baseURL}/api/common/entity/list`, + body: JSON.stringify({pId: this.parkId, lang: 'en', sortId}), + options: {json: false}, + } as any as HTTPObj; + } + + /** + * Fetch detailed info for a single entity (FastPass links, show schedule). + * Long cache (1h) since this data changes infrequently. + */ + @http({cacheSeconds: 3600}) + async fetchEntityDetail(entityId: number): Promise { + return { + method: 'POST', + url: `${this.baseURL}/api/common/entity/detail`, + body: JSON.stringify({pId: this.parkId, lang: 'en', entityId}), + options: {json: false}, + } as any as HTTPObj; + } + + /** Fetch 30-day park operating schedule. Refreshed every hour. */ + @http({cacheSeconds: 3600}) + async fetchParkSchedule(): Promise { + const today = formatDate(new Date(), TIMEZONE); + const end = formatDate(new Date(Date.now() + 30 * 24 * 3600 * 1000), TIMEZONE); + return { + method: 'POST', + url: `${this.baseURL}/api/common/park/list`, + body: JSON.stringify({pId: this.parkId, lang: 'en', startDate: today, endDate: end}), + options: {json: false}, + } as any as HTTPObj; + } + + /** Fetch reference points (pixel → lat/lng anchors) from the map subdomain. */ + @http({cacheSeconds: 86400}) + async fetchReferencePoints(): Promise { + return { + method: 'GET', + url: `${this.mapURL}/assets/data/reference_points.json`, + options: {json: true}, + } as any as HTTPObj; + } + + /** Fetch entity pixel positions for a given map category. */ + @http({cacheSeconds: 86400}) + async fetchMapCategoryData(category: string): Promise { + return { + method: 'GET', + url: `${this.mapURL}/assets/data/${category}.json`, + options: {json: true}, + } as any as HTTPObj; + } + + // ── Parsed Accessors ────────────────────────────────────────────────────── + // + // No @cache wrapper here — the underlying @http fetcher already caches the + // raw response at the same TTL. Adding @cache on top would just double-write + // the parsed payload to SQLite. Failures still propagate from the fetch + // layer, so a transient bad upstream doesn't poison a parsed-empty result + // into the cache for a full TTL. + + async getEntityList(sortId: number): Promise { + const resp = await this.fetchEntityList(sortId); + const body: OceanParkEntityListResponse = await resp.json(); + return body?.data?.data ?? []; + } + + async getEntityDetail(entityId: number): Promise { + const resp = await this.fetchEntityDetail(entityId); + const body: OceanParkEntityDetailResponse = await resp.json(); + return body?.data ?? {}; + } + + async getParkSchedule(): Promise { + const resp = await this.fetchParkSchedule(); + const body: OceanParkScheduleResponse = await resp.json(); + return body?.data?.parkOperatingHourList ?? []; + } + + /** + * Build a serialisable map from api_key → {latitude, longitude} by: + * 1. Fetching reference points and computing an affine pixel→geo transform. + * 2. Fetching each map category and projecting each entity's pixel position. + * + * Returned as an array of [key, value] pairs. + * + * No @cache here — degenerate input must throw so the underlying @http + * fetchers (24h TTL each) keep retrying instead of pinning every entity + * to its default location for a day. Callers are expected to catch and + * fall back to no-coords on transient failure. + */ + async getCoordinateMapEntries(): Promise<[string, {latitude: number; longitude: number}][]> { + const refResp = await this.fetchReferencePoints(); + const refPoints: OceanParkReferencePoint[] = await refResp.json(); + if (!Array.isArray(refPoints) || refPoints.length < 3) { + throw new Error( + `OceanPark: reference points payload invalid (got ${Array.isArray(refPoints) ? `${refPoints.length} entries` : typeof refPoints})`, + ); + } + + const coeffs = computeAffineTransform(refPoints); + if (!coeffs) { + throw new Error('OceanPark: affine transform degenerate (collinear or duplicate reference points)'); + } + const entries: [string, {latitude: number; longitude: number}][] = []; + + const categoryResponses = await Promise.all( + MAP_CATEGORIES.map((category) => this.fetchMapCategoryData(category)), + ); + for (const resp of categoryResponses) { + const entities: OceanParkMapEntity[] = await resp.json(); + if (!Array.isArray(entities)) continue; + + for (const e of entities) { + if (e.api_key != null && e.x != null && e.y != null) { + entries.push([ + String(e.api_key), + { + latitude: coeffs.a * e.x + coeffs.b * e.y + coeffs.c, + longitude: coeffs.d * e.x + coeffs.e * e.y + coeffs.f, + }, + ]); + } + } + } + + return entries; + } + + // ── Destination ─────────────────────────────────────────────────────────── + + async getDestinations(): Promise { + return [{ + id: DESTINATION_ID, + name: 'Ocean Park Hong Kong', + entityType: 'DESTINATION', + timezone: TIMEZONE, + location: {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity]; + } + + // ── Entity List ─────────────────────────────────────────────────────────── + + protected async buildEntityList(): Promise { + const [rides, transport, shows, dining, coordEntries] = await Promise.all([ + this.getEntityList(SORT_ID.RIDES), + this.getEntityList(SORT_ID.TRANSPORT), + this.getEntityList(SORT_ID.SHOWS), + this.getEntityList(SORT_ID.DINING), + // Coordinates are best-effort — a degenerate transform or unreachable + // map subdomain shouldn't take the whole entity list with it. Entities + // fall back to the destination's default lat/lng when this is empty. + this.getCoordinateMapEntries().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[OceanPark] coordinate map unavailable (${msg}); entities will use default location`); + return [] as [string, {latitude: number; longitude: number}][]; + }), + ]); + + const coordMap = new Map(coordEntries); + + const park: Entity = { + id: PARK_ID, + name: 'Ocean Park', + entityType: 'PARK', + parentId: DESTINATION_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + + // Fetch details for rides + transport to check for FastPass (relateList) + const attractions = [...rides, ...transport]; + const details = await Promise.all( + attractions.map(e => this.getEntityDetail(e.id).catch(() => ({} as OceanParkEntityDetail))), + ); + + const attractionEntities: Entity[] = attractions.map((entity, i) => { + // Source of truth for transport-vs-ride is the list this entity came + // from, not `entity.typeId` (which is optional and uses a different + // ID space than SORT_ID). Everything past `rides.length` came from + // the transport list. + const isTransport = i >= rides.length; + const coords = coordMap.get(String(entity.extEntityCode)); + const detail = details[i]; + const tags = []; + + // Coordinates already live on `entity.location` below — don't also + // push a TagBuilder.location() tag (per TagBuilder docs, that helper + // is for sub-locations distinct from the entity's own location). + const conditionList = entity.conditionList ?? []; + const {min, max} = parseHeightTag(conditionList); + if (min !== null) tags.push(TagBuilder.minimumHeight(min, 'cm')); + if (max !== null) tags.push(TagBuilder.maximumHeight(max, 'cm')); + + const hasPregnantWarning = conditionList.some(c => { + const text = typeof c === 'string' ? c : (c.conditionDesc ?? c.description ?? ''); + return /pregnant/i.test(text); + }); + if (hasPregnantWarning) tags.push(TagBuilder.unsuitableForPregnantPeople()); + + if (entity.raFacilityType === 'Wet Rides') tags.push(TagBuilder.mayGetWet()); + + const hasFastPass = Array.isArray(detail?.relateList) && + detail.relateList.some(r => r.type === 'ticket'); + if (hasFastPass) tags.push(TagBuilder.paidReturnTime()); + + const built: Entity = { + id: `attraction_${entity.id}`, + name: entity.name, + entityType: 'ATTRACTION', + attractionType: isTransport ? AttractionTypeEnum.TRANSPORT : AttractionTypeEnum.RIDE, + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + + if (tags.length > 0) built.tags = tags; + return built; + }); + + const showEntities: Entity[] = shows.map(entity => { + const coords = coordMap.get(String(entity.extEntityCode)); + return { + id: `show_${entity.id}`, + name: entity.name, + entityType: 'SHOW', + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + }); + + const restaurantEntities: Entity[] = dining.map(entity => { + const coords = coordMap.get(String(entity.extEntityCode)); + return { + id: `restaurant_${entity.id}`, + name: entity.name, + entityType: 'RESTAURANT', + parentId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: TIMEZONE, + location: coords ?? {latitude: DEFAULT_LAT, longitude: DEFAULT_LNG}, + } as Entity; + }); + + return [park, ...attractionEntities, ...showEntities, ...restaurantEntities]; + } + + // ── Live Data ───────────────────────────────────────────────────────────── + + protected async buildLiveData(): Promise { + const today = formatDate(new Date(), TIMEZONE); + + const [rides, transport, shows] = await Promise.all([ + this.getEntityList(SORT_ID.RIDES), + this.getEntityList(SORT_ID.TRANSPORT), + this.getEntityList(SORT_ID.SHOWS), + ]); + + const liveData: LiveData[] = []; + + // Rides and transport — include wait time and today's operating hours when open + for (const entity of [...rides, ...transport]) { + const pflow = entity.pflowInfo ?? {}; + const isOpen = pflow.entityStatus === 'open'; + + const ld: LiveData = { + id: `attraction_${entity.id}`, + status: isOpen ? 'OPERATING' : 'CLOSED', + } as LiveData; + + // Coerce + finite-check before emitting. The interface declares + // `number | null` but upstream APIs sometimes send strings; CLAUDE.md + // requires Number.isFinite over isNaN to handle empty-string coercion. + // Reject null/undefined/empty BEFORE coercing — Number(null) is 0, + // which would silently emit "0 min wait" for unknown waits. + const raw = pflow.entityWaitTime as number | string | null | undefined; + const wt = raw == null || raw === '' ? NaN : Number(raw); + if (isOpen && Number.isFinite(wt) && wt >= 0) { + ld.queue = {STANDBY: {waitTime: wt}}; + } + + const todayHours = (pflow.operatingHourList ?? []).find( + h => h.openDate === today && h.openTime && h.closeTime, + ); + if (todayHours) { + ld.operatingHours = [{ + // 'OPERATING' (uppercase) matches every other park's emission and + // any downstream string matchers. 'iso' format keeps the +08:00 + // offset consistent with buildSchedules below. + type: 'OPERATING', + startTime: formatInTimezone(new Date(todayHours.openTime!), TIMEZONE, 'iso'), + endTime: formatInTimezone(new Date(todayHours.closeTime!), TIMEZONE, 'iso'), + }]; + } + + liveData.push(ld); + } + + // Shows — include showtimes from entity detail activityList + const showDetails = await Promise.all( + shows.map(e => this.getEntityDetail(e.id).catch(() => ({} as OceanParkEntityDetail))), + ); + + const now = Date.now(); + for (let i = 0; i < shows.length; i++) { + const entity = shows[i]; + const isOpen = entity.pflowInfo?.entityStatus === 'open'; + const detail = showDetails[i]; + + // Filter out performances that have already ended today — a guest at + // 3pm shouldn't see this morning's 11am show listed as upcoming. + // Project timestamps via formatInTimezone so the emitted ISO strings + // carry the +08:00 offset (matches buildSchedules). + const showtimes = (detail.activityList ?? []).flatMap(activity => + (activity.timeList ?? []) + .filter(t => Number.isFinite(t.startTime) && Number.isFinite(t.endTime) && t.endTime >= now) + .map(t => ({ + type: 'Performance Time', + startTime: formatInTimezone(new Date(t.startTime), TIMEZONE, 'iso'), + endTime: formatInTimezone(new Date(t.endTime), TIMEZONE, 'iso'), + })), + ); + + const ld: LiveData = { + id: `show_${entity.id}`, + status: isOpen ? 'OPERATING' : 'CLOSED', + } as LiveData; + + if (showtimes.length > 0) ld.showtimes = showtimes; + + liveData.push(ld); + } + + return liveData; + } + + // ── Schedules ───────────────────────────────────────────────────────────── + + protected async buildSchedules(): Promise { + const parkDays = await this.getParkSchedule(); + const scheduleEntries: object[] = []; + + /** Parse a Unix-ms timestamp string to a Date, rejecting non-finite values. */ + const parseTs = (v: string | number | undefined | null): Date | null => { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? new Date(n) : null; + }; + + for (const day of parkDays) { + if (day.parkStatus !== 'open') continue; + + const open = parseTs(day.parkOpenTime); + const close = parseTs(day.parkCloseTime); + if (!open || !close) continue; + + scheduleEntries.push({ + date: day.openDate, + type: 'OPERATING', + openingTime: formatInTimezone(open, TIMEZONE, 'iso'), + closingTime: formatInTimezone(close, TIMEZONE, 'iso'), + }); + + const parkingOpen = parseTs(day.parkingOpenTime); + const parkingClose = parseTs(day.parkingCloseTime); + if (parkingOpen && parkingClose) { + scheduleEntries.push({ + date: day.openDate, + type: 'INFORMATIONAL', + description: 'Parking', + openingTime: formatInTimezone(parkingOpen, TIMEZONE, 'iso'), + closingTime: formatInTimezone(parkingClose, TIMEZONE, 'iso'), + }); + } + + // The Summit zone closes earlier than the main park on some days. + // API has `summitStaus` (sic — upstream typo); also accept `summitStatus` + // defensively in case it's ever corrected. There's no `summitOpenTime` + // in the payload, so we assume Summit opens with the main park — + // documented as part of the description so downstream consumers know. + const summitStatus = day.summitStaus ?? (day as {summitStatus?: string}).summitStatus; + const summitClose = parseTs(day.summitCloseTime); + if ( + summitStatus === 'open' && + summitClose && + summitClose.getTime() < close.getTime() + ) { + scheduleEntries.push({ + date: day.openDate, + type: 'INFORMATIONAL', + description: 'The Summit (closes earlier than the rest of the park)', + openingTime: formatInTimezone(open, TIMEZONE, 'iso'), + closingTime: formatInTimezone(summitClose, TIMEZONE, 'iso'), + }); + } + } + + return [{ + id: PARK_ID, + schedule: scheduleEntries, + } as unknown as EntitySchedule]; + } +}