fix: suppress weather-delay flag during post-close wind-down
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:
@@ -124,11 +124,14 @@ app.get("/week", async (c) => {
|
|||||||
|
|
||||||
// Only flag weather delay when we know rides are actually closed. An
|
// Only flag weather delay when we know rides are actually closed. An
|
||||||
// "unknown" entry means our upstream fetch failed — claim no badge rather
|
// "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) {
|
for (const { id, entry } of results) {
|
||||||
if (entry.kind !== "ok") continue;
|
if (entry.kind !== "ok") continue;
|
||||||
if (entry.openRides === 0) {
|
if (entry.openRides === 0) {
|
||||||
weatherDelayParkIds.push(id);
|
if (!closingSet.has(id)) weatherDelayParkIds.push(id);
|
||||||
} else {
|
} else {
|
||||||
rideCounts[id] = entry.openRides;
|
rideCounts[id] = entry.openRides;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ import { PARKS } from "../../../lib/parks";
|
|||||||
import type { Park } from "../../../lib/scrapers/types";
|
import type { Park } from "../../../lib/scrapers/types";
|
||||||
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map";
|
||||||
import { getCoasterSet } from "../../../lib/coaster-data";
|
import { getCoasterSet } from "../../../lib/coaster-data";
|
||||||
import { getTodayLocal } from "../../../lib/env";
|
import { getTodayLocal, getOperatingStatus } from "../../../lib/env";
|
||||||
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||||
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||||
import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
|
import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
|
||||||
import { isWithinOperatingWindow } from "../../../lib/env";
|
|
||||||
import { liveRidesCache, fastLaneCache } from "./live-cache";
|
import { liveRidesCache, fastLaneCache } from "./live-cache";
|
||||||
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
|
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
|
||||||
import { log } from "../log";
|
import { log } from "../log";
|
||||||
@@ -39,7 +38,11 @@ export interface SampleRunResult {
|
|||||||
errors: number;
|
errors: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function samplePark(park: Park, now: Date): Promise<{
|
async function samplePark(
|
||||||
|
park: Park,
|
||||||
|
now: Date,
|
||||||
|
status: "open" | "closing",
|
||||||
|
): Promise<{
|
||||||
ridesUpserted: number;
|
ridesUpserted: number;
|
||||||
samplesWritten: number;
|
samplesWritten: number;
|
||||||
weatherDelayed: boolean;
|
weatherDelayed: boolean;
|
||||||
@@ -65,10 +68,17 @@ async function samplePark(park: Park, now: Date): Promise<{
|
|||||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
|
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);
|
const anyOpen = liveRides.rides.some((r) => r.isOpen);
|
||||||
if (!anyOpen) {
|
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.
|
// 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
|
// Filter to parks that are open today AND currently within their operating
|
||||||
// window. Queue-Times keeps reporting yesterday's last wait with isOpen=true
|
// window (including the 1-hour post-close wind-down). Queue-Times keeps
|
||||||
// overnight, so the per-ride open check isn't enough on its own — we'd
|
// reporting yesterday's last wait with isOpen=true overnight, so the
|
||||||
// otherwise pollute uptime stats with phantom open samples between close
|
// per-ride open check isn't enough on its own — we'd otherwise pollute
|
||||||
// and the next morning's first refresh.
|
// uptime stats with phantom open samples between close and the next
|
||||||
const openParks = PARKS.filter((park) => {
|
// morning's first refresh.
|
||||||
|
const openParks: Array<{ park: Park; status: "open" | "closing" }> = [];
|
||||||
|
for (const park of PARKS) {
|
||||||
const day = getDayData(park.id, today);
|
const day = getDayData(park.id, today);
|
||||||
if (!day?.isOpen || !day.hoursLabel) return false;
|
if (!day?.isOpen || !day.hoursLabel) continue;
|
||||||
return isWithinOperatingWindow(day.hoursLabel, park.timezone);
|
const status = getOperatingStatus(day.hoursLabel, park.timezone);
|
||||||
});
|
if (status === "closed") continue;
|
||||||
|
openParks.push({ park, status });
|
||||||
|
}
|
||||||
result.parksSkipped = PARKS.length - openParks.length;
|
result.parksSkipped = PARKS.length - openParks.length;
|
||||||
|
|
||||||
// Fan out in bounded chunks so we don't blast 24 requests in parallel.
|
// Fan out in bounded chunks so we don't blast 24 requests in parallel.
|
||||||
for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) {
|
for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) {
|
||||||
const chunk = openParks.slice(i, 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) {
|
for (const r of chunkResults) {
|
||||||
if (r.error) result.errors++;
|
if (r.error) result.errors++;
|
||||||
else if (r.weatherDelayed) result.weatherDelayed++;
|
else if (r.weatherDelayed) result.weatherDelayed++;
|
||||||
|
|||||||
Reference in New Issue
Block a user