From 52f7efd21a69ac758795177387eaba98fb32e0aa Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 31 May 2026 20:49:14 -0400 Subject: [PATCH] fix: suppress weather-delay flag during post-close wind-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parks naturally wind down rides over the 1-hour buffer after their scheduled close, so all-rides-closed in that window isn't a weather delay — it's just closing time. Both the calendar UI badge and the sampler's telemetry counter were misclassifying this. Co-Authored-By: Claude Opus 4.7 --- backend/src/routes/calendar.ts | 7 +++-- backend/src/services/wait-sampler.ts | 42 ++++++++++++++++++---------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts index eefaf3b..978a125 100644 --- a/backend/src/routes/calendar.ts +++ b/backend/src/routes/calendar.ts @@ -124,11 +124,14 @@ app.get("/week", async (c) => { // Only flag weather delay when we know rides are actually closed. An // "unknown" entry means our upstream fetch failed — claim no badge rather - // than falsely showing the storm icon for an outage. + // than falsely showing the storm icon for an outage. Parks in the + // post-close wind-down buffer ("closing") naturally have rides shutting + // off, so suppress the weather badge there too. + const closingSet = new Set(closingParkIds); for (const { id, entry } of results) { if (entry.kind !== "ok") continue; if (entry.openRides === 0) { - weatherDelayParkIds.push(id); + if (!closingSet.has(id)) weatherDelayParkIds.push(id); } else { rideCounts[id] = entry.openRides; } diff --git a/backend/src/services/wait-sampler.ts b/backend/src/services/wait-sampler.ts index ee561e6..d2d4053 100644 --- a/backend/src/services/wait-sampler.ts +++ b/backend/src/services/wait-sampler.ts @@ -18,12 +18,11 @@ import { PARKS } from "../../../lib/parks"; import type { Park } from "../../../lib/scrapers/types"; import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map"; import { getCoasterSet } from "../../../lib/coaster-data"; -import { getTodayLocal } from "../../../lib/env"; +import { getTodayLocal, getOperatingStatus } from "../../../lib/env"; import { fetchLiveRides } from "../../../lib/scrapers/queuetimes"; import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes"; import { slugifyRideName } from "../../../lib/ride-slug"; import { formatLocalDate, formatLocalTime } from "../../../lib/timezone"; -import { isWithinOperatingWindow } from "../../../lib/env"; import { liveRidesCache, fastLaneCache } from "./live-cache"; import { getDayData, upsertRide, insertSample, transact } from "../db/queries"; import { log } from "../log"; @@ -39,7 +38,11 @@ export interface SampleRunResult { errors: number; } -async function samplePark(park: Park, now: Date): Promise<{ +async function samplePark( + park: Park, + now: Date, + status: "open" | "closing", +): Promise<{ ridesUpserted: number; samplesWritten: number; weatherDelayed: boolean; @@ -65,10 +68,17 @@ async function samplePark(park: Park, now: Date): Promise<{ return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false }; } - // Weather-delay heuristic — skip writing so uptime stays honest. + // Weather-delay heuristic — skip writing so uptime stays honest. Only + // applies during scheduled hours; in the post-close wind-down buffer, + // rides legitimately finish operating, so don't mislabel that as weather. const anyOpen = liveRides.rides.some((r) => r.isOpen); if (!anyOpen) { - return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: true, error: false }; + return { + ridesUpserted: 0, + samplesWritten: 0, + weatherDelayed: status === "open", + error: false, + }; } // Fast Lane — reuse cache; fetch on miss. @@ -150,21 +160,25 @@ export async function sampleAllOpenParks(): Promise { }; // Filter to parks that are open today AND currently within their operating - // window. Queue-Times keeps reporting yesterday's last wait with isOpen=true - // overnight, so the per-ride open check isn't enough on its own — we'd - // otherwise pollute uptime stats with phantom open samples between close - // and the next morning's first refresh. - const openParks = PARKS.filter((park) => { + // window (including the 1-hour post-close wind-down). Queue-Times keeps + // reporting yesterday's last wait with isOpen=true overnight, so the + // per-ride open check isn't enough on its own — we'd otherwise pollute + // uptime stats with phantom open samples between close and the next + // morning's first refresh. + const openParks: Array<{ park: Park; status: "open" | "closing" }> = []; + for (const park of PARKS) { const day = getDayData(park.id, today); - if (!day?.isOpen || !day.hoursLabel) return false; - return isWithinOperatingWindow(day.hoursLabel, park.timezone); - }); + if (!day?.isOpen || !day.hoursLabel) continue; + const status = getOperatingStatus(day.hoursLabel, park.timezone); + if (status === "closed") continue; + openParks.push({ park, status }); + } result.parksSkipped = PARKS.length - openParks.length; // Fan out in bounded chunks so we don't blast 24 requests in parallel. for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) { const chunk = openParks.slice(i, i + PARALLEL_CHUNK); - const chunkResults = await Promise.all(chunk.map((park) => samplePark(park, now))); + const chunkResults = await Promise.all(chunk.map(({ park, status }) => samplePark(park, now, status))); for (const r of chunkResults) { if (r.error) result.errors++; else if (r.weatherDelayed) result.weatherDelayed++;