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
+31
View File
@@ -0,0 +1,31 @@
/**
* URL-safe slug generator for ride names.
*
* Used as a secondary key on the `rides` table — the primary key is
* (park_id, qt_ride_id) so renames don't lose history. The slug is just
* for pretty URLs.
*
* Steps:
* 1. NFD-normalize to split accented letters into base + combining mark
* 2. Strip combining marks (diacritics, U+0300U+036F)
* 3. Strip trademark symbols
* 4. Lowercase
* 5. Replace any non-alphanumeric run with a single hyphen
* 6. Trim leading/trailing hyphens
*
* Examples:
* "X²" → "x"
* "Lex Luthor: Drop of Doom" → "lex-luthor-drop-of-doom"
* "Catwoman's Whip" → "catwoman-s-whip"
* "Façade" → "facade"
* "Batman™ The Ride" → "batman-the-ride"
*/
export function slugifyRideName(name: string): string {
return name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[™®©]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
+6
View File
@@ -19,6 +19,8 @@ const HEADERS = {
};
export interface LiveRide {
/** Stable Queue-Times ride ID — survives renames, used as the history key. */
qtRideId: number;
name: string;
isOpen: boolean;
waitMinutes: number;
@@ -30,6 +32,8 @@ export interface LiveRide {
hasFastLane?: boolean;
/** Current Fast Lane wait in minutes; null = no data / walk-on. Set by the rides route. */
fastLaneMinutes?: number | null;
/** URL-safe slug derived from name. Set by the rides route. */
slug?: string;
}
export interface LiveRidesResult {
@@ -95,6 +99,7 @@ export async function fetchLiveRides(
for (const r of land.rides ?? []) {
if (!r.name) continue;
rides.push({
qtRideId: r.id,
name: r.name,
isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0,
@@ -108,6 +113,7 @@ export async function fetchLiveRides(
for (const r of json.rides ?? []) {
if (!r.name) continue;
rides.push({
qtRideId: r.id,
name: r.name,
isOpen: r.is_open,
waitMinutes: r.wait_time ?? 0,
+29
View File
@@ -0,0 +1,29 @@
/**
* Format a Date as YYYY-MM-DD in an IANA timezone.
*
* Uses "en-CA" because that locale natively produces ISO-style dates,
* so we don't have to reassemble parts.
*/
export 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);
}
/**
* Format a Date as HH:MM (24-hour) in an IANA timezone.
*/
export function formatLocalTime(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const h = parts.find((p) => p.type === "hour")?.value ?? "00";
const m = parts.find((p) => p.type === "minute")?.value ?? "00";
return `${h}:${m}`;
}