Skip to content

Commit 9bee65f

Browse files
cubehouseclaude
andcommitted
fix(fantawild): guard time parsing + short-cache empty schedules
Both Copilot comments on commit bfc9945: - parseBusinessTime validates time strings via isValidHHMM() before feeding them to constructDateTime. A malformed entry now drops just that entry instead of throwing and aborting the whole sweep. hhmmToMinutes also rejects out-of-range hours/minutes (>24h, >59m) so '25:00' is treated as malformed rather than producing nonsense. - scrapeSchedule @cache uses a dynamic TTL: 6h for a populated result, 60s for an empty one. Stops a transient CDN/parse failure from freezing a fabricated zero-day schedule for hours. Mirrors the 'cache only TRUE, never FALSE' pattern already established for derived boolean flags. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bfc9945 commit 9bee65f

2 files changed

Lines changed: 54 additions & 9 deletions

File tree

src/parks/fantawild/__tests__/fantawild.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,41 @@ describe('parseBusinessTime', () => {
181181
expect(out[0].closingTime).toBe('2026-07-01T02:00:00+08:00');
182182
});
183183

184+
test('skips entries with malformed startTime/endTime instead of throwing', () => {
185+
// A garbage time string would otherwise blow up constructDateTime and abort
186+
// the entire sweep. Make sure only the bad entry is dropped, the good one
187+
// is still parsed.
188+
const json: FantawildBusinessTimeResponse = {
189+
key: 'k', value: [
190+
// Bad: non-HH:MM startTime
191+
{currentDate: '2026-06-21 00:00:00', startTime: 'morning', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''},
192+
// Bad: out-of-range hour
193+
{currentDate: '2026-06-22 00:00:00', startTime: '25:00', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''},
194+
// Good: should still parse
195+
{currentDate: '2026-06-23 00:00:00', startTime: '09:30', endTime: '18:00', isNight: false, isMorrow: false, nightStartTime: '', nightEndTime: '', activated: true, statusTips: '', parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: ''},
196+
],
197+
};
198+
const out = parseBusinessTime(json, TZ);
199+
expect(out).toHaveLength(1);
200+
expect(out[0].date).toBe('2026-06-23');
201+
});
202+
203+
test('skips night event with malformed nightStartTime but keeps the OPERATING entry', () => {
204+
const json: FantawildBusinessTimeResponse = {
205+
key: 'k', value: [{
206+
currentDate: '2026-06-21 00:00:00',
207+
startTime: '09:30', endTime: '21:00',
208+
isNight: true, isMorrow: false,
209+
nightStartTime: 'evening', nightEndTime: '23:00',
210+
activated: true, statusTips: '',
211+
parkCloseDesc: null, closeRemarkUrl: null, remarkUrl: null, stopIntoPark: '',
212+
}],
213+
};
214+
const out = parseBusinessTime(json, TZ);
215+
expect(out).toHaveLength(1);
216+
expect(out[0].type).toBe('OPERATING');
217+
});
218+
184219
test('does NOT roll when closing time is strictly after opening', () => {
185220
const json: FantawildBusinessTimeResponse = {
186221
key: 'k', value: [{

src/parks/fantawild/fantawild.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,14 @@ export function isFantawildShow(item: FantawildItem): boolean {
151151
function hhmmToMinutes(t: string): number {
152152
const m = /^(\d{1,2}):(\d{2})$/.exec(t);
153153
if (!m) return NaN;
154-
return Number(m[1]) * 60 + Number(m[2]);
154+
const h = Number(m[1]); const min = Number(m[2]);
155+
if (h > 24 || min > 59) return NaN;
156+
return h * 60 + min;
157+
}
158+
159+
/** Time string is parseable as "HH:MM". Avoids feeding garbage to constructDateTime. */
160+
function isValidHHMM(t: string): boolean {
161+
return Number.isFinite(hhmmToMinutes(t));
155162
}
156163

157164
/**
@@ -195,7 +202,9 @@ export function parseBusinessTime(
195202
// Date arrives as "YYYY-MM-DD HH:MM:SS" — take the YYYY-MM-DD prefix.
196203
const date = ev.currentDate?.split(' ')[0];
197204
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
198-
if (ev.startTime && ev.endTime) {
205+
// Validate time strings BEFORE handing them to constructDateTime — a single
206+
// malformed entry would otherwise throw and abort the whole sweep.
207+
if (ev.startTime && ev.endTime && isValidHHMM(ev.startTime) && isValidHHMM(ev.endTime)) {
199208
const closeDate = closeDateAcrossMidnight(date, ev.startTime, ev.endTime);
200209
out.push({
201210
date,
@@ -204,7 +213,8 @@ export function parseBusinessTime(
204213
closingTime: constructDateTime(closeDate, ev.endTime, timezone),
205214
});
206215
}
207-
if (ev.isNight && ev.nightStartTime && ev.nightEndTime) {
216+
if (ev.isNight && ev.nightStartTime && ev.nightEndTime
217+
&& isValidHHMM(ev.nightStartTime) && isValidHHMM(ev.nightEndTime)) {
208218
const nightCloseDate = closeDateAcrossMidnight(date, ev.nightStartTime, ev.nightEndTime);
209219
out.push({
210220
date,
@@ -353,13 +363,13 @@ class Fantawild extends Destination {
353363
* fetch/parse failure so an outage on one park doesn't take out a multi-
354364
* park sweep.
355365
*
356-
* Cached 6h — BusinessTime is a forward-looking calendar that almost
357-
* never changes intra-day, so the short TTL was wasted parse work and
358-
* extra CDN traffic across a 50-park sweep. `fetchBusinessTime()`'s own
359-
* `@http` cache still gives a 15-min upstream-update floor for the rare
360-
* case (closure pushed at short notice).
366+
* Dynamic cache TTL: 6h for a populated result (BusinessTime is a
367+
* forward-looking calendar that rarely changes intra-day, so a long TTL
368+
* cuts wasted parse work and CDN traffic across a 50-park sweep);
369+
* 60s if the result is empty — a transient CDN/parse failure shouldn't
370+
* stick around as a fabricated zero-day schedule for hours.
361371
*/
362-
@cache({ttlSeconds: 60 * 60 * 6})
372+
@cache({callback: (result: ScheduleEntry[]) => result.length === 0 ? 60 : 60 * 60 * 6})
363373
async scrapeSchedule(): Promise<ScheduleEntry[]> {
364374
try {
365375
const resp = await this.fetchBusinessTime();

0 commit comments

Comments
 (0)