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
+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;