fix: suppress weather-delay flag during post-close wind-down
Build and Deploy / Lint, typecheck, test (push) Successful in 28s
Build and Deploy / Build & Push (push) Successful in 1m15s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:49:14 -04:00
parent e1657f07d7
commit 52f7efd21a
2 changed files with 33 additions and 16 deletions
+5 -2
View File
@@ -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;
}
+28 -14
View File
@@ -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<SampleRunResult> {
};
// 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++;