From b7f0a243358523086bea741aaef1c375f6752f57 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Fri, 24 Apr 2026 15:51:50 +0000 Subject: [PATCH 1/3] feat(flamingoland): add Flamingo Land Resort destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads rides and ride categories from the park's Firebase Firestore project via authenticated REST calls. Emits status (OPERATING / CLOSED / REFURBISHMENT) for all 32 rides plus STANDBY wait times for categories flagged as queue-tracked in the source. Schedules left empty for now — no public feed located; can be revisited if the destination proves worth keeping. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parks/flamingoland/flamingoland.ts | 301 +++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/parks/flamingoland/flamingoland.ts diff --git a/src/parks/flamingoland/flamingoland.ts b/src/parks/flamingoland/flamingoland.ts new file mode 100644 index 00000000..38041a30 --- /dev/null +++ b/src/parks/flamingoland/flamingoland.ts @@ -0,0 +1,301 @@ +import {Destination, DestinationConstructor} from '../../destination.js'; +import {cache, CacheLib} from '../../cache.js'; +import {http, HTTPObj} from '../../http.js'; +import {inject} from '../../injector.js'; +import config from '../../config.js'; +import {destinationController} from '../../destinationRegistry.js'; +import {Entity, LiveData, EntitySchedule} from '@themeparks/typelib'; +import {decodeHtmlEntities} from '../../htmlUtils.js'; + +const TOKEN_CACHE_KEY = 'flamingoland:idToken'; +const DESTINATION_ID = 'flamingoland'; +const PARK_ID = 'flamingoland-park'; + +type FsValue = { + stringValue?: string; + integerValue?: string; + doubleValue?: number; + booleanValue?: boolean; + nullValue?: null; + arrayValue?: {values?: FsValue[]}; + mapValue?: {fields?: Record}; + referenceValue?: string; + timestampValue?: string; +}; + +type FsDoc = { + name: string; + fields?: Record; +}; + +function fsString(v?: FsValue): string | undefined { + return v?.stringValue; +} +function fsBool(v?: FsValue): boolean | undefined { + return v?.booleanValue; +} +function fsInt(v?: FsValue): number | undefined { + if (v?.integerValue !== undefined) return Number(v.integerValue); + if (v?.doubleValue !== undefined) return v.doubleValue; + return undefined; +} + +@destinationController({category: 'Flamingo Land'}) +export class FlamingoLand extends Destination { + @config apiKey: string = ''; + @config projectId: string = ''; + @config androidPackage: string = ''; + @config androidCert: string = ''; + @config email: string = ''; + @config password: string = ''; + @config timezone: string = 'Europe/London'; + + constructor(options?: DestinationConstructor) { + super(options); + this.addConfigPrefix('FLAMINGOLAND'); + } + + private get firestoreBase(): string { + return `https://firestore.googleapis.com/v1/projects/${this.projectId}/databases/(default)/documents`; + } + + private get identityBase(): string { + return 'https://identitytoolkit.googleapis.com/v1/accounts'; + } + + // ===== Authentication ===== + + @cache({ttlSeconds: 60 * 50, key: TOKEN_CACHE_KEY}) + async getIdToken(): Promise { + if (!this.email || !this.password) { + throw new Error('Flamingo Land requires FLAMINGOLAND_EMAIL and FLAMINGOLAND_PASSWORD to be set'); + } + + // Try sign-in first, fall back to sign-up if the account does not exist yet. + let resp = await this.fetchSignIn(this.email, this.password); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + const code = err?.error?.message; + if (code === 'EMAIL_NOT_FOUND') { + resp = await this.fetchSignUp(this.email, this.password); + } + } + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.idToken) { + throw new Error(`Flamingo Land auth failed: ${resp.status} ${JSON.stringify(data)}`); + } + return String(data.idToken); + } + + @http({retries: 1}) + async fetchSignIn(email: string, password: string): Promise { + return { + method: 'POST', + url: `${this.identityBase}:signInWithPassword?key=${this.apiKey}`, + headers: {'content-type': 'application/json'}, + body: JSON.stringify({email, password, returnSecureToken: true}), + options: {json: false}, + tags: ['auth'], + } as any as HTTPObj; + } + + @http({retries: 1}) + async fetchSignUp(email: string, password: string): Promise { + return { + method: 'POST', + url: `${this.identityBase}:signUp?key=${this.apiKey}`, + headers: {'content-type': 'application/json'}, + body: JSON.stringify({email, password, returnSecureToken: true}), + options: {json: false}, + tags: ['auth'], + } as any as HTTPObj; + } + + // ===== Injectors ===== + + @inject({ + eventName: 'httpRequest', + hostname: {$in: ['firestore.googleapis.com', 'identitytoolkit.googleapis.com']}, + } as any) + async injectAndroidClientHeaders(req: HTTPObj): Promise { + if (!this.androidPackage || !this.androidCert) return; + req.headers = { + ...req.headers, + 'X-Android-Package': this.androidPackage, + 'X-Android-Cert': this.androidCert, + }; + } + + @inject({ + eventName: 'httpRequest', + hostname: 'firestore.googleapis.com', + tags: {$nin: ['auth']}, + priority: 1, + } as any) + async injectAuthToken(req: HTTPObj): Promise { + const token = await this.getIdToken(); + req.headers = { + ...req.headers, + 'authorization': `Bearer ${token}`, + }; + } + + @inject({ + eventName: 'httpError', + hostname: 'firestore.googleapis.com', + tags: {$nin: ['auth']}, + } as any) + async handleUnauthorized(req: HTTPObj): Promise { + const status = req.response?.status; + if (status !== 401 && status !== 403) return; + CacheLib.delete(TOKEN_CACHE_KEY); + req.response = undefined as any; + } + + // ===== HTTP Fetches ===== + + @http({cacheSeconds: 60, retries: 2}) + async fetchCollectionPage(collection: string, pageToken: string | null): Promise { + const params = new URLSearchParams({key: this.apiKey, pageSize: '300'}); + if (pageToken) params.set('pageToken', pageToken); + return { + method: 'GET', + url: `${this.firestoreBase}/${collection}?${params.toString()}`, + options: {json: true}, + } as any as HTTPObj; + } + + private async readCollection(collection: string): Promise { + const out: FsDoc[] = []; + let pageToken: string | null = null; + // Firestore paginates — loop until exhausted. 300 per page matches app behaviour. + // Small collections (all of Flamingo Land's data is <100 docs) mean usually a single page. + for (let i = 0; i < 20; i++) { + const resp = await this.fetchCollectionPage(collection, pageToken); + const data = await resp.json(); + if (data?.documents) out.push(...(data.documents as FsDoc[])); + pageToken = data?.nextPageToken || null; + if (!pageToken) break; + } + return out; + } + + @cache({ttlSeconds: 60}) + async getRides(): Promise { + return this.readCollection('rides_data'); + } + + @cache({ttlSeconds: 3600}) + async getRideCategories(): Promise { + return this.readCollection('ride_categories'); + } + + // ===== Entities ===== + + async getDestinations(): Promise { + return [{ + id: DESTINATION_ID, + name: 'Flamingo Land Resort', + entityType: 'DESTINATION', + timezone: this.timezone, + location: {latitude: 54.21112, longitude: -0.80845}, + } as Entity]; + } + + protected async buildEntityList(): Promise { + const [rides, categories] = await Promise.all([this.getRides(), this.getRideCategories()]); + + const parkEntity: Entity = { + id: PARK_ID, + name: 'Flamingo Land', + entityType: 'PARK', + parentId: DESTINATION_ID, + destinationId: DESTINATION_ID, + timezone: this.timezone, + location: {latitude: 54.21112, longitude: -0.80845}, + } as Entity; + + // Category lookup to skip non-theme-park entries (e.g. zoo animals) in future. + // Current ride_categories: 16 Thrill, 17 Family, 19 Kids (all showQueueTime=true), + // 20 Getting Around, 135 Other Attractions (showQueueTime=false). + // We keep rows from all ride categories — non-tracked ones still emit status. + const validCategoryIds = new Set(); + for (const cat of categories) { + const id = fsInt(cat.fields?.id); + if (id !== undefined) validCategoryIds.add(id); + } + + const attractions: Entity[] = []; + for (const doc of rides) { + const id = doc.name.split('/').pop() || ''; + const title = fsString(doc.fields?.title); + const catId = fsInt(doc.fields?.categoriesId); + if (!id || !title) continue; + if (catId !== undefined && validCategoryIds.size > 0 && !validCategoryIds.has(catId)) continue; + + attractions.push({ + id, + name: decodeHtmlEntities(title), + entityType: 'ATTRACTION', + parentId: PARK_ID, + parkId: PARK_ID, + destinationId: DESTINATION_ID, + timezone: this.timezone, + } as Entity); + } + + return [parkEntity, ...attractions]; + } + + // ===== Live Data ===== + + protected async buildLiveData(): Promise { + const [rides, categories] = await Promise.all([this.getRides(), this.getRideCategories()]); + + // Only rides whose category has showQueueTime=true emit waitTime; others are status-only. + const queueCategories = new Set(); + for (const cat of categories) { + if (fsBool(cat.fields?.showQueueTime)) { + const id = fsInt(cat.fields?.id); + if (id !== undefined) queueCategories.add(id); + } + } + + const out: LiveData[] = []; + for (const doc of rides) { + const id = doc.name.split('/').pop() || ''; + const title = fsString(doc.fields?.title); + if (!id || !title) continue; + + const statusOpen = fsBool(doc.fields?.statusOpen) ?? false; + const underMaintenance = fsBool(doc.fields?.underMaintenance) ?? false; + const downAllDay = fsBool(doc.fields?.downAllDay) ?? false; + const catId = fsInt(doc.fields?.categoriesId); + + let status: LiveData['status']; + if (underMaintenance) status = 'REFURBISHMENT'; + else if (downAllDay || !statusOpen) status = 'CLOSED'; + else status = 'OPERATING'; + + const ld: LiveData = {id, status} as LiveData; + + if (status === 'OPERATING' && catId !== undefined && queueCategories.has(catId)) { + const wt = fsInt(doc.fields?.queue_time); + if (wt !== undefined && Number.isFinite(wt)) { + ld.queue = {STANDBY: {waitTime: wt}}; + } + } + + out.push(ld); + } + return out; + } + + // ===== Schedules ===== + + protected async buildSchedules(): Promise { + // No schedule feed found in-app. The website has a calendar but no public JSON. + // Leaving empty for now — rides.latest_ride_time analogue does not exist here. + return [{id: PARK_ID, schedule: []} as EntitySchedule]; + } +} From ff7767d1174ae71566e691af6c3916833425bb4c Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sat, 9 May 2026 13:28:27 +0000 Subject: [PATCH 2/3] feat(flamingoland): scrape schedules, coords + emit minimum-height tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedules ========= The Flamingo Land mobile app's Firestore source has no schedule data (verified — only rides_data and ride_categories collections exist; no Remote Config template, no RTDB instance, no public Cloud Function). Two website signals fill the gap: - The homepage banner ("Today the Theme Park will close at 5PM") gives today's actual closing time. - The webshop overview ("Open daily from 10am between 21st March and 1st November 2026") gives the season window. We emit OPERATING entries from today through season end (inclusive of 1st November). Today uses the scraped close; future days fall back to a 10am-5pm default since closing times for future dates aren't published. Coordinates + zone filter ========================= The /map/ page embeds a markersData JS array. Marker `id` matches each Firestore ride doc's `parkMapMarkerId`. Where that field is empty in Firestore, fall back to exact-title and then title-prefix match (with apostrophe/whitespace normalisation). The same matching is reused as a structural filter: rides_data also contains zone documents (e.g. Dino-Stone Park, Children's Planet) which have no map presence. A shared `getAttractionRideIds()` helper drops them so the zone never appears as an entity, and live data stays in sync with the entity list. Tags ==== Ride docs carry a `restrictions` field (cm) for minimum height. Wired through TagBuilder.minimumHeight where the value is non-zero. Polish ====== - Clarified the EMAIL_NOT_FOUND auto-sign-up rationale. - Trimmed the verbose category-filter comment. - Added an isoDateInTimezone helper to avoid the formatInTimezone(... 'date') return-format gotcha (US-locale MM/DD/YYYY vs. ISO YYYY-MM-DD). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parks/flamingoland/flamingoland.ts | 306 +++++++++++++++++++++++-- 1 file changed, 284 insertions(+), 22 deletions(-) diff --git a/src/parks/flamingoland/flamingoland.ts b/src/parks/flamingoland/flamingoland.ts index 38041a30..0332cbc0 100644 --- a/src/parks/flamingoland/flamingoland.ts +++ b/src/parks/flamingoland/flamingoland.ts @@ -6,6 +6,8 @@ import config from '../../config.js'; import {destinationController} from '../../destinationRegistry.js'; import {Entity, LiveData, EntitySchedule} from '@themeparks/typelib'; import {decodeHtmlEntities} from '../../htmlUtils.js'; +import {TagBuilder} from '../../tags/index.js'; +import {constructDateTime} from '../../datetime.js'; const TOKEN_CACHE_KEY = 'flamingoland:idToken'; const DESTINATION_ID = 'flamingoland'; @@ -28,6 +30,19 @@ type FsDoc = { fields?: Record; }; +function isoDateInTimezone(date: Date, tz: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const y = parts.find(p => p.type === 'year')?.value; + const m = parts.find(p => p.type === 'month')?.value; + const d = parts.find(p => p.type === 'day')?.value; + return `${y}-${m}-${d}`; +} + function fsString(v?: FsValue): string | undefined { return v?.stringValue; } @@ -71,7 +86,9 @@ export class FlamingoLand extends Destination { throw new Error('Flamingo Land requires FLAMINGOLAND_EMAIL and FLAMINGOLAND_PASSWORD to be set'); } - // Try sign-in first, fall back to sign-up if the account does not exist yet. + // The mobile app provisions an anonymous-style account on first launch. We + // mirror that: sign-in first, sign-up only if EMAIL_NOT_FOUND. After the + // first run the account exists and sign-up is never hit again. let resp = await this.fetchSignIn(this.email, this.password); if (!resp.ok) { const err = await resp.json().catch(() => ({})); @@ -190,6 +207,165 @@ export class FlamingoLand extends Destination { return this.readCollection('ride_categories'); } + @http({cacheSeconds: 60 * 60, retries: 2}) + async fetchHomepage(): Promise { + return { + method: 'GET', + url: 'https://www.flamingoland.co.uk/', + options: {json: false}, + } as any as HTTPObj; + } + + @http({cacheSeconds: 60 * 60 * 12, retries: 2}) + async fetchWebshopOverview(): Promise { + return { + method: 'GET', + url: 'https://webshop.flamingoland.co.uk/Exhibitions/Overview/', + options: {json: false}, + } as any as HTTPObj; + } + + @http({cacheSeconds: 60 * 60 * 24, retries: 2}) + async fetchMapPage(): Promise { + return { + method: 'GET', + url: 'https://www.flamingoland.co.uk/map/', + options: {json: false}, + } as any as HTTPObj; + } + + // The /map/ page embeds a JS array + // markersData = [{id: '216', title: 'Splash Battle', lat:.., lng:.., type:'ride'}, …] + // Marker `id` matches each Firestore ride doc's `parkMapMarkerId`. Some ride + // docs have an empty parkMapMarkerId, so buildEntityList also falls back to + // exact / prefix title match. + @cache({ttlSeconds: 60 * 60 * 24}) + async scrapeMarkers(): Promise> { + const resp = await this.fetchMapPage(); + const html = await resp.text(); + const out: Array<{id: string; title: string; lat: number; lng: number; type: string}> = []; + // Each marker is a small object literal; capture id, title, lat, lng, type within + // a single block. The 600-char window comfortably contains one entry. + const re = /id:\s*'(\d+)',[\s\S]{0,600}?title:\s*'((?:[^'\\]|\\.)*)',[\s\S]{0,400}?lat:\s*(-?\d+(?:\.\d+)?),\s*lng:\s*(-?\d+(?:\.\d+)?)[\s\S]{0,400}?type:\s*'([^']*)'/g; + for (const m of html.matchAll(re)) { + const lat = parseFloat(m[3]); + const lng = parseFloat(m[4]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + out.push({ + id: m[1], + title: m[2].replace(/\\'/g, "'"), + lat, + lng, + type: m[5], + }); + } + return out; + } + + // ===== Schedule scraping ===== + + // Homepage banner:
Today the Theme Park will close at 5PM
+ // We accept "open at" too — the morning banner may show that instead. + // Returns "HH:mm" 24-hour, or null if no banner is present (off-season / closed). + @cache({ttlSeconds: 60 * 30}) + async scrapeTodayCloseTime(): Promise { + const resp = await this.fetchHomepage(); + const html = await resp.text(); + const m = html.match(/Today the Theme Park will (close|open) at\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)/i); + if (!m) return null; + const hour12 = parseInt(m[2], 10); + const minute = m[3] ? parseInt(m[3], 10) : 0; + const isPM = m[4].toUpperCase() === 'PM'; + let hour24 = hour12 % 12; + if (isPM) hour24 += 12; + return `${String(hour24).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + } + + // Webshop blurb: "Open daily from 10am between 21st March and 1st November 2026." + // Updated yearly on the source page; cached for 12h. Returns ISO date strings + // (not Date objects) so the cache layer can JSON-roundtrip safely. + @cache({ttlSeconds: 60 * 60 * 12}) + async scrapeSeasonWindow(): Promise<{start: string; end: string; openHour: number} | null> { + const resp = await this.fetchWebshopOverview(); + const html = await resp.text(); + const m = html.match( + /Open daily from\s+(\d{1,2})(am|pm)\s+between\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+and\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+(\d{4})/i, + ); + if (!m) return null; + const monthIndex = (name: string): number => { + return ['january','february','march','april','may','june','july','august','september','october','november','december'].indexOf(name.toLowerCase()); + }; + const openHour12 = parseInt(m[1], 10); + const openIsPM = m[2].toLowerCase() === 'pm'; + const openHour = (openHour12 % 12) + (openIsPM ? 12 : 0); + const startMonth = monthIndex(m[4]); + const endMonth = monthIndex(m[6]); + if (startMonth < 0 || endMonth < 0) return null; + const year = parseInt(m[7], 10); + const fmt = (mo: number, day: number) => `${year}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return { + start: fmt(startMonth, parseInt(m[3], 10)), + end: fmt(endMonth, parseInt(m[5], 10)), + openHour, + }; + } + + // ===== Shared filter ===== + + // Filters rides_data to entries that represent real attractions (excludes + // zone documents like "Dino-Stone Park" / "Children's Planet" which appear + // in rides_data but have no map presence). Used by both buildEntityList + // and buildLiveData so the two stay in sync. + @cache({ttlSeconds: 60}) + async getAttractionRideIds(): Promise { + const [rides, categories, markers] = await Promise.all([ + this.getRides(), + this.getRideCategories(), + this.scrapeMarkers().catch(() => [] as Array<{id: string; title: string; lat: number; lng: number; type: string}>), + ]); + + const validCategoryIds = new Set(); + for (const cat of categories) { + const id = fsInt(cat.fields?.id); + if (id !== undefined) validCategoryIds.add(id); + } + + const norm = (s: string) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim(); + const markersById: Record = {}; + const markersByExactTitle: Record = {}; + for (const m of markers) { + markersById[m.id] = m; + const key = norm(m.title); + const existing = markersByExactTitle[key]; + if (!existing || (existing.type !== 'ride' && m.type === 'ride')) { + markersByExactTitle[key] = m; + } + } + const hasMarker = (rideTitle: string, markerId: string | undefined): boolean => { + if (markerId && markersById[markerId]) return true; + const key = norm(rideTitle); + if (markersByExactTitle[key]) return true; + for (const m of markers) { + if (norm(m.title).startsWith(key)) return true; + } + return false; + }; + + const out: string[] = []; + for (const doc of rides) { + const id = doc.name.split('/').pop() || ''; + const title = fsString(doc.fields?.title); + const catId = fsInt(doc.fields?.categoriesId); + if (!id || !title) continue; + if (catId !== undefined && validCategoryIds.size > 0 && !validCategoryIds.has(catId)) continue; + // Defensive: if the marker scrape failed entirely, fall back to keeping + // every ride doc rather than dropping the lot. + if (markers.length > 0 && !hasMarker(decodeHtmlEntities(title), fsString(doc.fields?.parkMapMarkerId) || undefined)) continue; + out.push(id); + } + return out; + } + // ===== Entities ===== async getDestinations(): Promise { @@ -203,7 +379,45 @@ export class FlamingoLand extends Destination { } protected async buildEntityList(): Promise { - const [rides, categories] = await Promise.all([this.getRides(), this.getRideCategories()]); + const [rides, validIdList, markers] = await Promise.all([ + this.getRides(), + this.getAttractionRideIds(), + this.scrapeMarkers().catch(() => [] as Array<{id: string; title: string; lat: number; lng: number; type: string}>), + ]); + const validIds = new Set(validIdList); + + // Index markers for tiered ride→coords lookup: + // 1) by Firestore parkMapMarkerId 2) by exact title 3) by title-prefix. + // Where a title is shared (e.g. a ride and its associated shop), prefer the + // marker tagged type=ride so a fuzzy lookup still picks the attraction. + // Normalise titles for comparison — Firestore uses curly apostrophes + // ("Children's") while marker titles use straight ones. + const norm = (s: string) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim(); + + const markersById: Record = {}; + const markersByExactTitle: Record = {}; + for (const m of markers) { + markersById[m.id] = m; + const key = norm(m.title); + const existing = markersByExactTitle[key]; + if (!existing || (existing.type !== 'ride' && m.type === 'ride')) { + markersByExactTitle[key] = m; + } + } + const findCoords = (rideTitle: string, markerId: string | undefined) => { + if (markerId && markersById[markerId]) return markersById[markerId]; + const key = norm(rideTitle); + if (markersByExactTitle[key]) return markersByExactTitle[key]; + // Prefix fallback: marker title starts with the ride title (e.g. ride + // "Pirates of Zanzibar" → marker "Pirates of Zanzibar Show…"). Prefer + // ride-typed markers when several candidates match. + let best: typeof markers[number] | undefined; + for (const m of markers) { + if (!norm(m.title).startsWith(key)) continue; + if (!best || (best.type !== 'ride' && m.type === 'ride')) best = m; + } + return best; + }; const parkEntity: Entity = { id: PARK_ID, @@ -215,25 +429,14 @@ export class FlamingoLand extends Destination { location: {latitude: 54.21112, longitude: -0.80845}, } as Entity; - // Category lookup to skip non-theme-park entries (e.g. zoo animals) in future. - // Current ride_categories: 16 Thrill, 17 Family, 19 Kids (all showQueueTime=true), - // 20 Getting Around, 135 Other Attractions (showQueueTime=false). - // We keep rows from all ride categories — non-tracked ones still emit status. - const validCategoryIds = new Set(); - for (const cat of categories) { - const id = fsInt(cat.fields?.id); - if (id !== undefined) validCategoryIds.add(id); - } - const attractions: Entity[] = []; for (const doc of rides) { const id = doc.name.split('/').pop() || ''; + if (!validIds.has(id)) continue; const title = fsString(doc.fields?.title); - const catId = fsInt(doc.fields?.categoriesId); - if (!id || !title) continue; - if (catId !== undefined && validCategoryIds.size > 0 && !validCategoryIds.has(catId)) continue; + if (!title) continue; - attractions.push({ + const entity: Entity = { id, name: decodeHtmlEntities(title), entityType: 'ATTRACTION', @@ -241,7 +444,22 @@ export class FlamingoLand extends Destination { parkId: PARK_ID, destinationId: DESTINATION_ID, timezone: this.timezone, - } as Entity); + } as Entity; + + const markerId = fsString(doc.fields?.parkMapMarkerId); + const marker = findCoords(decodeHtmlEntities(title), markerId || undefined); + if (marker) { + (entity as any).location = {latitude: marker.lat, longitude: marker.lng}; + } + + // The `restrictions` field on each ride doc carries the minimum height in cm + // (e.g. 91.44 = 36"). Zero / missing means no restriction. + const minHeightCm = fsInt(doc.fields?.restrictions); + if (minHeightCm !== undefined && minHeightCm > 0) { + (entity as any).tags = [TagBuilder.minimumHeight(Math.round(minHeightCm), 'cm')]; + } + + attractions.push(entity); } return [parkEntity, ...attractions]; @@ -250,7 +468,12 @@ export class FlamingoLand extends Destination { // ===== Live Data ===== protected async buildLiveData(): Promise { - const [rides, categories] = await Promise.all([this.getRides(), this.getRideCategories()]); + const [rides, categories, validIdList] = await Promise.all([ + this.getRides(), + this.getRideCategories(), + this.getAttractionRideIds(), + ]); + const validIds = new Set(validIdList); // Only rides whose category has showQueueTime=true emit waitTime; others are status-only. const queueCategories = new Set(); @@ -264,8 +487,9 @@ export class FlamingoLand extends Destination { const out: LiveData[] = []; for (const doc of rides) { const id = doc.name.split('/').pop() || ''; + if (!validIds.has(id)) continue; const title = fsString(doc.fields?.title); - if (!id || !title) continue; + if (!title) continue; const statusOpen = fsBool(doc.fields?.statusOpen) ?? false; const underMaintenance = fsBool(doc.fields?.underMaintenance) ?? false; @@ -293,9 +517,47 @@ export class FlamingoLand extends Destination { // ===== Schedules ===== + // No schedule API exists. The mobile app's Firestore source carries rides only, + // and Remote Config has no published template. Two scrape sources fill the gap: + // - homepage banner gives today's actual closing time + // - webshop overview gives the season window ("Open daily from 10am between …") + // Future in-season days fall back to a 10am–5pm default; we don't predict early + // closures past today. protected async buildSchedules(): Promise { - // No schedule feed found in-app. The website has a calendar but no public JSON. - // Leaving empty for now — rides.latest_ride_time analogue does not exist here. - return [{id: PARK_ID, schedule: []} as EntitySchedule]; + const [season, todayClose] = await Promise.all([ + this.scrapeSeasonWindow().catch(() => null), + this.scrapeTodayCloseTime().catch(() => null), + ]); + if (!season) return [{id: PARK_ID, schedule: []} as EntitySchedule]; + + // YYYY-MM-DD in the park's timezone — formatInTimezone(... 'date') returns + // US-locale MM/DD/YYYY, which doesn't sort lexicographically against the + // ISO strings we use elsewhere, so build the ISO form directly. + const todayStr = isoDateInTimezone(new Date(), this.timezone); + const openTime = `${String(season.openHour).padStart(2, '0')}:00`; + const defaultClose = '17:00'; + + const schedule: Array<{date: string; type: 'OPERATING'; openingTime: string; closingTime: string}> = []; + + // Iterate from today (or season start, whichever is later) through season + // end inclusive. The `<=` keeps Nov 1 in the list when we reach it. The + // MAX_DAYS cap is a safety net; with a real season it just stops at end. + const startStr = season.start > todayStr ? season.start : todayStr; + const cursor = new Date(`${startStr}T00:00:00Z`); + const endMs = Date.parse(`${season.end}T00:00:00Z`); + const MAX_DAYS = 365; + for (let i = 0; i < MAX_DAYS && cursor.getTime() <= endMs; i++) { + const dateStr = `${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}-${String(cursor.getUTCDate()).padStart(2, '0')}`; + const closeTime = dateStr === todayStr && todayClose ? todayClose : defaultClose; + schedule.push({ + date: dateStr, + type: 'OPERATING', + openingTime: constructDateTime(dateStr, openTime, this.timezone), + closingTime: constructDateTime(dateStr, closeTime, this.timezone), + }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + return [{id: PARK_ID, schedule} as EntitySchedule]; } } From e585e50a985d20e0ea335d1d9b233826e5e8c271 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sat, 9 May 2026 14:48:02 +0000 Subject: [PATCH 3/3] test(flamingoland): address Copilot review + add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the Copilot review of #173: 1. fsInt() now rejects non-finite values (NaN from malformed input, Infinity, and empty integerValue strings — Number('') is 0 which would silently pollute Set membership checks). 2. parseTodayCloseBanner only parses "close at" — the homepage may show a morning "open at 10AM" banner whose time is the *opening* time. Re-using it as the closing time would generate a schedule entry with open=10:00 close=10:00. 3. downAllDay maps to DOWN, not CLOSED. statusOpen=false (park-level closure) keeps mapping to CLOSED, but a ride that's down for the day with the park open is operationally distinct, and the typelib has a dedicated DOWN status for it. Refactor: pulled the substantive logic into pure module-scope functions (parseTodayCloseBanner, parseSeasonWindow, parseMapMarkers, findMarkerForRide, decideRideStatus, iterateScheduleDays) so they're testable without Firebase or live network access. Class methods are now thin wrappers over the pure functions. Tests: src/parks/flamingoland/__tests__/flamingoland.test.ts — 41 tests covering happy paths, edge cases, and the three review fixes. Follows the plopsa pattern of testing exported pure helpers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/flamingoland.test.ts | 359 ++++++++++++++++++ src/parks/flamingoland/flamingoland.ts | 346 +++++++++-------- 2 files changed, 541 insertions(+), 164 deletions(-) create mode 100644 src/parks/flamingoland/__tests__/flamingoland.test.ts diff --git a/src/parks/flamingoland/__tests__/flamingoland.test.ts b/src/parks/flamingoland/__tests__/flamingoland.test.ts new file mode 100644 index 00000000..a7d8ac5f --- /dev/null +++ b/src/parks/flamingoland/__tests__/flamingoland.test.ts @@ -0,0 +1,359 @@ +/** + * Unit tests for Flamingo Land pure helpers. + * + * The class itself is integration-tested via `npm run dev -- flamingoland`. + * The substantive logic lives in module-scope pure functions so it can be + * exercised here without Firebase, the HTTP queue, or live network access. + */ +import {describe, test, expect} from 'vitest'; +import { + fsInt, + isoDateInTimezone, + parseTodayCloseBanner, + parseSeasonWindow, + parseMapMarkers, + findMarkerForRide, + decideRideStatus, + iterateScheduleDays, + type Marker, + type SeasonWindow, +} from '../flamingoland.js'; + +describe('fsInt', () => { + test('parses integerValue strings', () => { + expect(fsInt({integerValue: '42'})).toBe(42); + expect(fsInt({integerValue: '-7'})).toBe(-7); + }); + + test('uses doubleValue when integerValue is absent', () => { + expect(fsInt({doubleValue: 91.44})).toBe(91.44); + }); + + test('returns undefined for missing fields', () => { + expect(fsInt(undefined)).toBeUndefined(); + expect(fsInt({})).toBeUndefined(); + }); + + test('returns undefined for non-finite values (Copilot review fix)', () => { + // The defensive guard exists so callers that check `!== undefined` don't + // accidentally treat NaN as a valid value. + expect(fsInt({integerValue: 'not-a-number'})).toBeUndefined(); + expect(fsInt({integerValue: ''})).toBeUndefined(); + expect(fsInt({doubleValue: NaN})).toBeUndefined(); + expect(fsInt({doubleValue: Infinity})).toBeUndefined(); + }); +}); + +describe('isoDateInTimezone', () => { + test('formats UTC instant in Europe/London during BST', () => { + // 2026-07-15 10:00 UTC — London is BST (UTC+1) → still 2026-07-15 + const d = new Date('2026-07-15T10:00:00Z'); + expect(isoDateInTimezone(d, 'Europe/London')).toBe('2026-07-15'); + }); + + test('formats UTC instant in Europe/London during GMT', () => { + const d = new Date('2026-12-25T10:00:00Z'); + expect(isoDateInTimezone(d, 'Europe/London')).toBe('2026-12-25'); + }); + + test('respects timezone difference at day boundaries', () => { + // 2026-05-09 23:30 UTC = 2026-05-10 09:30 in Tokyo + const d = new Date('2026-05-09T23:30:00Z'); + expect(isoDateInTimezone(d, 'Asia/Tokyo')).toBe('2026-05-10'); + }); +}); + +describe('parseTodayCloseBanner', () => { + test('parses "5PM" → "17:00"', () => { + const html = '
Today the Theme Park will close at 5PM
'; + expect(parseTodayCloseBanner(html)).toBe('17:00'); + }); + + test('parses "5:30PM" with minutes', () => { + const html = 'Today the Theme Park will close at 5:30PM'; + expect(parseTodayCloseBanner(html)).toBe('17:30'); + }); + + test('parses "10AM" → "10:00"', () => { + expect(parseTodayCloseBanner('Today the Theme Park will close at 10AM')).toBe('10:00'); + }); + + test('parses "12AM" → "00:00" and "12PM" → "12:00"', () => { + expect(parseTodayCloseBanner('Today the Theme Park will close at 12AM')).toBe('00:00'); + expect(parseTodayCloseBanner('Today the Theme Park will close at 12PM')).toBe('12:00'); + }); + + test('returns null when banner is absent', () => { + expect(parseTodayCloseBanner('nothing relevant')).toBeNull(); + }); + + test('rejects "open at" banner (Copilot review fix)', () => { + // Reusing the morning "open at" time as today's close would corrupt the + // schedule entry to e.g. open=10:00 close=10:00. Only "close at" parses. + const html = 'Today the Theme Park will open at 10AM'; + expect(parseTodayCloseBanner(html)).toBeNull(); + }); + + test('is case-insensitive on AM/PM', () => { + expect(parseTodayCloseBanner('Today the Theme Park will close at 5pm')).toBe('17:00'); + }); +}); + +describe('parseSeasonWindow', () => { + test('parses the canonical webshop blurb', () => { + const html = 'Open daily from 10am between 21st March and 1st November 2026.'; + expect(parseSeasonWindow(html)).toEqual({ + start: '2026-03-21', + end: '2026-11-01', + openHour: 10, + }); + }); + + test('handles missing day-suffix ordinals', () => { + const html = 'Open daily from 9am between 1 April and 30 October 2026.'; + expect(parseSeasonWindow(html)).toEqual({ + start: '2026-04-01', + end: '2026-10-30', + openHour: 9, + }); + }); + + test('parses pm openings', () => { + const html = 'Open daily from 1pm between 1st June and 31st August 2026.'; + expect(parseSeasonWindow(html)?.openHour).toBe(13); + }); + + test('returns null when blurb is absent', () => { + expect(parseSeasonWindow('nothing')).toBeNull(); + }); + + test('returns null when month name is invalid', () => { + const html = 'Open daily from 10am between 21st Marchish and 1st Nov 2026.'; + expect(parseSeasonWindow(html)).toBeNull(); + }); +}); + +describe('parseMapMarkers', () => { + const fixture = `markersData = [{ + id: '216', + icon: 'markers/47.png', + title: 'Splash Battle', + display_name: 'Splash Battle', + lat: 54.21071676426168, + lng: -0.8069901885986397, + position:{lat:54.21071676426168,lng:-0.8069901885986397}, + area: 'splosh', + type: 'ride', + }, + { + id: '378', + icon: 'markers/04.png', + title: 'The Club, Zoo Bar and Street Food Kitchen', + display_name: 'The Club, Zoo Bar and Street Food Kitchen', + lat: 54.21186750470534, + lng: -0.8092216663360507, + position:{lat:54.21186750470534,lng:-0.8092216663360507}, + area: 'riverside_one', + type: 'info', + }, + { + id: '999', + icon: 'markers/x.png', + title: 'Children\\'s Planet Shop', + display_name: 'Children\\'s Planet Shop', + lat: 54.206621990407285, + lng: -0.8078349814414998, + position:{lat:54.206621990407285,lng:-0.8078349814414998}, + area: 'kids', + type: 'info', + }];`; + + test('extracts each marker with id, title, lat, lng, type', () => { + const out = parseMapMarkers(fixture); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ + id: '216', + title: 'Splash Battle', + lat: 54.21071676426168, + lng: -0.8069901885986397, + type: 'ride', + }); + expect(out[1].type).toBe('info'); + expect(out[1].title).toContain('Zoo Bar'); + }); + + test('decodes \\\' escaped apostrophes inside titles', () => { + const out = parseMapMarkers(fixture); + expect(out[2].title).toBe("Children's Planet Shop"); + }); + + test('returns empty array on input with no markers', () => { + expect(parseMapMarkers('no markers here')).toEqual([]); + }); + + test('skips markers with NaN coordinates', () => { + // Defensive: malformed lat/lng shouldn't yield a marker. + const bad = `markersData = [{id:'1',title:'x',lat:'nope',lng:'nope',type:'ride'}];`; + expect(parseMapMarkers(bad)).toEqual([]); + }); +}); + +describe('findMarkerForRide', () => { + const markers: Marker[] = [ + {id: '216', title: 'Splash Battle', lat: 1, lng: 2, type: 'ride'}, + {id: '301', title: 'Sik', lat: 3, lng: 4, type: 'ride'}, + {id: '302', title: 'Sik Shop', lat: 5, lng: 6, type: 'info'}, + {id: '400', title: 'Pirates of Zanzibar Show and Farewell Show', lat: 7, lng: 8, type: 'show'}, + {id: '500', title: "Children's Planet Shop", lat: 9, lng: 10, type: 'info'}, + ]; + + test('returns the marker whose id matches the ride.parkMapMarkerId', () => { + expect(findMarkerForRide('Splash Battle', '216', markers)?.id).toBe('216'); + }); + + test('id-match wins over title clashes', () => { + // Even if another marker has the same title, the id pin is authoritative. + const m: Marker[] = [ + {id: '11', title: 'Sik', lat: 0, lng: 0, type: 'ride'}, + {id: '99', title: 'Sik', lat: 9, lng: 9, type: 'info'}, + ]; + expect(findMarkerForRide('Sik', '99', m)?.id).toBe('99'); + }); + + test('falls back to exact-title match when id is empty/missing', () => { + expect(findMarkerForRide('Sik', undefined, markers)?.id).toBe('301'); + expect(findMarkerForRide('Sik', '', markers)?.id).toBe('301'); + }); + + test('exact-title match prefers type=ride over type=info', () => { + const m: Marker[] = [ + {id: '1', title: 'Sik', lat: 0, lng: 0, type: 'info'}, + {id: '2', title: 'Sik', lat: 0, lng: 0, type: 'ride'}, + ]; + expect(findMarkerForRide('Sik', undefined, m)?.id).toBe('2'); + }); + + test('falls back to prefix match when no exact title hit', () => { + expect(findMarkerForRide('Pirates of Zanzibar', undefined, markers)?.id).toBe('400'); + }); + + test('prefix match across apostrophe encoding (curly vs straight)', () => { + expect(findMarkerForRide('Children’s Planet', undefined, markers)?.id).toBe('500'); + }); + + test('returns undefined when no candidate matches', () => { + expect(findMarkerForRide('Nonexistent Ride', undefined, markers)).toBeUndefined(); + }); +}); + +describe('decideRideStatus', () => { + test('REFURBISHMENT takes precedence over everything', () => { + expect(decideRideStatus({statusOpen: true, underMaintenance: true, downAllDay: true})).toBe('REFURBISHMENT'); + expect(decideRideStatus({statusOpen: false, underMaintenance: true, downAllDay: false})).toBe('REFURBISHMENT'); + }); + + test('!statusOpen → CLOSED (park-level closure)', () => { + expect(decideRideStatus({statusOpen: false, underMaintenance: false, downAllDay: false})).toBe('CLOSED'); + expect(decideRideStatus({statusOpen: false, underMaintenance: false, downAllDay: true})).toBe('CLOSED'); + }); + + test('downAllDay (with park open) → DOWN, not CLOSED (Copilot review fix)', () => { + // Before the fix this returned CLOSED, conflating an operational fault with + // a park-level closure. The typelib has a dedicated DOWN status for this. + expect(decideRideStatus({statusOpen: true, underMaintenance: false, downAllDay: true})).toBe('DOWN'); + }); + + test('default → OPERATING', () => { + expect(decideRideStatus({statusOpen: true, underMaintenance: false, downAllDay: false})).toBe('OPERATING'); + }); +}); + +describe('iterateScheduleDays', () => { + const season: SeasonWindow = {start: '2026-03-21', end: '2026-11-01', openHour: 10}; + + test('emits one entry per day from todayStr through season end inclusive', () => { + const out = iterateScheduleDays({ + todayStr: '2026-10-30', + season, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + }); + expect(out.map(d => d.date)).toEqual(['2026-10-30', '2026-10-31', '2026-11-01']); + expect(out[2].date).toBe('2026-11-01'); + }); + + test('uses scraped todayClose for today, defaultClose for future days', () => { + const out = iterateScheduleDays({ + todayStr: '2026-10-30', + season, + todayClose: '21:00', + defaultClose: '17:00', + timezone: 'Europe/London', + }); + expect(out[0].closingTime).toContain('T21:00:00'); + expect(out[1].closingTime).toContain('T17:00:00'); + }); + + test('starts from season.start when today is before the season', () => { + const out = iterateScheduleDays({ + todayStr: '2026-01-15', + season, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + maxDays: 3, + }); + expect(out.map(d => d.date)).toEqual(['2026-03-21', '2026-03-22', '2026-03-23']); + }); + + test('emits no days when today is past season end', () => { + const out = iterateScheduleDays({ + todayStr: '2026-12-01', + season, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + }); + expect(out).toEqual([]); + }); + + test('honours the maxDays cap', () => { + const out = iterateScheduleDays({ + todayStr: '2026-03-21', + season, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + maxDays: 5, + }); + expect(out).toHaveLength(5); + }); + + test('opening time follows season.openHour', () => { + const out = iterateScheduleDays({ + todayStr: '2026-06-15', + season: {...season, openHour: 9}, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + maxDays: 1, + }); + expect(out[0].openingTime).toContain('T09:00:00'); + }); + + test('switches GMT/BST offset across the DST boundary in late October', () => { + const out = iterateScheduleDays({ + todayStr: '2026-10-24', + season, + todayClose: null, + defaultClose: '17:00', + timezone: 'Europe/London', + }); + // Saturday 24 Oct is BST (+01:00); 1 Nov is GMT (+00:00). + const oct24 = out.find(d => d.date === '2026-10-24'); + const nov01 = out.find(d => d.date === '2026-11-01'); + expect(oct24?.openingTime.endsWith('+01:00')).toBe(true); + expect(nov01?.openingTime.endsWith('+00:00')).toBe(true); + }); +}); diff --git a/src/parks/flamingoland/flamingoland.ts b/src/parks/flamingoland/flamingoland.ts index 0332cbc0..28d8e783 100644 --- a/src/parks/flamingoland/flamingoland.ts +++ b/src/parks/flamingoland/flamingoland.ts @@ -30,7 +30,10 @@ type FsDoc = { fields?: Record; }; -function isoDateInTimezone(date: Date, tz: string): string { +export type Marker = {id: string; title: string; lat: number; lng: number; type: string}; +export type SeasonWindow = {start: string; end: string; openHour: number}; + +export function isoDateInTimezone(date: Date, tz: string): string { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', @@ -43,16 +46,160 @@ function isoDateInTimezone(date: Date, tz: string): string { return `${y}-${m}-${d}`; } -function fsString(v?: FsValue): string | undefined { +export function fsString(v?: FsValue): string | undefined { return v?.stringValue; } -function fsBool(v?: FsValue): boolean | undefined { +export function fsBool(v?: FsValue): boolean | undefined { return v?.booleanValue; } -function fsInt(v?: FsValue): number | undefined { - if (v?.integerValue !== undefined) return Number(v.integerValue); - if (v?.doubleValue !== undefined) return v.doubleValue; - return undefined; +export function fsInt(v?: FsValue): number | undefined { + let n: number | undefined; + if (v?.integerValue !== undefined) { + // Reject empty strings up-front — Number('') is 0, which would pollute set + // membership checks (see CLAUDE.md "Numeric validation" rule). + if (v.integerValue === '') return undefined; + n = Number(v.integerValue); + } else if (v?.doubleValue !== undefined) { + n = v.doubleValue; + } + return n !== undefined && Number.isFinite(n) ? n : undefined; +} + +// ===== Pure parsers / pure decision functions (separate so they're testable) ===== + +// Homepage banner pattern, e.g. +//
Today the Theme Park will close at 5PM
+// Only "close at" is parsed — a morning "open at" banner exists but its time +// is the opening time, not the closing time, so reusing it would corrupt the +// schedule entry. Returns "HH:mm" 24-hour, or null if no closing banner. +export function parseTodayCloseBanner(html: string): string | null { + const m = html.match(/Today the Theme Park will close at\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)/i); + if (!m) return null; + const hour12 = parseInt(m[1], 10); + const minute = m[2] ? parseInt(m[2], 10) : 0; + const isPM = m[3].toUpperCase() === 'PM'; + let hour24 = hour12 % 12; + if (isPM) hour24 += 12; + return `${String(hour24).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +// Webshop blurb, e.g. "Open daily from 10am between 21st March and 1st November 2026." +export function parseSeasonWindow(html: string): SeasonWindow | null { + const m = html.match( + /Open daily from\s+(\d{1,2})(am|pm)\s+between\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+and\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+(\d{4})/i, + ); + if (!m) return null; + const monthIndex = (name: string): number => + ['january','february','march','april','may','june','july','august','september','october','november','december'].indexOf(name.toLowerCase()); + const openHour12 = parseInt(m[1], 10); + const openIsPM = m[2].toLowerCase() === 'pm'; + const openHour = (openHour12 % 12) + (openIsPM ? 12 : 0); + const startMonth = monthIndex(m[4]); + const endMonth = monthIndex(m[6]); + if (startMonth < 0 || endMonth < 0) return null; + const year = parseInt(m[7], 10); + const fmt = (mo: number, day: number) => `${year}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return { + start: fmt(startMonth, parseInt(m[3], 10)), + end: fmt(endMonth, parseInt(m[5], 10)), + openHour, + }; +} + +// /map/ page embeds a JS array +// markersData = [{id: '216', title: 'Splash Battle', lat:.., lng:.., type:'ride'}, …] +export function parseMapMarkers(html: string): Marker[] { + const out: Marker[] = []; + // Each marker is a small object literal; capture id, title, lat, lng, type within + // a single block. The 600-char window comfortably contains one entry. + const re = /id:\s*'(\d+)',[\s\S]{0,600}?title:\s*'((?:[^'\\]|\\.)*)',[\s\S]{0,400}?lat:\s*(-?\d+(?:\.\d+)?),\s*lng:\s*(-?\d+(?:\.\d+)?)[\s\S]{0,400}?type:\s*'([^']*)'/g; + for (const m of html.matchAll(re)) { + const lat = parseFloat(m[3]); + const lng = parseFloat(m[4]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + out.push({id: m[1], title: m[2].replace(/\\'/g, "'"), lat, lng, type: m[5]}); + } + return out; +} + +// Normalise titles for comparison — Firestore uses curly apostrophes +// ("Children's") while marker titles use straight ones. +function normTitle(s: string): string { + return s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim(); +} + +// Tiered lookup: 1) parkMapMarkerId 2) exact title (case + apostrophe insensitive) +// 3) marker title that *starts with* the ride title. Where multiple candidates +// match, prefer the marker tagged type='ride' so a fuzzy lookup still picks the +// attraction over a same-named shop / kiosk. +export function findMarkerForRide( + rideTitle: string, + markerId: string | undefined, + markers: Marker[], +): Marker | undefined { + for (const m of markers) { + if (markerId && m.id === markerId) return m; + } + const key = normTitle(rideTitle); + let exact: Marker | undefined; + for (const m of markers) { + if (normTitle(m.title) !== key) continue; + if (!exact || (exact.type !== 'ride' && m.type === 'ride')) exact = m; + } + if (exact) return exact; + let prefix: Marker | undefined; + for (const m of markers) { + if (!normTitle(m.title).startsWith(key)) continue; + if (!prefix || (prefix.type !== 'ride' && m.type === 'ride')) prefix = m; + } + return prefix; +} + +// statusOpen=false → park-level closure (off-season, weather etc) → CLOSED. +// downAllDay=true → operational fault for the whole day → DOWN (ride out of +// service even though park is open). +// underMaintenance=true → planned refurbishment, takes precedence. +export function decideRideStatus(flags: { + statusOpen: boolean; + underMaintenance: boolean; + downAllDay: boolean; +}): 'OPERATING' | 'CLOSED' | 'DOWN' | 'REFURBISHMENT' { + if (flags.underMaintenance) return 'REFURBISHMENT'; + if (!flags.statusOpen) return 'CLOSED'; + if (flags.downAllDay) return 'DOWN'; + return 'OPERATING'; +} + +// Iterate from today (or season start, whichever is later) through season end +// inclusive. Today's close uses the scraped value; future days fall back to +// `defaultClose`. The `<=` on endMs keeps the season-end day in the list. The +// `maxDays` cap is a safety net. +export function iterateScheduleDays(opts: { + todayStr: string; + season: SeasonWindow; + todayClose: string | null; + defaultClose: string; + timezone: string; + maxDays?: number; +}): Array<{date: string; type: 'OPERATING'; openingTime: string; closingTime: string}> { + const out: Array<{date: string; type: 'OPERATING'; openingTime: string; closingTime: string}> = []; + const startStr = opts.season.start > opts.todayStr ? opts.season.start : opts.todayStr; + const cursor = new Date(`${startStr}T00:00:00Z`); + const endMs = Date.parse(`${opts.season.end}T00:00:00Z`); + const max = opts.maxDays ?? 365; + const openTime = `${String(opts.season.openHour).padStart(2, '0')}:00`; + for (let i = 0; i < max && cursor.getTime() <= endMs; i++) { + const dateStr = `${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}-${String(cursor.getUTCDate()).padStart(2, '0')}`; + const closeTime = dateStr === opts.todayStr && opts.todayClose ? opts.todayClose : opts.defaultClose; + out.push({ + date: dateStr, + type: 'OPERATING', + openingTime: constructDateTime(dateStr, openTime, opts.timezone), + closingTime: constructDateTime(dateStr, closeTime, opts.timezone), + }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return out; } @destinationController({category: 'Flamingo Land'}) @@ -234,80 +381,26 @@ export class FlamingoLand extends Destination { } as any as HTTPObj; } - // The /map/ page embeds a JS array - // markersData = [{id: '216', title: 'Splash Battle', lat:.., lng:.., type:'ride'}, …] - // Marker `id` matches each Firestore ride doc's `parkMapMarkerId`. Some ride - // docs have an empty parkMapMarkerId, so buildEntityList also falls back to - // exact / prefix title match. @cache({ttlSeconds: 60 * 60 * 24}) - async scrapeMarkers(): Promise> { + async scrapeMarkers(): Promise { const resp = await this.fetchMapPage(); - const html = await resp.text(); - const out: Array<{id: string; title: string; lat: number; lng: number; type: string}> = []; - // Each marker is a small object literal; capture id, title, lat, lng, type within - // a single block. The 600-char window comfortably contains one entry. - const re = /id:\s*'(\d+)',[\s\S]{0,600}?title:\s*'((?:[^'\\]|\\.)*)',[\s\S]{0,400}?lat:\s*(-?\d+(?:\.\d+)?),\s*lng:\s*(-?\d+(?:\.\d+)?)[\s\S]{0,400}?type:\s*'([^']*)'/g; - for (const m of html.matchAll(re)) { - const lat = parseFloat(m[3]); - const lng = parseFloat(m[4]); - if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; - out.push({ - id: m[1], - title: m[2].replace(/\\'/g, "'"), - lat, - lng, - type: m[5], - }); - } - return out; + return parseMapMarkers(await resp.text()); } // ===== Schedule scraping ===== - // Homepage banner:
Today the Theme Park will close at 5PM
- // We accept "open at" too — the morning banner may show that instead. - // Returns "HH:mm" 24-hour, or null if no banner is present (off-season / closed). @cache({ttlSeconds: 60 * 30}) async scrapeTodayCloseTime(): Promise { const resp = await this.fetchHomepage(); - const html = await resp.text(); - const m = html.match(/Today the Theme Park will (close|open) at\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)/i); - if (!m) return null; - const hour12 = parseInt(m[2], 10); - const minute = m[3] ? parseInt(m[3], 10) : 0; - const isPM = m[4].toUpperCase() === 'PM'; - let hour24 = hour12 % 12; - if (isPM) hour24 += 12; - return `${String(hour24).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + return parseTodayCloseBanner(await resp.text()); } - // Webshop blurb: "Open daily from 10am between 21st March and 1st November 2026." - // Updated yearly on the source page; cached for 12h. Returns ISO date strings - // (not Date objects) so the cache layer can JSON-roundtrip safely. + // Returns ISO date strings (not Date objects) so the @cache layer can + // JSON-roundtrip safely. @cache({ttlSeconds: 60 * 60 * 12}) - async scrapeSeasonWindow(): Promise<{start: string; end: string; openHour: number} | null> { + async scrapeSeasonWindow(): Promise { const resp = await this.fetchWebshopOverview(); - const html = await resp.text(); - const m = html.match( - /Open daily from\s+(\d{1,2})(am|pm)\s+between\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+and\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+(\d{4})/i, - ); - if (!m) return null; - const monthIndex = (name: string): number => { - return ['january','february','march','april','may','june','july','august','september','october','november','december'].indexOf(name.toLowerCase()); - }; - const openHour12 = parseInt(m[1], 10); - const openIsPM = m[2].toLowerCase() === 'pm'; - const openHour = (openHour12 % 12) + (openIsPM ? 12 : 0); - const startMonth = monthIndex(m[4]); - const endMonth = monthIndex(m[6]); - if (startMonth < 0 || endMonth < 0) return null; - const year = parseInt(m[7], 10); - const fmt = (mo: number, day: number) => `${year}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - return { - start: fmt(startMonth, parseInt(m[3], 10)), - end: fmt(endMonth, parseInt(m[5], 10)), - openHour, - }; + return parseSeasonWindow(await resp.text()); } // ===== Shared filter ===== @@ -321,7 +414,7 @@ export class FlamingoLand extends Destination { const [rides, categories, markers] = await Promise.all([ this.getRides(), this.getRideCategories(), - this.scrapeMarkers().catch(() => [] as Array<{id: string; title: string; lat: number; lng: number; type: string}>), + this.scrapeMarkers().catch(() => [] as Marker[]), ]); const validCategoryIds = new Set(); @@ -330,27 +423,6 @@ export class FlamingoLand extends Destination { if (id !== undefined) validCategoryIds.add(id); } - const norm = (s: string) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim(); - const markersById: Record = {}; - const markersByExactTitle: Record = {}; - for (const m of markers) { - markersById[m.id] = m; - const key = norm(m.title); - const existing = markersByExactTitle[key]; - if (!existing || (existing.type !== 'ride' && m.type === 'ride')) { - markersByExactTitle[key] = m; - } - } - const hasMarker = (rideTitle: string, markerId: string | undefined): boolean => { - if (markerId && markersById[markerId]) return true; - const key = norm(rideTitle); - if (markersByExactTitle[key]) return true; - for (const m of markers) { - if (norm(m.title).startsWith(key)) return true; - } - return false; - }; - const out: string[] = []; for (const doc of rides) { const id = doc.name.split('/').pop() || ''; @@ -358,9 +430,12 @@ export class FlamingoLand extends Destination { const catId = fsInt(doc.fields?.categoriesId); if (!id || !title) continue; if (catId !== undefined && validCategoryIds.size > 0 && !validCategoryIds.has(catId)) continue; - // Defensive: if the marker scrape failed entirely, fall back to keeping - // every ride doc rather than dropping the lot. - if (markers.length > 0 && !hasMarker(decodeHtmlEntities(title), fsString(doc.fields?.parkMapMarkerId) || undefined)) continue; + // Defensive: if the marker scrape failed entirely (markers empty), fall + // back to keeping every ride doc rather than dropping the lot. + if (markers.length > 0) { + const markerId = fsString(doc.fields?.parkMapMarkerId); + if (!findMarkerForRide(decodeHtmlEntities(title), markerId || undefined, markers)) continue; + } out.push(id); } return out; @@ -382,43 +457,10 @@ export class FlamingoLand extends Destination { const [rides, validIdList, markers] = await Promise.all([ this.getRides(), this.getAttractionRideIds(), - this.scrapeMarkers().catch(() => [] as Array<{id: string; title: string; lat: number; lng: number; type: string}>), + this.scrapeMarkers().catch(() => [] as Marker[]), ]); const validIds = new Set(validIdList); - // Index markers for tiered ride→coords lookup: - // 1) by Firestore parkMapMarkerId 2) by exact title 3) by title-prefix. - // Where a title is shared (e.g. a ride and its associated shop), prefer the - // marker tagged type=ride so a fuzzy lookup still picks the attraction. - // Normalise titles for comparison — Firestore uses curly apostrophes - // ("Children's") while marker titles use straight ones. - const norm = (s: string) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim(); - - const markersById: Record = {}; - const markersByExactTitle: Record = {}; - for (const m of markers) { - markersById[m.id] = m; - const key = norm(m.title); - const existing = markersByExactTitle[key]; - if (!existing || (existing.type !== 'ride' && m.type === 'ride')) { - markersByExactTitle[key] = m; - } - } - const findCoords = (rideTitle: string, markerId: string | undefined) => { - if (markerId && markersById[markerId]) return markersById[markerId]; - const key = norm(rideTitle); - if (markersByExactTitle[key]) return markersByExactTitle[key]; - // Prefix fallback: marker title starts with the ride title (e.g. ride - // "Pirates of Zanzibar" → marker "Pirates of Zanzibar Show…"). Prefer - // ride-typed markers when several candidates match. - let best: typeof markers[number] | undefined; - for (const m of markers) { - if (!norm(m.title).startsWith(key)) continue; - if (!best || (best.type !== 'ride' && m.type === 'ride')) best = m; - } - return best; - }; - const parkEntity: Entity = { id: PARK_ID, name: 'Flamingo Land', @@ -447,7 +489,7 @@ export class FlamingoLand extends Destination { } as Entity; const markerId = fsString(doc.fields?.parkMapMarkerId); - const marker = findCoords(decodeHtmlEntities(title), markerId || undefined); + const marker = findMarkerForRide(decodeHtmlEntities(title), markerId || undefined, markers); if (marker) { (entity as any).location = {latitude: marker.lat, longitude: marker.lng}; } @@ -491,16 +533,13 @@ export class FlamingoLand extends Destination { const title = fsString(doc.fields?.title); if (!title) continue; - const statusOpen = fsBool(doc.fields?.statusOpen) ?? false; - const underMaintenance = fsBool(doc.fields?.underMaintenance) ?? false; - const downAllDay = fsBool(doc.fields?.downAllDay) ?? false; + const status = decideRideStatus({ + statusOpen: fsBool(doc.fields?.statusOpen) ?? false, + underMaintenance: fsBool(doc.fields?.underMaintenance) ?? false, + downAllDay: fsBool(doc.fields?.downAllDay) ?? false, + }); const catId = fsInt(doc.fields?.categoriesId); - let status: LiveData['status']; - if (underMaintenance) status = 'REFURBISHMENT'; - else if (downAllDay || !statusOpen) status = 'CLOSED'; - else status = 'OPERATING'; - const ld: LiveData = {id, status} as LiveData; if (status === 'OPERATING' && catId !== undefined && queueCategories.has(catId)) { @@ -530,34 +569,13 @@ export class FlamingoLand extends Destination { ]); if (!season) return [{id: PARK_ID, schedule: []} as EntitySchedule]; - // YYYY-MM-DD in the park's timezone — formatInTimezone(... 'date') returns - // US-locale MM/DD/YYYY, which doesn't sort lexicographically against the - // ISO strings we use elsewhere, so build the ISO form directly. - const todayStr = isoDateInTimezone(new Date(), this.timezone); - const openTime = `${String(season.openHour).padStart(2, '0')}:00`; - const defaultClose = '17:00'; - - const schedule: Array<{date: string; type: 'OPERATING'; openingTime: string; closingTime: string}> = []; - - // Iterate from today (or season start, whichever is later) through season - // end inclusive. The `<=` keeps Nov 1 in the list when we reach it. The - // MAX_DAYS cap is a safety net; with a real season it just stops at end. - const startStr = season.start > todayStr ? season.start : todayStr; - const cursor = new Date(`${startStr}T00:00:00Z`); - const endMs = Date.parse(`${season.end}T00:00:00Z`); - const MAX_DAYS = 365; - for (let i = 0; i < MAX_DAYS && cursor.getTime() <= endMs; i++) { - const dateStr = `${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}-${String(cursor.getUTCDate()).padStart(2, '0')}`; - const closeTime = dateStr === todayStr && todayClose ? todayClose : defaultClose; - schedule.push({ - date: dateStr, - type: 'OPERATING', - openingTime: constructDateTime(dateStr, openTime, this.timezone), - closingTime: constructDateTime(dateStr, closeTime, this.timezone), - }); - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - + const schedule = iterateScheduleDays({ + todayStr: isoDateInTimezone(new Date(), this.timezone), + season, + todayClose, + defaultClose: '17:00', + timezone: this.timezone, + }); return [{id: PARK_ID, schedule} as EntitySchedule]; } }