From ae6ce63a7894e245f48f0d476913a32b99d6325a Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Wed, 29 Jul 2026 12:13:36 +0000 Subject: [PATCH 1/2] feat(enchantedparks): live per-attraction status from operator feed Fill in the previously-empty buildLiveData() stub for all six Enchanted Parks. The operator's guest-experience platform publishes a per-attraction operationalStatus; map it to OPERATING / DOWN / CLOSED and join it to the scraped ride entities by normalized name. - maps the feed's free-text status (Open/OPEN, Temporarily Closed/ TEMPORARILY_CLOSED, Closed) to OPERATING / DOWN / CLOSED, preserving the temporary-closure state that is distinct from an all-day close - name normalization strips the feed's park-code prefix ("WOF - ") so it joins to the scraped ride names; sibling-park names can't bleed across - endpoint + key are @config (values in .env), never hardcoded - per-park site-name mapping; fetch paginates, caches 60s, degrades to no-op on error so a transient miss just skips the tick Tests: status-map, name-normalization, feature->entity match, and a stubbed-network buildLiveData wiring test. Eval: fetchFeatures wired into `npm run health`; verified live via `npm run dev` (Worlds of Fun 54/55 attractions matched; Great Escape 49/49 incl. 38 DOWN). Co-Authored-By: Claude Opus 4.8 --- .../__tests__/enchantedparks.test.ts | 150 +++++++++++++ src/parks/enchantedparks/enchantedparks.ts | 211 +++++++++++++++++- .../galvestonislandwaterpark.ts | 1 + src/parks/enchantedparks/greatescapeparks.ts | 3 + .../enchantedparks/michigansadventure.ts | 1 + src/parks/enchantedparks/midamericaparks.ts | 2 + src/parks/enchantedparks/valleyfair.ts | 1 + src/parks/enchantedparks/worldsoffun.ts | 3 + 8 files changed, 370 insertions(+), 2 deletions(-) diff --git a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts index 54ef257f..eb57106a 100644 --- a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts +++ b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts @@ -3,6 +3,13 @@ import {parseTribeEvents, type TribeEventsResponse, EnchantedParks} from '../enc import {parseICalFeed} from '../enchantedparks.js'; import {parseAttractionsPage} from '../enchantedparks.js'; import {parseShowsPage} from '../enchantedparks.js'; +import { + mapFeatureStatus, + normalizeRideName, + normalizeFeatureName, + matchFeaturesToLiveData, + type LiveFeature, +} from '../enchantedparks.js'; describe('parseTribeEvents', () => { const fixture: TribeEventsResponse = { @@ -413,3 +420,146 @@ describe('attractionLocations wiring on every EnchantedParks subclass', () => { } }); }); + +describe('mapFeatureStatus', () => { + // The operator's live feed uses free-text status strings with inconsistent + // casing (Open / OPEN, Temporarily Closed / TEMPORARILY_CLOSED). Map them to + // the framework's canonical statuses. + test('open variants → OPERATING', () => { + expect(mapFeatureStatus('Open')).toBe('OPERATING'); + expect(mapFeatureStatus('OPEN')).toBe('OPERATING'); + expect(mapFeatureStatus('opened')).toBe('OPERATING'); + }); + + test('temporary-closure variants → DOWN (the state the app hides)', () => { + expect(mapFeatureStatus('Temporarily Closed')).toBe('DOWN'); + expect(mapFeatureStatus('TEMPORARILY_CLOSED')).toBe('DOWN'); + expect(mapFeatureStatus('temp closed')).toBe('DOWN'); + }); + + test('all-day closure → CLOSED', () => { + expect(mapFeatureStatus('Closed')).toBe('CLOSED'); + expect(mapFeatureStatus('CLOSED')).toBe('CLOSED'); + }); + + test('unknown / empty → CLOSED (safe default)', () => { + expect(mapFeatureStatus('')).toBe('CLOSED'); + expect(mapFeatureStatus('something new')).toBe('CLOSED'); + }); +}); + +describe('feature-name normalization', () => { + // Feature names carry a park-code prefix ("WOF - ", "OOF - ") that the + // scraped ride names don't. Stripping it lets the two sources join by name. + test('strips the park-code prefix from feature names', () => { + expect(normalizeFeatureName('WOF - RipCord')).toBe(normalizeRideName('RipCord')); + expect(normalizeFeatureName('OOF - Typhoon')).toBe(normalizeRideName('Typhoon')); + expect(normalizeFeatureName('SSA - Oasis Bar')).toBe(normalizeRideName('Oasis Bar')); + }); + + test('does NOT strip a dash from an unprefixed scraped ride name', () => { + // A scraped name is passed through normalizeRideName, which must not eat + // leading words — only the uppercase-code prefix on the feature side goes. + expect(normalizeRideName('Timber Wolf')).toBe('timber wolf'); + expect(normalizeRideName('Wild Thing')).toBe('wild thing'); + }); + + test('folds curly apostrophes so both sources agree', () => { + expect(normalizeFeatureName('MA - Thunderhawk’s')).toBe(normalizeRideName("Thunderhawk's")); + }); +}); + +describe('matchFeaturesToLiveData', () => { + const rides = [ + {id: 'enchantedparks_attraction_WOF_ripcord', name: 'RipCord'}, + {id: 'enchantedparks_attraction_WOF_zambezi-zinger', name: 'Zambezi Zinger'}, + {id: 'enchantedparks_attraction_WOF_mamba', name: 'Mamba'}, + {id: 'enchantedparks_attraction_OOF_typhoon', name: 'Typhoon'}, + ]; + + const features: LiveFeature[] = [ + {name: 'WOF - RipCord', parentName: 'Worlds of Fun', operationalStatus: 'Temporarily Closed'}, + {name: 'WOF - Zambezi Zinger', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Closed'}, + {name: 'OOF - Typhoon', parentName: 'Worlds of Fun', operationalStatus: 'OPEN'}, + // Non-ride POS feature — no matching ride entity, must be dropped. + {name: 'WOF - Ticket Sales', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + // Feature from a different site — must be ignored even if name collides. + {name: 'VF - RipCord', parentName: 'Valleyfair', operationalStatus: 'Closed'}, + ]; + + test('maps each matched ride to its live status by id', () => { + const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + const byId = Object.fromEntries(out.map((l) => [l.id, l.status])); + expect(byId['enchantedparks_attraction_WOF_ripcord']).toBe('DOWN'); + expect(byId['enchantedparks_attraction_WOF_zambezi-zinger']).toBe('OPERATING'); + expect(byId['enchantedparks_attraction_WOF_mamba']).toBe('CLOSED'); + expect(byId['enchantedparks_attraction_OOF_typhoon']).toBe('OPERATING'); + }); + + test('drops features with no matching ride entity (POS, retail, gates)', () => { + const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + // 4 rides matched, Ticket Sales dropped. + expect(out).toHaveLength(4); + expect(out.every((l) => l.id.startsWith('enchantedparks_attraction_'))).toBe(true); + }); + + test('only uses features from the requested site(s)', () => { + // The Valleyfair "VF - RipCord" (Closed) must not overwrite the Worlds of + // Fun RipCord (Temporarily Closed → DOWN). + const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + const ripcord = out.find((l) => l.id === 'enchantedparks_attraction_WOF_ripcord'); + expect(ripcord?.status).toBe('DOWN'); + }); + + test('returns empty when no site names are supplied', () => { + expect(matchFeaturesToLiveData(features, [], rides)).toEqual([]); + }); +}); + +describe('buildLiveData wiring (stubbed network)', () => { + // Integration of the pieces without hitting the real feed: stub the two + // network-backed getters and assert buildLiveData joins them to the right + // entity ids and honours the empty-config guards. Full live integration is + // exercised via `npm run dev -- ` / `npm run health`. + async function makeWorldsOfFun(): Promise { + const mod = await import('../worldsoffun.js'); + const ParkClass = Object.values(mod)[0] as new () => EnchantedParks; + return new ParkClass(); + } + + test('joins feed features to scraped attractions by name', async () => { + const park = await makeWorldsOfFun(); + (park as any).liveStatusEndpoint = 'https://example.invalid/graphql'; + (park as any).liveStatusApiKey = 'test-key'; + (park as any).getFeatures = async (): Promise => [ + {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + {name: 'WOF - Prowler', parentName: 'Worlds of Fun', operationalStatus: 'Temporarily Closed'}, + {name: 'OOF - Typhoon', parentName: 'Worlds of Fun', operationalStatus: 'Closed'}, + {name: 'WOF - Ticket Sales', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + ]; + (park as any).scrapeAttractions = async (path: string) => + path === 'oceans-of-fun' + ? [{slug: 'typhoon', name: 'Typhoon'}] + : [{slug: 'mamba', name: 'Mamba'}, {slug: 'prowler', name: 'Prowler'}]; + + const live = await (park as any).buildLiveData(); + const byId = Object.fromEntries(live.map((l: any) => [l.id, l.status])); + + expect(byId['enchantedparks_attraction_WOF_mamba']).toBe('OPERATING'); + expect(byId['enchantedparks_attraction_WOF_prowler']).toBe('DOWN'); + expect(byId['enchantedparks_attraction_OOF_typhoon']).toBe('CLOSED'); + // Ticket Sales has no scraped ride entity → dropped. + expect(live).toHaveLength(3); + }); + + test('returns no live data when the endpoint/key are unset', async () => { + const park = await makeWorldsOfFun(); + (park as any).liveStatusEndpoint = ''; + (park as any).liveStatusApiKey = ''; + (park as any).getFeatures = async () => [ + {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + ]; + expect(await (park as any).buildLiveData()).toEqual([]); + }); +}); diff --git a/src/parks/enchantedparks/enchantedparks.ts b/src/parks/enchantedparks/enchantedparks.ts index b045ed4d..14537f86 100644 --- a/src/parks/enchantedparks/enchantedparks.ts +++ b/src/parks/enchantedparks/enchantedparks.ts @@ -5,6 +5,7 @@ import config from '../../config.js'; import type {Entity, LiveData, EntitySchedule, ScheduleEntry} from '@themeparks/typelib'; import {constructDateTime, formatDate} from '../../datetime.js'; import {decodeHtmlEntities, stripHtmlTags} from '../../htmlUtils.js'; +import {createStatusMap} from '../../statusMap.js'; export type TribeEvent = { start_date: string; // "YYYY-MM-DD HH:MM:SS" @@ -208,6 +209,98 @@ export function parseShowsPage(html: string, categorySlug: string): AttractionSt return out; } +// ===== Live ride status (operator guest-experience API) ===== +// +// The operator runs a guest-experience platform whose live feed publishes a +// per-attraction `operationalStatus`. It's the only real-time source for these +// parks (there are no published wait times). The status distinguishes an +// all-day CLOSED ride from a temporarily-DOWN one — a distinction the operator +// app itself collapses. Endpoint + key are config (kept in `.env`), never +// hardcoded here. + +/** One attraction record from the operator's live feed. */ +export type LiveFeature = { + /** Display name, prefixed with a park code, e.g. "WOF - RipCord". */ + name: string; + /** Site the feature belongs to, e.g. "Worlds of Fun". */ + parentName: string; + /** Free-text operational status, e.g. "Open", "Temporarily Closed". */ + operationalStatus: string; +}; + +/** + * Map the feed's free-text status to a framework status. The feed uses + * inconsistent casing (Open/OPEN) and both spaced and underscored temporary- + * closure strings; `createStatusMap` lowercases before lookup so one entry per + * spelling covers every casing. Unknown/empty defaults to CLOSED and logs once. + */ +export const mapFeatureStatus = createStatusMap( + { + OPERATING: ['open', 'opened'], + DOWN: ['temporarily closed', 'temporarily_closed', 'temp closed', 'temp closed due weather'], + CLOSED: ['closed', 'not scheduled', ''], + }, + {parkName: 'EnchantedParks', defaultStatus: 'CLOSED'}, +); + +/** + * Normalize a scraped ride name for cross-source matching: fold curly + * apostrophes and trademark glyphs, lowercase, and collapse any run of + * non-alphanumerics to a single space. Does NOT strip leading words. + */ +export function normalizeRideName(name: string): string { + return name + .replace(/[‘’]/g, "'") + .replace(/[™®©]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +/** + * Normalize a live-feed feature name. Same as {@link normalizeRideName} but + * first strips the uppercase park-code prefix ("WOF - ", "OOF - ", "SSA - ") + * that the feed carries and the scraped ride names don't. The prefix strip is + * uppercase-only so it can't eat a leading word from a scraped name. + */ +export function normalizeFeatureName(name: string): string { + return normalizeRideName(name.replace(/^[A-Z]{2,5}\s*-\s*/, '')); +} + +/** + * Join live-feed features to scraped ride entities by normalized name and + * emit LiveData. Only features whose `parentName` is in `siteNames` are used, + * so a same-named ride at a sibling park can't bleed across. Features with no + * matching ride entity (POS registers, gates, retail carts, points offers) are + * dropped — the ride roster is the scraped entity list, not the feed. + */ +export function matchFeaturesToLiveData( + features: LiveFeature[], + siteNames: string[], + rides: Array<{id: string; name: string}>, +): LiveData[] { + if (!siteNames.length) return []; + const siteSet = new Set(siteNames); + const statusByName = new Map(); + for (const f of features) { + if (!siteSet.has(f.parentName)) continue; + const key = normalizeFeatureName(f.name); + if (!key) continue; + statusByName.set(key, mapFeatureStatus(f.operationalStatus)); + } + + const out: LiveData[] = []; + const emitted = new Set(); + for (const r of rides) { + if (emitted.has(r.id)) continue; + const status = statusByName.get(normalizeRideName(r.name)); + if (!status) continue; + emitted.add(r.id); + out.push({id: r.id, status} as LiveData); + } + return out; +} + export type ParkConfig = { /** Entity id, e.g. `enchantedparks_park_VF` */ id: string; @@ -227,6 +320,10 @@ export type ParkConfig = { location?: {latitude: number; longitude: number}; }; +/** Paginated list of live attraction statuses across every site. */ +const LIST_FEATURES_QUERY = + 'query($nextToken:String){ listFeatures(limit:300, nextToken:$nextToken){ nextToken items{ name parentName operationalStatus } } }'; + class EnchantedParks extends Destination { /** Subdomain root, e.g. `https://valleyfair.enchantedparks.com` (no trailing slash) */ @config subdomain: string = ''; @@ -284,6 +381,21 @@ class EnchantedParks extends Destination { /** IANA timezone for the destination */ @config timezone: string = 'America/Chicago'; + /** + * Operator live guest-experience API — GraphQL endpoint and public key. + * Empty by default; real values come from `.env` (never hardcoded per the + * no-secrets rule). When either is empty, live data is skipped. + */ + @config liveStatusEndpoint: string = ''; + @config liveStatusApiKey: string = ''; + + /** + * Site name(s) in the operator's live feed that belong to this destination + * (matched against a feature's `parentName`). Set per subclass. When unset, + * live data is skipped. + */ + liveStatusSites?: string[]; + constructor(options?: DestinationConstructor) { super(options); this.addConfigPrefix('ENCHANTEDPARKS'); @@ -292,6 +404,7 @@ class EnchantedParks extends Destination { if (cfg.themePark) this.themePark = cfg.themePark; if (cfg.waterPark) this.waterPark = cfg.waterPark; if (cfg.destinationLocation) this.destinationLocation = cfg.destinationLocation; + if (cfg.liveStatusSites) this.liveStatusSites = cfg.liveStatusSites; } /** Cache-key prefix so multiple Enchanted Parks don't collide on shared cache keys. */ @@ -575,9 +688,103 @@ class EnchantedParks extends Destination { return [...parks, ...attractions]; } + // ===== Live ride status ===== + + /** + * Fetch one page of the operator's live attraction feed. Cached 1min at the + * HTTP layer — every subclass POSTs the same query/body, so the shared HTTP + * cache serves all six parks from a single network round-trip per page. + * `retries: 0` — a transient miss just yields no live update this tick. + */ + @http({cacheSeconds: 60, retries: 0, healthCheckArgs: ['']}) + async fetchFeatures(nextToken: string | null): Promise { + return { + method: 'POST', + url: this.liveStatusEndpoint, + headers: { + 'x-api-key': this.liveStatusApiKey, + 'content-type': 'application/json', + }, + // Coerce an empty token (e.g. from the health check's args) to null so + // the feed returns the first page rather than erroring on an empty string. + body: {query: LIST_FEATURES_QUERY, variables: {nextToken: nextToken || null}}, + options: {json: true}, + } as any as HTTPObj; + } + + /** + * All live attraction records across every site, following pagination. + * Returns [] on any failure so live-data build degrades to "no update" + * rather than throwing. + */ + @cache({ttlSeconds: 60}) + async getFeatures(): Promise { + if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; + const out: LiveFeature[] = []; + let nextToken: string | null = null; + let guard = 0; + try { + do { + const resp = await this.fetchFeatures(nextToken); + const data = await resp.json(); + const box = data?.data?.listFeatures; + if (!box) break; + for (const it of box.items ?? []) { + if (!it?.name) continue; + out.push({ + name: it.name, + parentName: it.parentName ?? '', + operationalStatus: it.operationalStatus ?? '', + }); + } + nextToken = box.nextToken ?? null; + } while (nextToken && ++guard < 20); + } catch { + return []; + } + return out; + } + + /** + * Rebuild the (entity id, name) pairs for this destination's attractions, + * using the same id formula as {@link buildEntityList} so live data attaches + * to the entities already emitted. Restaurants and shows are excluded — the + * live feed only carries attractions. + */ + private async getAttractionStubs(): Promise> { + const stubs: Array<{id: string; name: string}> = []; + + let waterParkSlugs = new Set(); + if (this.waterPark) { + const wpRides = await this.scrapeAttractions(this.waterPark.ridesPath); + waterParkSlugs = new Set(wpRides.map(r => r.slug)); + for (const r of wpRides) { + stubs.push({id: `enchantedparks_attraction_${this.waterPark.code}_${r.slug}`, name: r.name}); + } + } + + if (this.themePark) { + const tpRides = await this.scrapeAttractions(this.themePark.ridesPath); + for (const r of tpRides) { + if (waterParkSlugs.has(r.slug)) continue; + stubs.push({id: `enchantedparks_attraction_${this.themePark.code}_${r.slug}`, name: r.name}); + } + } + + return stubs; + } + protected async buildLiveData(): Promise { - // No live wait-times source for Enchanted Parks until their app launches. - return []; + // Live per-attraction status from the operator's guest-experience feed. + // Requires the endpoint/key (from .env) and the site-name mapping. + if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; + if (!this.liveStatusSites?.length) return []; + + const features = await this.getFeatures(); + if (!features.length) return []; + + const rides = await this.getAttractionStubs(); + return matchFeaturesToLiveData(features, this.liveStatusSites, rides); } protected async buildSchedules(): Promise { diff --git a/src/parks/enchantedparks/galvestonislandwaterpark.ts b/src/parks/enchantedparks/galvestonislandwaterpark.ts index b16d27a9..c52cb29c 100644 --- a/src/parks/enchantedparks/galvestonislandwaterpark.ts +++ b/src/parks/enchantedparks/galvestonislandwaterpark.ts @@ -16,6 +16,7 @@ export class GalvestonIslandWaterpark extends EnchantedParks { }, }); this.attractionLocations = locations; + this.liveStatusSites ??= ['Galveston']; this.destinationLocation ??= {latitude: 29.2767, longitude: -94.8231}; // Water-park-only destination: the upstream site exposes a single // `/rides-and-experiences/attractions/` list under the "Waterpark Hours" diff --git a/src/parks/enchantedparks/greatescapeparks.ts b/src/parks/enchantedparks/greatescapeparks.ts index 5871fe6b..5f782a1c 100644 --- a/src/parks/enchantedparks/greatescapeparks.ts +++ b/src/parks/enchantedparks/greatescapeparks.ts @@ -16,6 +16,9 @@ export class GreatEscapeParks extends EnchantedParks { }, }); this.attractionLocations = locations; + // The Great Escape's water park is a separate site ("Whitewater Bay") in + // the live feed; include both so its rides get status too. + this.liveStatusSites ??= ['Great Escape', 'Whitewater Bay']; this.destinationLocation ??= {latitude: 43.3506, longitude: -73.6889}; this.themePark ??= { id: 'enchantedparks_park_GE', diff --git a/src/parks/enchantedparks/michigansadventure.ts b/src/parks/enchantedparks/michigansadventure.ts index 755b3288..76ec3f0e 100644 --- a/src/parks/enchantedparks/michigansadventure.ts +++ b/src/parks/enchantedparks/michigansadventure.ts @@ -16,6 +16,7 @@ export class MichigansAdventure extends EnchantedParks { }, }); this.attractionLocations = locations; + this.liveStatusSites ??= ['Michigan\'s Adventure']; this.destinationLocation ??= {latitude: 43.3411, longitude: -86.2625}; this.themePark ??= { id: 'enchantedparks_park_MA', diff --git a/src/parks/enchantedparks/midamericaparks.ts b/src/parks/enchantedparks/midamericaparks.ts index 53844867..2b9d6026 100644 --- a/src/parks/enchantedparks/midamericaparks.ts +++ b/src/parks/enchantedparks/midamericaparks.ts @@ -16,6 +16,8 @@ export class MidAmericaParks extends EnchantedParks { }, }); this.attractionLocations = locations; + // Six Flags St. Louis is the "St Louis" site in the operator's live feed. + this.liveStatusSites ??= ['St Louis']; this.destinationLocation ??= {latitude: 38.5128, longitude: -90.6724}; this.themePark ??= { id: 'enchantedparks_park_MAP', diff --git a/src/parks/enchantedparks/valleyfair.ts b/src/parks/enchantedparks/valleyfair.ts index fba1e4f4..1f2dab30 100644 --- a/src/parks/enchantedparks/valleyfair.ts +++ b/src/parks/enchantedparks/valleyfair.ts @@ -20,6 +20,7 @@ export class Valleyfair extends EnchantedParks { // these if a caller wants to supply different ones — only set defaults // when the caller hasn't. this.attractionLocations = locations; + this.liveStatusSites ??= ['Valleyfair']; this.destinationLocation ??= {latitude: 44.7977, longitude: -93.4399}; this.themePark ??= { id: 'enchantedparks_park_VF', diff --git a/src/parks/enchantedparks/worldsoffun.ts b/src/parks/enchantedparks/worldsoffun.ts index b184d7e8..37e17d17 100644 --- a/src/parks/enchantedparks/worldsoffun.ts +++ b/src/parks/enchantedparks/worldsoffun.ts @@ -16,6 +16,9 @@ export class WorldsOfFun extends EnchantedParks { }, }); this.attractionLocations = locations; + // Oceans of Fun rides appear under the "Worlds of Fun" site in the live + // feed (prefixed "OOF -"); matching is by name so one site name covers both. + this.liveStatusSites ??= ['Worlds of Fun']; this.destinationLocation ??= {latitude: 39.1746, longitude: -94.4886}; this.themePark ??= { id: 'enchantedparks_park_WOF', From 998a6be3c1e4dbbf323f537e2b1b18413e65556d Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Wed, 29 Jul 2026 12:58:02 +0000 Subject: [PATCH 2/2] refactor(enchantedparks): match live status by stable site id; cache 2min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - select sites by the feed's parentAssignmentId (== the site's UUID) rather than the display name, so renaming a park (e.g. "St Louis" → "Six Flags St. Louis") can't silently drop its live status - bump the live feed cache 60s → 120s so a single fetch serves all six parks across a longer window Co-Authored-By: Claude Opus 4.8 --- .../__tests__/enchantedparks.test.ts | 36 +++++++------- src/parks/enchantedparks/enchantedparks.ts | 47 ++++++++++--------- .../galvestonislandwaterpark.ts | 2 +- src/parks/enchantedparks/greatescapeparks.ts | 5 +- .../enchantedparks/michigansadventure.ts | 2 +- src/parks/enchantedparks/midamericaparks.ts | 2 +- src/parks/enchantedparks/valleyfair.ts | 2 +- src/parks/enchantedparks/worldsoffun.ts | 4 +- 8 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts index eb57106a..d5da339f 100644 --- a/src/parks/enchantedparks/__tests__/enchantedparks.test.ts +++ b/src/parks/enchantedparks/__tests__/enchantedparks.test.ts @@ -477,19 +477,22 @@ describe('matchFeaturesToLiveData', () => { {id: 'enchantedparks_attraction_OOF_typhoon', name: 'Typhoon'}, ]; + const WOF = 'site-uuid-wof'; + const VF = 'site-uuid-vf'; + const features: LiveFeature[] = [ - {name: 'WOF - RipCord', parentName: 'Worlds of Fun', operationalStatus: 'Temporarily Closed'}, - {name: 'WOF - Zambezi Zinger', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, - {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Closed'}, - {name: 'OOF - Typhoon', parentName: 'Worlds of Fun', operationalStatus: 'OPEN'}, + {name: 'WOF - RipCord', siteId: WOF, operationalStatus: 'Temporarily Closed'}, + {name: 'WOF - Zambezi Zinger', siteId: WOF, operationalStatus: 'Open'}, + {name: 'WOF - Mamba', siteId: WOF, operationalStatus: 'Closed'}, + {name: 'OOF - Typhoon', siteId: WOF, operationalStatus: 'OPEN'}, // Non-ride POS feature — no matching ride entity, must be dropped. - {name: 'WOF - Ticket Sales', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + {name: 'WOF - Ticket Sales', siteId: WOF, operationalStatus: 'Open'}, // Feature from a different site — must be ignored even if name collides. - {name: 'VF - RipCord', parentName: 'Valleyfair', operationalStatus: 'Closed'}, + {name: 'VF - RipCord', siteId: VF, operationalStatus: 'Closed'}, ]; test('maps each matched ride to its live status by id', () => { - const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + const out = matchFeaturesToLiveData(features, [WOF], rides); const byId = Object.fromEntries(out.map((l) => [l.id, l.status])); expect(byId['enchantedparks_attraction_WOF_ripcord']).toBe('DOWN'); expect(byId['enchantedparks_attraction_WOF_zambezi-zinger']).toBe('OPERATING'); @@ -498,21 +501,21 @@ describe('matchFeaturesToLiveData', () => { }); test('drops features with no matching ride entity (POS, retail, gates)', () => { - const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + const out = matchFeaturesToLiveData(features, [WOF], rides); // 4 rides matched, Ticket Sales dropped. expect(out).toHaveLength(4); expect(out.every((l) => l.id.startsWith('enchantedparks_attraction_'))).toBe(true); }); - test('only uses features from the requested site(s)', () => { + test('only uses features from the requested site id(s)', () => { // The Valleyfair "VF - RipCord" (Closed) must not overwrite the Worlds of // Fun RipCord (Temporarily Closed → DOWN). - const out = matchFeaturesToLiveData(features, ['Worlds of Fun'], rides); + const out = matchFeaturesToLiveData(features, [WOF], rides); const ripcord = out.find((l) => l.id === 'enchantedparks_attraction_WOF_ripcord'); expect(ripcord?.status).toBe('DOWN'); }); - test('returns empty when no site names are supplied', () => { + test('returns empty when no site ids are supplied', () => { expect(matchFeaturesToLiveData(features, [], rides)).toEqual([]); }); }); @@ -532,11 +535,12 @@ describe('buildLiveData wiring (stubbed network)', () => { const park = await makeWorldsOfFun(); (park as any).liveStatusEndpoint = 'https://example.invalid/graphql'; (park as any).liveStatusApiKey = 'test-key'; + (park as any).liveStatusSiteIds = ['test-site']; (park as any).getFeatures = async (): Promise => [ - {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, - {name: 'WOF - Prowler', parentName: 'Worlds of Fun', operationalStatus: 'Temporarily Closed'}, - {name: 'OOF - Typhoon', parentName: 'Worlds of Fun', operationalStatus: 'Closed'}, - {name: 'WOF - Ticket Sales', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + {name: 'WOF - Mamba', siteId: 'test-site', operationalStatus: 'Open'}, + {name: 'WOF - Prowler', siteId: 'test-site', operationalStatus: 'Temporarily Closed'}, + {name: 'OOF - Typhoon', siteId: 'test-site', operationalStatus: 'Closed'}, + {name: 'WOF - Ticket Sales', siteId: 'test-site', operationalStatus: 'Open'}, ]; (park as any).scrapeAttractions = async (path: string) => path === 'oceans-of-fun' @@ -558,7 +562,7 @@ describe('buildLiveData wiring (stubbed network)', () => { (park as any).liveStatusEndpoint = ''; (park as any).liveStatusApiKey = ''; (park as any).getFeatures = async () => [ - {name: 'WOF - Mamba', parentName: 'Worlds of Fun', operationalStatus: 'Open'}, + {name: 'WOF - Mamba', siteId: 'test-site', operationalStatus: 'Open'}, ]; expect(await (park as any).buildLiveData()).toEqual([]); }); diff --git a/src/parks/enchantedparks/enchantedparks.ts b/src/parks/enchantedparks/enchantedparks.ts index 14537f86..4b733ab3 100644 --- a/src/parks/enchantedparks/enchantedparks.ts +++ b/src/parks/enchantedparks/enchantedparks.ts @@ -222,8 +222,12 @@ export function parseShowsPage(html: string, categorySlug: string): AttractionSt export type LiveFeature = { /** Display name, prefixed with a park code, e.g. "WOF - RipCord". */ name: string; - /** Site the feature belongs to, e.g. "Worlds of Fun". */ - parentName: string; + /** + * Stable UUID of the site the feature belongs to (the feed's + * `parentAssignmentId`, which equals the site's `id`). Preferred over the + * display name for site selection — it survives renames. + */ + siteId: string; /** Free-text operational status, e.g. "Open", "Temporarily Closed". */ operationalStatus: string; }; @@ -269,21 +273,21 @@ export function normalizeFeatureName(name: string): string { /** * Join live-feed features to scraped ride entities by normalized name and - * emit LiveData. Only features whose `parentName` is in `siteNames` are used, - * so a same-named ride at a sibling park can't bleed across. Features with no + * emit LiveData. Only features whose `siteId` is in `siteIds` are used, so a + * same-named ride at a sibling park can't bleed across. Features with no * matching ride entity (POS registers, gates, retail carts, points offers) are * dropped — the ride roster is the scraped entity list, not the feed. */ export function matchFeaturesToLiveData( features: LiveFeature[], - siteNames: string[], + siteIds: string[], rides: Array<{id: string; name: string}>, ): LiveData[] { - if (!siteNames.length) return []; - const siteSet = new Set(siteNames); + if (!siteIds.length) return []; + const siteSet = new Set(siteIds); const statusByName = new Map(); for (const f of features) { - if (!siteSet.has(f.parentName)) continue; + if (!siteSet.has(f.siteId)) continue; const key = normalizeFeatureName(f.name); if (!key) continue; statusByName.set(key, mapFeatureStatus(f.operationalStatus)); @@ -322,7 +326,7 @@ export type ParkConfig = { /** Paginated list of live attraction statuses across every site. */ const LIST_FEATURES_QUERY = - 'query($nextToken:String){ listFeatures(limit:300, nextToken:$nextToken){ nextToken items{ name parentName operationalStatus } } }'; + 'query($nextToken:String){ listFeatures(limit:300, nextToken:$nextToken){ nextToken items{ name parentAssignmentId operationalStatus } } }'; class EnchantedParks extends Destination { /** Subdomain root, e.g. `https://valleyfair.enchantedparks.com` (no trailing slash) */ @@ -390,11 +394,11 @@ class EnchantedParks extends Destination { @config liveStatusApiKey: string = ''; /** - * Site name(s) in the operator's live feed that belong to this destination - * (matched against a feature's `parentName`). Set per subclass. When unset, - * live data is skipped. + * Stable site UUID(s) in the operator's live feed that belong to this + * destination (matched against a feature's `parentAssignmentId`). Set per + * subclass. When unset, live data is skipped. */ - liveStatusSites?: string[]; + liveStatusSiteIds?: string[]; constructor(options?: DestinationConstructor) { super(options); @@ -404,7 +408,7 @@ class EnchantedParks extends Destination { if (cfg.themePark) this.themePark = cfg.themePark; if (cfg.waterPark) this.waterPark = cfg.waterPark; if (cfg.destinationLocation) this.destinationLocation = cfg.destinationLocation; - if (cfg.liveStatusSites) this.liveStatusSites = cfg.liveStatusSites; + if (cfg.liveStatusSiteIds) this.liveStatusSiteIds = cfg.liveStatusSiteIds; } /** Cache-key prefix so multiple Enchanted Parks don't collide on shared cache keys. */ @@ -696,7 +700,7 @@ class EnchantedParks extends Destination { * cache serves all six parks from a single network round-trip per page. * `retries: 0` — a transient miss just yields no live update this tick. */ - @http({cacheSeconds: 60, retries: 0, healthCheckArgs: ['']}) + @http({cacheSeconds: 120, retries: 0, healthCheckArgs: ['']}) async fetchFeatures(nextToken: string | null): Promise { return { method: 'POST', @@ -714,10 +718,11 @@ class EnchantedParks extends Destination { /** * All live attraction records across every site, following pagination. - * Returns [] on any failure so live-data build degrades to "no update" - * rather than throwing. + * Cached 2min so the feed is fetched once and reused by every park for the + * window. Returns [] on any failure so live-data build degrades to "no + * update" rather than throwing. */ - @cache({ttlSeconds: 60}) + @cache({ttlSeconds: 120}) async getFeatures(): Promise { if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; const out: LiveFeature[] = []; @@ -733,7 +738,7 @@ class EnchantedParks extends Destination { if (!it?.name) continue; out.push({ name: it.name, - parentName: it.parentName ?? '', + siteId: it.parentAssignmentId ?? '', operationalStatus: it.operationalStatus ?? '', }); } @@ -778,13 +783,13 @@ class EnchantedParks extends Destination { // Live per-attraction status from the operator's guest-experience feed. // Requires the endpoint/key (from .env) and the site-name mapping. if (!this.liveStatusEndpoint || !this.liveStatusApiKey) return []; - if (!this.liveStatusSites?.length) return []; + if (!this.liveStatusSiteIds?.length) return []; const features = await this.getFeatures(); if (!features.length) return []; const rides = await this.getAttractionStubs(); - return matchFeaturesToLiveData(features, this.liveStatusSites, rides); + return matchFeaturesToLiveData(features, this.liveStatusSiteIds, rides); } protected async buildSchedules(): Promise { diff --git a/src/parks/enchantedparks/galvestonislandwaterpark.ts b/src/parks/enchantedparks/galvestonislandwaterpark.ts index c52cb29c..b698438e 100644 --- a/src/parks/enchantedparks/galvestonislandwaterpark.ts +++ b/src/parks/enchantedparks/galvestonislandwaterpark.ts @@ -16,7 +16,7 @@ export class GalvestonIslandWaterpark extends EnchantedParks { }, }); this.attractionLocations = locations; - this.liveStatusSites ??= ['Galveston']; + this.liveStatusSiteIds ??= ['33fd9067-be6d-4cdd-aa28-ae098d460147']; // Galveston this.destinationLocation ??= {latitude: 29.2767, longitude: -94.8231}; // Water-park-only destination: the upstream site exposes a single // `/rides-and-experiences/attractions/` list under the "Waterpark Hours" diff --git a/src/parks/enchantedparks/greatescapeparks.ts b/src/parks/enchantedparks/greatescapeparks.ts index 5f782a1c..a9a206a8 100644 --- a/src/parks/enchantedparks/greatescapeparks.ts +++ b/src/parks/enchantedparks/greatescapeparks.ts @@ -18,7 +18,10 @@ export class GreatEscapeParks extends EnchantedParks { this.attractionLocations = locations; // The Great Escape's water park is a separate site ("Whitewater Bay") in // the live feed; include both so its rides get status too. - this.liveStatusSites ??= ['Great Escape', 'Whitewater Bay']; + this.liveStatusSiteIds ??= [ + 'd0437b8a-ca50-4fed-9f8e-fcd50b2af04b', // Great Escape + 'ca6dc180-e3ef-4151-9c12-6cea9c43204d', // Whitewater Bay + ]; this.destinationLocation ??= {latitude: 43.3506, longitude: -73.6889}; this.themePark ??= { id: 'enchantedparks_park_GE', diff --git a/src/parks/enchantedparks/michigansadventure.ts b/src/parks/enchantedparks/michigansadventure.ts index 76ec3f0e..6a408b83 100644 --- a/src/parks/enchantedparks/michigansadventure.ts +++ b/src/parks/enchantedparks/michigansadventure.ts @@ -16,7 +16,7 @@ export class MichigansAdventure extends EnchantedParks { }, }); this.attractionLocations = locations; - this.liveStatusSites ??= ['Michigan\'s Adventure']; + this.liveStatusSiteIds ??= ['09c32d20-e8e4-482c-a200-d098d4007672']; // Michigan's Adventure this.destinationLocation ??= {latitude: 43.3411, longitude: -86.2625}; this.themePark ??= { id: 'enchantedparks_park_MA', diff --git a/src/parks/enchantedparks/midamericaparks.ts b/src/parks/enchantedparks/midamericaparks.ts index 2b9d6026..558095fd 100644 --- a/src/parks/enchantedparks/midamericaparks.ts +++ b/src/parks/enchantedparks/midamericaparks.ts @@ -17,7 +17,7 @@ export class MidAmericaParks extends EnchantedParks { }); this.attractionLocations = locations; // Six Flags St. Louis is the "St Louis" site in the operator's live feed. - this.liveStatusSites ??= ['St Louis']; + this.liveStatusSiteIds ??= ['535e5890-11cb-4c95-ac07-a927c6af5398']; // St Louis this.destinationLocation ??= {latitude: 38.5128, longitude: -90.6724}; this.themePark ??= { id: 'enchantedparks_park_MAP', diff --git a/src/parks/enchantedparks/valleyfair.ts b/src/parks/enchantedparks/valleyfair.ts index 1f2dab30..d8f76a5b 100644 --- a/src/parks/enchantedparks/valleyfair.ts +++ b/src/parks/enchantedparks/valleyfair.ts @@ -20,7 +20,7 @@ export class Valleyfair extends EnchantedParks { // these if a caller wants to supply different ones — only set defaults // when the caller hasn't. this.attractionLocations = locations; - this.liveStatusSites ??= ['Valleyfair']; + this.liveStatusSiteIds ??= ['146a4170-9ef0-4fbd-936f-1d1544c8ce88']; // Valleyfair this.destinationLocation ??= {latitude: 44.7977, longitude: -93.4399}; this.themePark ??= { id: 'enchantedparks_park_VF', diff --git a/src/parks/enchantedparks/worldsoffun.ts b/src/parks/enchantedparks/worldsoffun.ts index 37e17d17..656e905e 100644 --- a/src/parks/enchantedparks/worldsoffun.ts +++ b/src/parks/enchantedparks/worldsoffun.ts @@ -17,8 +17,8 @@ export class WorldsOfFun extends EnchantedParks { }); this.attractionLocations = locations; // Oceans of Fun rides appear under the "Worlds of Fun" site in the live - // feed (prefixed "OOF -"); matching is by name so one site name covers both. - this.liveStatusSites ??= ['Worlds of Fun']; + // feed (prefixed "OOF -"); ride-name matching folds both into this one site. + this.liveStatusSiteIds ??= ['86792ef7-20f2-417d-8efd-778d3c70ce0e']; // Worlds of Fun this.destinationLocation ??= {latitude: 39.1746, longitude: -94.4886}; this.themePark ??= { id: 'enchantedparks_park_WOF',