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:
@@ -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+0300–U+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, "");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user