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,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;
|
||||
Reference in New Issue
Block a user