Skip to content

feat(attractionsio): venue & park opening hours, show times, and wider category coverage for all 16 parks#244

Merged
cubehouse merged 11 commits into
ThemeParks:mainfrom
BeyondVertical:feat/attractionsio-venue-hours-and-showtimes
Jul 9, 2026
Merged

feat(attractionsio): venue & park opening hours, show times, and wider category coverage for all 16 parks#244
cubehouse merged 11 commits into
ThemeParks:mainfrom
BeyondVertical:feat/attractionsio-venue-hours-and-showtimes

Conversation

@BeyondVertical

Copy link
Copy Markdown
Contributor

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 AttractionsIOV1 base 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:

  • Restaurants — open/closed status (from the live IsOpen signal, with a fallback to the opening-hours window when the feed omits it) plus today's opening hours (from the live OpeningTimes blob).
  • The park — today's actual opening window (from the live Resort record).
  • Shows — today's performance times, evaluated from each show's recurring ShowTimes schedule 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 ShowTimes evaluator moved ahead of the base class; HeideParkBase now keeps only its custom v2 schedule endpoint.

The ShowTimes point-start-time fix

ShowTimes encodes a show's recurring schedule as a temporal set-algebra tree. A period node normally defines a repeating window (offset_date + n×period_length, each range_length long). A period without a range_length encodes 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 a range_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-only LiveTimeSlots ({type, startTime}, no endTime — the schema only requires type).

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:

  • Shows: Entertainment, Shows & 4D Movies, 4D Movies — surfacing shows for Chessington, Legoland Windsor, Legoland California, Legoland Deutschland and Knoebels. (Entertainment is now part of the base list, so Heide Park's override was removed.)
  • Restaurants: a curated set of clearly-dining labels (e.g. 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 and getSchedules Δ=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 > end in any restaurant / park / show slot). Only SHOW and RESTAURANT counts grew, and only where expected.

Final per-park entity counts:

Park Attractions Shows Restaurants Park hours today
Alton Towers 47 4 35 10:00–18:00
Thorpe Park 25 0 36 10:00–18:00
Chessington 31 2 19 10:00–17:00
Legoland Windsor 44 8 19 10:00–17:00
Legoland Orlando 48 3 32 10:00–18:00
Legoland California 26 9 4 10:00–19:00
Legoland Billund 49 9 46 10:00–19:00
Legoland Deutschland 65 4 3 10:00–18:00
Gardaland 36 6 25 10:00–23:00
Heide Park 43 8 20 10:00–18:00
Knoebels 69 6 19 12:00–21:00
Djurs Sommerland 68 0 31 10:00–20:00
Legoland Japan 45 5 22 10:00–17:00
Legoland New York 51 2 24 10:00–17:00
Legoland Korea 36 0 0 10:00–17:00
Peppa Pig Florida 16 2 2 10:00–17:00

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; the showtimes unit 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 harden commit:

  • Half-open point containment in the interval algebra (a point on a span's exclusive end / a subtracted span's inclusive start was mishandled).
  • Restaurant status falling back to CLOSED when the live feed omits IsOpen.
  • Duplicate live-data entries for an item classified into more than one type.
  • Base-class JSDoc naming a specific park; JSDoc bullets vs the house -.
  • Test coverage for the point/boundary algebra branches.

Known limitations / future work

  • Legoland Korea surfaces no restaurants: its only dining label is the mixed Dining/Shopping category, excluded to avoid emitting shops as restaurants.
  • Legoland California has ~7 rides under an uppercase ATTRACTIONS category that the (case-sensitive) attraction list does not match — an attraction-side coverage gap out of scope here.
  • Non-dining venues with opening hours (shops, facilities) are not surfaced; only restaurants among venues are emitted.
  • Meet & Greet / SEA LIFE style categories carry schedule fields but are not treated as shows.

Commits

  1. fix(efteling): emit restaurant hours under schema operatingHours key — restaurant hours were written under the non-schema key operatinghours.
  2. feat(heidepark): live venue hours, park hours & show times — the original Heide Park implementation.
  3. feat(attractionsio): surface venue & park hours and show times for all parks — generalise (2) into the shared base class.
  4. fix(attractionsio): evaluate range_length-less ShowTimes as point start times — the point-start-time fix.
  5. feat(attractionsio): recognise more show & dining category labels — wider category coverage.
  6. fix(attractionsio): harden against a multi-lens review of the venue/show rewrite — the confirmed review findings above.

BeyondVertical and others added 6 commits July 8, 2026 15:35
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 cubehouse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 operatinghoursoperatingHours 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.

BeyondVertical and others added 5 commits July 9, 2026 00:44
…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>
@BeyondVertical

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all four points made sense, and each is now fixed in its own commit. Summary below.

1. PARK live status hardcoded to OPERATING

Fixed in a776d6f6. The park status is now derived from whether "now" sits inside today's live opening window, exactly the way the restaurant block does. Both paths share a single isOpenNow(hours, nowMs) helper (half-open [start, end) containment) so the two can't drift apart again.

Verified live across three timezones at the same instant:

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 with typeof raw !== 'string' before calling .trim(), so a start/end/offset_date that comes back null/missing/non-string yields NaN (which the callers already treat as "drop") instead of throwing.
  • The per-item showTimesForDate() call in the shows loop is wrapped in try/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 new attractionsiov1.test.ts follows the oceanpark/seaworld pattern: a Probe extends AttractionsIOV1 stubs getPOIData/fetchLiveData and drives the real buildEntityList()/buildLiveData(). It covers entity classification including the wider category coverage (4D Movies → SHOW, Food & Drinks → RESTAURANT, Shopping excluded, child-category walking), the restaurant IsOpen fallback, the time-derived park status (open window → OPERATING, night → CLOSED), and the malformed-ShowTimes containment.
  • parseLiveOpeningTimes is now exported and unit-tested in isolation (backs both the restaurant- and park-hours features).
  • Efteling now has a test suite (b82d110c) — a focused buildLiveData test that pins the operatinghoursoperatingHours key 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.

@cubehouse cubehouse merged commit bfdab7f into ThemeParks:main Jul 9, 2026
1 check passed
@BeyondVertical BeyondVertical deleted the feat/attractionsio-venue-hours-and-showtimes branch July 9, 2026 22:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants