4f838d99c1
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>
194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|