feat(attractionsio): venue & park opening hours, show times, and wider category coverage for all 16 parks#244
Conversation
The field was written as `operatinghours` (lowercase), which the base class never normalises, so the opening hours never reached schema-compliant consumers. Rename to the schema field `operatingHours`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the opening-hours and show-schedule data Attractions.io exposes for Heide Park but the base implementation dropped: - Restaurants now carry open/closed status (from the live `IsOpen` field — `IsOperational` only flags running rides) plus today's operating hours parsed from the live-data `OpeningTimes` field. - The park entity carries today's actual opening window from the live Resort record. - "Entertainment" POIs are now modelled as SHOW entities (via an overridable getShowCategories hook) and carry today's performance times, evaluated from each show's recurring `ShowTimes` set-algebra tree (union/intersection/range/period, plus defensive difference/complement). Show status is OPERATING only while a performance is still upcoming/ongoing, then CLOSED once the day's last performance has ended; the full day's schedule stays published in `showtimes`. Adds unit tests for the ShowTimes evaluator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l parks Lift the restaurant status/opening-hours, park opening window (Resort record) and per-show performance times out of HeideParkBase into the shared AttractionsIOV1 base, so every park that populates the underlying live-data / records fields benefits — not just Heide Park. - extend the live-data types with the venue + Resort OpeningTimes fields - unify buildLiveData to cover attractions, restaurants, park and shows - move the (now general) opening-hours parser and ShowTimes evaluator ahead of the base class; they were Heide-Park-local helpers before - HeideParkBase keeps only its Entertainment show-category override and its custom v2 opening-times schedule endpoint Verified across all 16 parks: entity and schedule output unchanged; Heide Park live output byte-identical; the other parks additionally emit restaurant and park opening hours. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rt times A `period` node with no `range_length` encodes a show start time with no duration (e.g. "13:30 daily"). The evaluator dropped these (rangeMs <= 0 → []), so any show built purely from point periods produced no times — which is nearly every park other than Heide Park. Heide Park's shows all carry a range_length, so the gap stayed hidden until the live-data generalisation surfaced the other parks' shows. - period: emit point occurrences as zero-length [s, s] intervals - make normalise/intersect point-aware so a point survives intersection with its season/weekday span, while positive intervals are unaffected - showTimesForDate emits point occurrences as start-only slots (no endTime, which the schema permits) since the duration is unknown Heide Park output is unchanged (all its shows use positive ranges). Alton Towers, Gardaland, LEGOLAND California et al. now surface their show start times. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several parks label their shows and food venues with category names the base lists did not cover, so those shows and restaurants were dropped entirely. Add the show labels observed across parks (Entertainment, Shows & 4D Movies, 4D Movies) and the dining labels, and drop the now redundant HeidePark show-category override since Entertainment is part of the base list. Attraction counts are unchanged for every park; shows now surface for Chessington, Windsor, California, Deutschland and Knoebels, and restaurants for Billund, Deutschland, Knoebels and Djurs Sommerland. Ambiguous mixed labels (e.g. dining-plus-retail) are deliberately left out to avoid misclassification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…how rewrite - Half-open point containment in the ShowTimes interval algebra: a point occurrence on a span's exclusive end is now excluded by intersection and a point on a subtracted span's inclusive start is removed by difference, matching the documented [start, end) semantics. Previously such boundary points produced a spurious extra slot. - Restaurant status falls back to the opening-hours window when the live feed omits IsOpen, instead of asserting CLOSED with no signal. - Shows no longer emit a second, conflicting live-data entry for an item that is also classified as an attraction or restaurant. - Describe the show-category override without naming a specific park, and use "-" JSDoc bullets to match the rest of the tree. - Add unit tests covering the point/boundary algebra branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cubehouse
left a comment
There was a problem hiding this comment.
Good scope on the category coverage and the show-time set-algebra — the union/intersection/difference/complement handling in evaluateShowTimeNode is a genuinely hard problem to model correctly, and the verification table across 16 parks shows real legwork. Found two production-impacting bugs and a coverage gap on the parts of this PR that changed the most, worth fixing before merge.
1. PARK entity's live status is hardcoded to OPERATING, regardless of the time of day
const resort = raw?.entities?.Resort?.records?.[0];
const parkHours = parseLiveOpeningTimes(resort?.OpeningTimes, this.timezone);
if (parkHours.length > 0) {
liveData.push({ id: this.parkId, status: 'OPERATING', operatingHours: parkHours });
}No comparison against Date.now() here at all — this pushes status: 'OPERATING' any day the Resort record has hours, which per your own verification table is every day for all 16 parks. The two sibling blocks in this same diff (restaurants, shows) both correctly derive status from current time; this is the one that doesn't. Net effect: every park reports "open" all night, every night, after closing and before opening. Worth deriving it the same way the restaurant block does (openNow from hours vs Date.now()).
2. A malformed ShowTimes record can crash live data for the entire park, not just that show
parseShowTimes() guards the top-level JSON parse and checks typeof parsed.type === 'string', but never validates nested fields (start, end, offset_date) before casting to ShowTimeNode. If any show's start/end comes back null, missing, or non-string, parseNaiveMs()'s raw.trim() throws, uncaught — there's no try/catch around the showTimesForDate() call in the poi.Item loop, and buildLiveData() isn't wrapped either. Since attractions/restaurants/park-hours are all pushed onto the same liveData array before the shows loop runs, one bad show record takes down live data for the whole park, not just that show. Given you've already documented multiple ShowTimes shape variants across parks, this feels like a when-not-if. A try/catch around the per-item showTimesForDate() call (skip that show, keep everything else) would contain the blast radius to a single entity.
3. NaN silently passes the period/range duration guard
if (!Number.isFinite(offset) || periodMs <= 0 || rangeMs < 0) return [];Only offset is Number.isFinite-checked. periodMs/rangeMs come from unvalidated external duration fields — if either is NaN, both NaN <= 0 and NaN < 0 evaluate false, so it passes the guard. The loop then runs the full SHOWTIME_MAX_ITERATIONS (100,000) synchronously and does nothing, every poll cycle, for that show. Same fix shape as offset: !Number.isFinite(periodMs) || periodMs <= 0 || !Number.isFinite(rangeMs) || rangeMs < 0.
4. The new test only covers the pure functions, not the rewrite that actually shipped
showtimes.test.ts calls parseShowTimes/evaluateShowTimeNode/showTimesForDate directly with hand-built fixtures — it never instantiates AttractionsIOV1 or calls the real buildLiveData(), which is the method that changed the most here (~30 → ~180 lines: entity classification, the restaurant IsOpen fallback, the Resort park-hours extraction, the duplicate-classification guard). None of that wiring is exercised, so a regression in the classification logic itself — including the "wider category coverage for all 16 parks" change, which has zero test coverage anywhere in this diff — wouldn't be caught. oceanpark.test.ts and seaworld.test.ts already have the pattern for this (Probe extends <RealClass>, override the raw fetch method, call the real public method) and AttractionsIOV1 is exported, so it's a direct drop-in rather than new infra.
Smaller: parseLiveOpeningTimes (backing both the restaurant-hours and park-hours features) isn't exported, so it can't even be unit-tested in isolation the way the ShowTimes functions were. And the efteling.ts hunk in this PR (fixing the operatinghours → operatingHours typo) has no test either — there's no __tests__ for Efteling at all, so nothing pins that fix going forward.
Checked this against #243 (still open, also touches efteling.ts) — no actual overlap, different sections of the file, doesn't interact with the single-rider fix. Not a blocker between the two.
…not a hardcoded OPERATING The Resort record carries the same opening hours every day, so pushing a fixed status: 'OPERATING' whenever hours exist reported the park open all night, after close and before open. Derive it from whether "now" sits inside today's window, the same way the restaurant block already does, via a shared isOpenNow helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parseNaiveMs called .trim() on its argument, so a node whose start/end/offset_date came back null or non-string threw uncaught. As attractions, restaurants and park hours are pushed onto the live-data array before the shows loop runs, one bad record took down live data for the whole park. Guard parseNaiveMs to yield NaN (which callers already drop) for non-string input, and wrap the per-show evaluation in try/catch so a failure skips only that show. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
periodMs/rangeMs come from unvalidated external duration fields. A NaN slipped past the <= 0 / < 0 checks (both false for NaN), so the evaluator then spun the full SHOWTIME_MAX_ITERATIONS doing nothing on every poll cycle. Add explicit Number.isFinite checks alongside the existing offset guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior suite only exercised the pure ShowTimes helpers. Add integration tests that drive the real buildEntityList/buildLiveData on a stubbed subclass: entity classification incl. the wider category coverage, the restaurant IsOpen fallback, the time-derived park status, and the malformed-ShowTimes containment. Export parseLiveOpeningTimes so it can be unit-tested too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Efteling had no test suite at all, so nothing guarded the fix that writes dining hours under the schema key operatingHours (camelCase) rather than the previously-misspelled operatinghours. Add a focused test on buildLiveData with the raw fetch methods stubbed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — all four points made sense, and each is now fixed in its own commit. Summary below. 1. PARK live
|
| Park | Local time | Park status |
|---|---|---|
| Alton Towers | 23:37 (after 18:00 close) | CLOSED |
| LEGOLAND California | 15:37 (10:00–19:00) | OPERATING |
| Gardaland | 00:37 (after 23:00 close) | CLOSED |
The full opening hours are still published in every case; only the status now reflects the time of day.
2. A malformed ShowTimes record could crash live data for the whole park ✅
Fixed in 4f66e533, defence in depth:
parseNaiveMs()now guards its argument withtypeof raw !== 'string'before calling.trim(), so astart/end/offset_datethat comes back null/missing/non-string yieldsNaN(which the callers already treat as "drop") instead of throwing.- The per-item
showTimesForDate()call in the shows loop is wrapped intry/catch— a failure now skips just that one show and keeps every other entity's live data.
3. NaN silently passed the period/range duration guard ✅
Fixed in fa804f75. The guard is now:
if (
!Number.isFinite(offset) ||
!Number.isFinite(periodMs) || periodMs <= 0 ||
!Number.isFinite(rangeMs) || rangeMs < 0
) return [];so a NaN duration returns [] immediately instead of spinning the loop the full SHOWTIME_MAX_ITERATIONS every poll cycle.
4. Tests only covered the pure functions, not the rewrite ✅
- Integration tests (
0311a188) — a newattractionsiov1.test.tsfollows theoceanpark/seaworldpattern: aProbe extends AttractionsIOV1stubsgetPOIData/fetchLiveDataand drives the realbuildEntityList()/buildLiveData(). It covers entity classification including the wider category coverage (4D Movies→ SHOW,Food & Drinks→ RESTAURANT,Shoppingexcluded, child-category walking), the restaurantIsOpenfallback, the time-derived park status (open window →OPERATING, night →CLOSED), and the malformed-ShowTimescontainment. parseLiveOpeningTimesis now exported and unit-tested in isolation (backs both the restaurant- and park-hours features).- Efteling now has a test suite (
b82d110c) — a focusedbuildLiveDatatest that pins theoperatinghours→operatingHourskey fix so it can't regress.
New boundary tests were also added to showtimes.test.ts for the NaN-guard and non-string-input branches.
All suites are green (npx vitest run src/parks/attractionsio src/parks/efteling → 82 passing) and npm run dev -- <park> passes 4/4 for the spot-checked parks. Attraction wait-time behaviour is unchanged.
Summary
The Attractions.io v1 adapter previously emitted only attraction wait times as live data. Restaurants, the park itself and shows existed as entities but carried no live data at all, and one park (Heide Park) had a bespoke implementation for venue hours / show times that the other 15 parks did not share.
This branch generalises venue opening hours, park opening hours and show times from Heide Park into the shared
AttractionsIOV1base class, fixes the show-time evaluator so instantaneous ("point") show starts are no longer dropped, and widens category coverage so shows and dining venues surface for parks that label those categories differently.The result: across all 16 Merlin / Legoland parks, live data now includes restaurant status + hours, today's park opening window, and per-show performance times — with attraction wait-time behaviour completely unchanged.
What's new
For every inheriting park,
buildLiveData()now produces, in addition to attraction status + standby wait time:IsOpensignal, with a fallback to the opening-hours window when the feed omits it) plus today's opening hours (from the liveOpeningTimesblob).ShowTimesschedule in the records data (there is no live show feed), with a schedule-derived status (OPERATING while a performance is still upcoming, otherwise CLOSED; the full day's schedule always stays published).The Heide Park–specific opening-hours parser and
ShowTimesevaluator moved ahead of the base class;HeideParkBasenow keeps only its custom v2 schedule endpoint.The
ShowTimespoint-start-time fixShowTimesencodes a show's recurring schedule as a temporal set-algebra tree. Aperiodnode normally defines a repeating window (offset_date+ n×period_length, eachrange_lengthlong). Aperiodwithout arange_lengthencodes an instantaneous show start time (e.g. "13:30 daily"), not a window.The evaluator's guard
rangeMs <= 0 → []silently dropped these, so every park except Heide Park (whose shows all carry arange_length) produced zero show times. The fix treats a range-less period as a zero-length interval[s, s], carries points through the interval algebra with half-open[start, end)semantics, and emits them as start-onlyLiveTimeSlots ({type, startTime}, noendTime— the schema only requirestype).Wider category coverage
Some parks label their shows and food venues with category names the base lists did not recognise, so those items were dropped entirely. This branch adds the category labels observed across parks:
Entertainment,Shows & 4D Movies,4D Movies— surfacing shows for Chessington, Legoland Windsor, Legoland California, Legoland Deutschland and Knoebels. (Entertainmentis now part of the base list, so Heide Park's override was removed.)Food & Drinks,Fast food,Café & Snacks,Beverages,Buffet,Ice cream, coffee & treats,Snacks & Sweets) — surfacing restaurants for Legoland Billund, Legoland Deutschland, Knoebels and Djurs Sommerland.Ambiguous mixed labels (e.g. Legoland Korea's
Dining/Shopping, Knoebels'Pavilions) and non-dining/non-show categories carrying schedule fields (e.g.SEA LIFE,Meet & Greet,Season Content,Hotel,Rides) are deliberately left out to avoid misclassification.Verification (live feeds, 2026-07-08)
Generalisation — before/after across all 16 parks:
getEntitiesΔ=0 andgetSchedulesΔ=0 (no regression), and Heide Park output byte-identical (72 live / 20 opening-hour / 7 show-time entries unchanged). Restaurant/park hours and show times appeared for the other parks.Category expansion — before/after across all 16 parks: attraction counts unchanged for every park (Δ=0) and zero malformed times (no
start > endin any restaurant / park / show slot). Only SHOW and RESTAURANT counts grew, and only where expected.Final per-park entity counts:
Sample show times (plausibility): Gardaland Gardaland Night Beats 22:45 (park open until 23:00), Aloha 18:15; Legoland New York Daily Park Opening Ceremony! 09:55 (CLOSED — already passed); Heide Park emits real windows (11:15–11:35) while point-based parks emit start-only times.
npm run dev -- <park>passes 4/4 for the checked parks; theshowtimesunit suite is green (23 tests, incl. the new boundary cases).Adversarial review
The branch was put through a multi-lens review (conventions, ShowTimes algebra, live-data integration, regression risk, test coverage), each finding then adversarially verified. Of 14 raw findings, 7 were confirmed and 7 refuted; all 7 confirmed were addressed in the
hardencommit:IsOpen.•JSDoc bullets vs the house-.Known limitations / future work
Dining/Shoppingcategory, excluded to avoid emitting shops as restaurants.ATTRACTIONScategory that the (case-sensitive) attraction list does not match — an attraction-side coverage gap out of scope here.Meet & Greet/SEA LIFEstyle categories carry schedule fields but are not treated as shows.Commits
operatingHourskey — restaurant hours were written under the non-schema keyoperatinghours.