feat: add per-ride history charts with wait time and uptime tracking
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:
2026-05-29 23:35:27 -04:00
parent bfe099322f
commit 4f838d99c1
25 changed files with 2052 additions and 18 deletions
+2 -1
View File
@@ -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",
+35
View File
@@ -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;
}
+219
View File
@@ -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;
}
+2
View File
@@ -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);
+113
View File
@@ -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;
+11 -5
View File
@@ -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 =
+5 -1
View File
@@ -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);
+17
View File
@@ -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);
+15
View File
@@ -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) {
+157
View File
@@ -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;
}
+193
View File
@@ -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,
};
}