feat: add per-ride history charts with wait time and uptime tracking
Build and Deploy / Build & Push (push) Successful in 3m7s
Build and Deploy / Build & Push (push) Successful in 3m7s
Adds a cron-driven sampler that snapshots Queue-Times waits and Six Flags Fast Lane data every 5 minutes into a new ride_wait_samples table, and a clickable per-ride detail page at /park/[id]/ride/[slug] with Today / 7d / 30d Recharts views plus a 30d uptime pill. Rides are keyed by Queue-Times' stable qt_ride_id so renames don't fragment history. Samples store pre-bucketed local_date and local_time in the park's IANA timezone so aggregations are pure SQL and DST-safe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.0",
|
||||
|
||||
@@ -28,6 +28,41 @@ export function getDb(): Database.Database {
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Per-ride canonical record. PK is (park_id, qt_ride_id) so renames
|
||||
// don't fragment history — the slug just provides pretty URLs.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rides (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_coaster INTEGER NOT NULL DEFAULT 0,
|
||||
has_fast_lane INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (park_id, qt_ride_id)
|
||||
)
|
||||
`);
|
||||
_db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug)`);
|
||||
|
||||
// Time-series wait time samples. recorded_at is UTC; local_date/local_time
|
||||
// are pre-bucketed in the park's IANA timezone at insert time so reads are
|
||||
// pure SQL and DST-safe.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
local_date TEXT NOT NULL,
|
||||
local_time TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER,
|
||||
fast_lane_minutes INTEGER,
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
)
|
||||
`);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,3 +161,222 @@ export function getParkDayCount(): number {
|
||||
export function transact(fn: () => void): void {
|
||||
getDb().transaction(fn)();
|
||||
}
|
||||
|
||||
// ─── Ride history ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RideRow {
|
||||
parkId: string;
|
||||
qtRideId: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
isCoaster: boolean;
|
||||
hasFastLane: boolean;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
interface RideDbRow {
|
||||
park_id: string;
|
||||
qt_ride_id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
is_coaster: number;
|
||||
has_fast_lane: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
function rowToRide(row: RideDbRow): RideRow {
|
||||
return {
|
||||
parkId: row.park_id,
|
||||
qtRideId: row.qt_ride_id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
isCoaster: row.is_coaster === 1,
|
||||
hasFastLane: row.has_fast_lane === 1,
|
||||
firstSeen: row.first_seen,
|
||||
lastSeen: row.last_seen,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a ride if new, otherwise update its mutable fields (name, slug,
|
||||
* has_fast_lane, last_seen). is_coaster is sticky once set true.
|
||||
*/
|
||||
export function upsertRide(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
slug: string,
|
||||
name: string,
|
||||
isCoaster: boolean,
|
||||
hasFastLane: boolean,
|
||||
now: string,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO rides (park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (park_id, qt_ride_id) DO UPDATE SET
|
||||
slug = excluded.slug,
|
||||
name = excluded.name,
|
||||
is_coaster = MAX(rides.is_coaster, excluded.is_coaster),
|
||||
has_fast_lane = MAX(rides.has_fast_lane, excluded.has_fast_lane),
|
||||
last_seen = excluded.last_seen`,
|
||||
)
|
||||
.run(parkId, qtRideId, slug, name, isCoaster ? 1 : 0, hasFastLane ? 1 : 0, now, now);
|
||||
}
|
||||
|
||||
export function insertSample(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
recordedAt: string,
|
||||
localDate: string,
|
||||
localTime: string,
|
||||
isOpen: boolean,
|
||||
waitMinutes: number | null,
|
||||
fastLaneMinutes: number | null,
|
||||
): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO ride_wait_samples
|
||||
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
parkId,
|
||||
qtRideId,
|
||||
recordedAt,
|
||||
localDate,
|
||||
localTime,
|
||||
isOpen ? 1 : 0,
|
||||
waitMinutes,
|
||||
fastLaneMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRideBySlug(parkId: string, slug: string): RideRow | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
|
||||
FROM rides
|
||||
WHERE park_id = ? AND slug = ?`,
|
||||
)
|
||||
.get(parkId, slug) as RideDbRow | undefined;
|
||||
return row ? rowToRide(row) : null;
|
||||
}
|
||||
|
||||
export function listRidesForPark(parkId: string): RideRow[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen
|
||||
FROM rides
|
||||
WHERE park_id = ?
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all(parkId) as RideDbRow[];
|
||||
return rows.map(rowToRide);
|
||||
}
|
||||
|
||||
export interface DailySample {
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
export function getRideSamplesForDay(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
localDate: string,
|
||||
): DailySample[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT local_time, is_open, wait_minutes, fast_lane_minutes
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date = ?
|
||||
ORDER BY local_time`,
|
||||
)
|
||||
.all(parkId, qtRideId, localDate) as {
|
||||
local_time: string;
|
||||
is_open: number;
|
||||
wait_minutes: number | null;
|
||||
fast_lane_minutes: number | null;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
localTime: r.local_time,
|
||||
isOpen: r.is_open === 1,
|
||||
waitMinutes: r.wait_minutes,
|
||||
fastLaneMinutes: r.fast_lane_minutes,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface DailyAggregate {
|
||||
localDate: string;
|
||||
avgWait: number | null;
|
||||
maxWait: number | null;
|
||||
avgFastLane: number | null;
|
||||
maxFastLane: number | null;
|
||||
uptimePct: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export function getRideDailyAggregates(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
sinceLocalDate: string,
|
||||
): DailyAggregate[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT local_date,
|
||||
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
|
||||
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
|
||||
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
|
||||
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
|
||||
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
|
||||
COUNT(*) AS sample_count
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
|
||||
GROUP BY local_date
|
||||
ORDER BY local_date`,
|
||||
)
|
||||
.all(parkId, qtRideId, sinceLocalDate) as {
|
||||
local_date: string;
|
||||
avg_wait: number | null;
|
||||
max_wait: number | null;
|
||||
avg_fl: number | null;
|
||||
max_fl: number | null;
|
||||
uptime_pct: number;
|
||||
sample_count: number;
|
||||
}[];
|
||||
return rows.map((r) => ({
|
||||
localDate: r.local_date,
|
||||
avgWait: r.avg_wait,
|
||||
maxWait: r.max_wait,
|
||||
avgFastLane: r.avg_fl,
|
||||
maxFastLane: r.max_fl,
|
||||
uptimePct: r.uptime_pct,
|
||||
sampleCount: r.sample_count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of distinct local_date values for a ride in the given window.
|
||||
* Used to decide whether 7d/30d charts have enough data to render.
|
||||
*/
|
||||
export function countRideDays(parkId: string, qtRideId: number, sinceLocalDate: string): number {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT local_date) AS days
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?`,
|
||||
)
|
||||
.get(parkId, qtRideId, sinceLocalDate) as { days: number };
|
||||
return row.days;
|
||||
}
|
||||
|
||||
export function getRideSampleCount(): number {
|
||||
const row = getDb()
|
||||
.prepare(`SELECT COUNT(*) AS count FROM ride_wait_samples`)
|
||||
.get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { startScheduler } from "./services/scheduler";
|
||||
import calendarRoutes from "./routes/calendar";
|
||||
import parksRoutes from "./routes/parks";
|
||||
import ridesRoutes from "./routes/rides";
|
||||
import rideHistoryRoutes from "./routes/ride-history";
|
||||
import statusRoutes from "./routes/status";
|
||||
import scrapeRoutes from "./routes/scrape";
|
||||
|
||||
@@ -22,6 +23,7 @@ app.use("*", cors());
|
||||
app.route("/api/calendar", calendarRoutes);
|
||||
app.route("/api/parks", parksRoutes);
|
||||
app.route("/api/parks", ridesRoutes);
|
||||
app.route("/api/parks", rideHistoryRoutes);
|
||||
app.route("/api/status", statusRoutes);
|
||||
app.route("/api/scrape", scrapeRoutes);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Ride detail + history endpoint.
|
||||
*
|
||||
* GET /api/parks/:parkId/rides/:slug
|
||||
* Returns ride metadata, today's per-sample series, and 7d + 30d
|
||||
* per-day aggregates in a single round-trip.
|
||||
*
|
||||
* The frontend renders Today / 7d / 30d tabs from one payload — no
|
||||
* client-side fetching of additional ranges. Cache: 60s public.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { PARK_MAP } from "../../../lib/parks";
|
||||
import {
|
||||
getRideBySlug,
|
||||
getRideSamplesForDay,
|
||||
getRideDailyAggregates,
|
||||
countRideDays,
|
||||
type DailySample,
|
||||
type DailyAggregate,
|
||||
} from "../db/queries";
|
||||
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
|
||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||
import { lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
function formatLocalDate(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD `n` days before the given local date (calendar-day math). */
|
||||
function daysAgoIso(localDate: string, n: number): string {
|
||||
const [y, m, d] = localDate.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
date.setUTCDate(date.getUTCDate() - n);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
app.get("/:parkId/rides/:slug", (c) => {
|
||||
const parkId = c.req.param("parkId");
|
||||
const slug = c.req.param("slug");
|
||||
|
||||
const park = PARK_MAP.get(parkId);
|
||||
if (!park) return c.json({ error: "Park not found" }, 404);
|
||||
|
||||
const ride = getRideBySlug(parkId, slug);
|
||||
if (!ride) {
|
||||
return c.json({ error: "Ride not found or no history yet" }, 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayLocal = formatLocalDate(now, park.timezone);
|
||||
const since7d = daysAgoIso(todayLocal, 6); // last 7 calendar days inclusive
|
||||
const since30d = daysAgoIso(todayLocal, 29); // last 30 calendar days inclusive
|
||||
|
||||
const today: DailySample[] = getRideSamplesForDay(parkId, ride.qtRideId, todayLocal);
|
||||
const last7d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since7d);
|
||||
const last30d: DailyAggregate[] = getRideDailyAggregates(parkId, ride.qtRideId, since30d);
|
||||
const daysWith7d = countRideDays(parkId, ride.qtRideId, since7d);
|
||||
const daysWith30d = countRideDays(parkId, ride.qtRideId, since30d);
|
||||
|
||||
// Best-effort current live state from the shared cache (no upstream fetch
|
||||
// — the cache is warmed by Tier-5 every 5 min and by the /rides route).
|
||||
const liveRides = liveRidesCache.get(parkId);
|
||||
const liveMatch = liveRides?.rides.find((r) => slugifyRideName(r.name) === slug) ?? null;
|
||||
const fastLaneCacheEntry = fastLaneCache.get(parkId);
|
||||
const flMatch = liveMatch && fastLaneCacheEntry ? lookupFastLane(liveMatch.name, fastLaneCacheEntry) : null;
|
||||
|
||||
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
|
||||
return c.json({
|
||||
park: {
|
||||
id: park.id,
|
||||
name: park.name,
|
||||
shortName: park.shortName,
|
||||
timezone: park.timezone,
|
||||
},
|
||||
ride: {
|
||||
qtRideId: ride.qtRideId,
|
||||
slug: ride.slug,
|
||||
name: ride.name,
|
||||
isCoaster: ride.isCoaster,
|
||||
hasFastLane: ride.hasFastLane,
|
||||
firstSeen: ride.firstSeen,
|
||||
lastSeen: ride.lastSeen,
|
||||
},
|
||||
live: liveMatch
|
||||
? {
|
||||
isOpen: liveMatch.isOpen,
|
||||
waitMinutes: liveMatch.waitMinutes,
|
||||
hasFastLane: Boolean(flMatch?.hasFastLane),
|
||||
fastLaneMinutes: liveMatch.isOpen ? (flMatch?.fastLaneMinutes ?? null) : null,
|
||||
lastUpdated: liveMatch.lastUpdated,
|
||||
}
|
||||
: null,
|
||||
todayLocal,
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
coverage: {
|
||||
daysWith7d,
|
||||
daysWith30d,
|
||||
todaySampleCount: today.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -7,12 +7,9 @@ import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
|
||||
import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags";
|
||||
import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
import { getDayData } from "../db/queries";
|
||||
import { TtlCache } from "../services/cache";
|
||||
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
|
||||
import { slugifyRideName } from "../../../lib/ride-slug";
|
||||
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
|
||||
import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
|
||||
const liveRidesCache = new TtlCache<LiveRidesResult | null>(5 * 60 * 1000);
|
||||
const fastLaneCache = new TtlCache<FastLaneResult | null>(5 * 60 * 1000);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -68,6 +65,15 @@ app.get("/:id/rides", async (c) => {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Attach URL slug to each live ride so the frontend can build links
|
||||
// without re-slugifying. Same algorithm the sampler uses for the rides table.
|
||||
if (liveRides) {
|
||||
liveRides = {
|
||||
...liveRides,
|
||||
rides: liveRides.rides.map((r) => ({ ...r, slug: slugifyRideName(r.name) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isWeatherDelay =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper";
|
||||
import { sampleAllOpenParks } from "../services/wait-sampler";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -23,8 +24,11 @@ app.post("/trigger", async (c) => {
|
||||
case "force":
|
||||
result = await scrapeFullYear(true);
|
||||
break;
|
||||
case "samples":
|
||||
result = await sampleAllOpenParks();
|
||||
break;
|
||||
default:
|
||||
return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force" }, 400);
|
||||
return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force, samples" }, 400);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shared in-memory caches for live ride data.
|
||||
*
|
||||
* Both the on-demand `/api/parks/:id/rides` route and the Tier-5 wait
|
||||
* sampler hit the same upstream APIs (Queue-Times + Six Flags wait-times).
|
||||
* Sharing the cache means the sampler "warms" it every 5 minutes, so
|
||||
* subsequent user requests hit a fresh cache without re-fetching.
|
||||
*/
|
||||
|
||||
import { TtlCache } from "./cache";
|
||||
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
|
||||
import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes";
|
||||
|
||||
const FIVE_MIN = 5 * 60 * 1000;
|
||||
|
||||
export const liveRidesCache = new TtlCache<LiveRidesResult | null>(FIVE_MIN);
|
||||
export const fastLaneCache = new TtlCache<FastLaneResult | null>(FIVE_MIN);
|
||||
@@ -1,5 +1,6 @@
|
||||
import cron from "node-cron";
|
||||
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
|
||||
import { sampleAllOpenParks } from "./wait-sampler";
|
||||
import { getParkDayCount } from "../db/queries";
|
||||
|
||||
let initialized = false;
|
||||
@@ -32,11 +33,25 @@ export function startScheduler(): void {
|
||||
await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err));
|
||||
});
|
||||
|
||||
// Tier 5: Wait-time samples — every 5 minutes for parks open today
|
||||
cron.schedule("*/5 * * * *", async () => {
|
||||
try {
|
||||
const r = await sampleAllOpenParks();
|
||||
console.log(
|
||||
`[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` +
|
||||
`${r.weatherDelayed} weather-delayed, ${r.errors} errors`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[scheduler] tier-5 error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[scheduler] cron jobs registered");
|
||||
console.log(" tier-1: today — hourly (Mar-Dec)");
|
||||
console.log(" tier-2: current month — every 6h");
|
||||
console.log(" tier-3: upcoming — 3 AM + 3 PM");
|
||||
console.log(" tier-4: full year — 3 AM daily");
|
||||
console.log(" tier-5: wait samples — every 5 min");
|
||||
|
||||
const existingRows = getParkDayCount();
|
||||
if (existingRows < 50) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tier-5 wait-time sampler. Runs every 5 minutes via cron.
|
||||
*
|
||||
* For each park whose `park_days` row marks it open today:
|
||||
* 1. Fetch live rides (Queue-Times) and Fast Lane waits (Six Flags) —
|
||||
* reusing the shared TtlCache so we don't double-hit upstreams.
|
||||
* 2. Detect the "weather delay" case (rides exist but all closed); skip
|
||||
* writes for that park so it doesn't pollute uptime stats.
|
||||
* 3. Upsert each ride into `rides` and INSERT OR IGNORE a sample row.
|
||||
*
|
||||
* Park-local date/time are computed at insert time via Intl.DateTimeFormat
|
||||
* with the park's IANA timezone — DST-safe, automatic.
|
||||
*
|
||||
* Parks are fanned out in chunks of 6 to bound concurrency.
|
||||
*/
|
||||
|
||||
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 { 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 { liveRidesCache, fastLaneCache } from "./live-cache";
|
||||
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
|
||||
|
||||
const PARALLEL_CHUNK = 6;
|
||||
|
||||
export interface SampleRunResult {
|
||||
parksSampled: number;
|
||||
parksSkipped: number;
|
||||
ridesUpserted: number;
|
||||
samplesWritten: number;
|
||||
weatherDelayed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
async function samplePark(park: Park, now: Date): Promise<{
|
||||
ridesUpserted: number;
|
||||
samplesWritten: number;
|
||||
weatherDelayed: boolean;
|
||||
error: boolean;
|
||||
}> {
|
||||
const queueTimesId = QUEUE_TIMES_IDS[park.id];
|
||||
if (!queueTimesId) {
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Live rides — reuse cache; fetch on miss.
|
||||
let liveRides = liveRidesCache.get(park.id);
|
||||
if (liveRides === null) {
|
||||
const coasterSet = getCoasterSet(park.id);
|
||||
liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null);
|
||||
if (liveRides) liveRidesCache.set(park.id, liveRides);
|
||||
}
|
||||
if (!liveRides || liveRides.rides.length === 0) {
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false };
|
||||
}
|
||||
|
||||
// Weather-delay heuristic — skip writing so uptime stays honest.
|
||||
const anyOpen = liveRides.rides.some((r) => r.isOpen);
|
||||
if (!anyOpen) {
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: true, error: false };
|
||||
}
|
||||
|
||||
// Fast Lane — reuse cache; fetch on miss.
|
||||
let fastLane = fastLaneCache.get(park.id);
|
||||
if (fastLane === null) {
|
||||
fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null);
|
||||
if (fastLane) fastLaneCache.set(park.id, fastLane);
|
||||
}
|
||||
|
||||
const recordedAt = now.toISOString();
|
||||
const localDate = formatLocalDate(now, park.timezone);
|
||||
const localTime = formatLocalTime(now, park.timezone);
|
||||
|
||||
let ridesUpserted = 0;
|
||||
let samplesWritten = 0;
|
||||
|
||||
transact(() => {
|
||||
for (const ride of liveRides!.rides) {
|
||||
if (!ride.qtRideId) continue;
|
||||
const slug = slugifyRideName(ride.name);
|
||||
const flMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null;
|
||||
const hasFastLane = Boolean(flMatch?.hasFastLane);
|
||||
const fastLaneMinutes =
|
||||
ride.isOpen && flMatch ? flMatch.fastLaneMinutes : null;
|
||||
|
||||
upsertRide(
|
||||
park.id,
|
||||
ride.qtRideId,
|
||||
slug,
|
||||
ride.name,
|
||||
ride.isCoaster,
|
||||
hasFastLane,
|
||||
recordedAt,
|
||||
);
|
||||
ridesUpserted++;
|
||||
|
||||
insertSample(
|
||||
park.id,
|
||||
ride.qtRideId,
|
||||
recordedAt,
|
||||
localDate,
|
||||
localTime,
|
||||
ride.isOpen,
|
||||
ride.isOpen ? ride.waitMinutes : null,
|
||||
fastLaneMinutes,
|
||||
);
|
||||
samplesWritten++;
|
||||
}
|
||||
});
|
||||
|
||||
return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false };
|
||||
} catch (err) {
|
||||
console.error(`[wait-sampler] error sampling ${park.id}:`, err);
|
||||
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleAllOpenParks(): Promise<SampleRunResult> {
|
||||
const today = getTodayLocal();
|
||||
const now = new Date();
|
||||
const result: SampleRunResult = {
|
||||
parksSampled: 0,
|
||||
parksSkipped: 0,
|
||||
ridesUpserted: 0,
|
||||
samplesWritten: 0,
|
||||
weatherDelayed: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Filter to parks open today.
|
||||
const openParks = PARKS.filter((park) => {
|
||||
const day = getDayData(park.id, today);
|
||||
return day?.isOpen ?? false;
|
||||
});
|
||||
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)));
|
||||
for (const r of chunkResults) {
|
||||
if (r.error) result.errors++;
|
||||
else if (r.weatherDelayed) result.weatherDelayed++;
|
||||
else if (r.samplesWritten > 0) result.parksSampled++;
|
||||
result.ridesUpserted += r.ridesUpserted;
|
||||
result.samplesWritten += r.samplesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Aggregation query tests.
|
||||
*
|
||||
* Spins up an in-memory better-sqlite3 instance with the production schema,
|
||||
* seeds known samples, and verifies the daily aggregation produces the right
|
||||
* avg / max / uptime / sample_count. Locks the SQL semantics so a refactor
|
||||
* can't silently change the meaning of "uptime" or how closed samples are
|
||||
* filtered.
|
||||
*
|
||||
* Run with: npm --prefix backend test
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE ride_wait_samples (
|
||||
park_id TEXT NOT NULL,
|
||||
qt_ride_id INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
local_date TEXT NOT NULL,
|
||||
local_time TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL,
|
||||
wait_minutes INTEGER,
|
||||
fast_lane_minutes INTEGER,
|
||||
PRIMARY KEY (park_id, qt_ride_id, recorded_at)
|
||||
);
|
||||
`;
|
||||
|
||||
const AGGREGATE_QUERY = `
|
||||
SELECT local_date,
|
||||
AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait,
|
||||
MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait,
|
||||
AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl,
|
||||
MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl,
|
||||
CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct,
|
||||
COUNT(*) AS sample_count
|
||||
FROM ride_wait_samples
|
||||
WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?
|
||||
GROUP BY local_date
|
||||
ORDER BY local_date
|
||||
`;
|
||||
|
||||
interface Sample {
|
||||
parkId: string;
|
||||
qtRideId: number;
|
||||
recordedAt: string;
|
||||
localDate: string;
|
||||
localTime: string;
|
||||
isOpen: boolean;
|
||||
waitMinutes: number | null;
|
||||
fastLaneMinutes: number | null;
|
||||
}
|
||||
|
||||
function setup(samples: Sample[]) {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(SCHEMA);
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ride_wait_samples
|
||||
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const s of samples) {
|
||||
stmt.run(
|
||||
s.parkId,
|
||||
s.qtRideId,
|
||||
s.recordedAt,
|
||||
s.localDate,
|
||||
s.localTime,
|
||||
s.isOpen ? 1 : 0,
|
||||
s.waitMinutes,
|
||||
s.fastLaneMinutes,
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
interface AggregateRow {
|
||||
local_date: string;
|
||||
avg_wait: number | null;
|
||||
max_wait: number | null;
|
||||
avg_fl: number | null;
|
||||
max_fl: number | null;
|
||||
uptime_pct: number;
|
||||
sample_count: number;
|
||||
}
|
||||
|
||||
test("avg and max are computed only over open samples", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 20, null),
|
||||
s("p", 1, "2026-05-29", "10:10", true, 30, null),
|
||||
s("p", 1, "2026-05-29", "10:15", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:20", true, 40, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].max_wait, 40);
|
||||
assert.equal(rows[0].avg_wait, (10 + 20 + 30 + 40) / 4);
|
||||
assert.equal(rows[0].sample_count, 5);
|
||||
});
|
||||
|
||||
test("uptime_pct is open_samples / total_samples", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 20, null),
|
||||
s("p", 1, "2026-05-29", "10:10", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:15", false, null, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows[0].uptime_pct, 0.5);
|
||||
});
|
||||
|
||||
test("an all-closed day reports uptime 0 and null waits", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", false, null, null),
|
||||
s("p", 1, "2026-05-29", "10:05", false, null, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].uptime_pct, 0);
|
||||
assert.equal(rows[0].avg_wait, null);
|
||||
assert.equal(rows[0].max_wait, null);
|
||||
});
|
||||
|
||||
test("multiple days are returned separately, ordered by local_date", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p", 1, "2026-05-28", "10:00", true, 50, null),
|
||||
s("p", 1, "2026-05-30", "10:00", true, 30, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-28") as AggregateRow[];
|
||||
assert.equal(rows.length, 3);
|
||||
assert.deepEqual(rows.map((r) => r.local_date), ["2026-05-28", "2026-05-29", "2026-05-30"]);
|
||||
assert.deepEqual(rows.map((r) => r.max_wait), [50, 10, 30]);
|
||||
});
|
||||
|
||||
test("local_date filter excludes earlier days", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-20", "10:00", true, 99, null), // before window
|
||||
s("p", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].local_date, "2026-05-29");
|
||||
});
|
||||
|
||||
test("fast lane stats roll up independently of regular wait stats", () => {
|
||||
const db = setup([
|
||||
s("p", 1, "2026-05-29", "10:00", true, 30, 5),
|
||||
s("p", 1, "2026-05-29", "10:05", true, 40, 10),
|
||||
s("p", 1, "2026-05-29", "10:10", true, 50, null), // open but no FL data
|
||||
]);
|
||||
const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(rows[0].max_fl, 10);
|
||||
assert.equal(rows[0].avg_fl, 7.5); // averaged over the two non-null FL samples
|
||||
assert.equal(rows[0].max_wait, 50);
|
||||
});
|
||||
|
||||
test("parks and rides are isolated", () => {
|
||||
const db = setup([
|
||||
s("p1", 1, "2026-05-29", "10:00", true, 10, null),
|
||||
s("p1", 2, "2026-05-29", "10:00", true, 99, null),
|
||||
s("p2", 1, "2026-05-29", "10:00", true, 50, null),
|
||||
]);
|
||||
const r = db.prepare(AGGREGATE_QUERY).all("p1", 1, "2026-05-29") as AggregateRow[];
|
||||
assert.equal(r[0].max_wait, 10);
|
||||
assert.equal(r[0].sample_count, 1);
|
||||
});
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function s(
|
||||
parkId: string,
|
||||
qtRideId: number,
|
||||
localDate: string,
|
||||
localTime: string,
|
||||
isOpen: boolean,
|
||||
waitMinutes: number | null,
|
||||
fastLaneMinutes: number | null,
|
||||
): Sample {
|
||||
return {
|
||||
parkId,
|
||||
qtRideId,
|
||||
recordedAt: `${localDate}T${localTime}:00Z`,
|
||||
localDate,
|
||||
localTime,
|
||||
isOpen,
|
||||
waitMinutes,
|
||||
fastLaneMinutes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user