/** * 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 { getDayData, 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"; import { getTodayLocal, isWithinOperatingWindow } from "../../../lib/env"; 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; // Operating-window gate. Queue-Times keeps reporting yesterday's last wait // with isOpen=true overnight, so we override to closed when we're outside // the park's hours — same behaviour as the /rides route. const todayData = getDayData(parkId, getTodayLocal()); const withinWindow = todayData?.hoursLabel ? isWithinOperatingWindow(todayData.hoursLabel, park.timezone) : false; const liveIsOpen = Boolean(liveMatch?.isOpen) && withinWindow; 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: liveIsOpen, // Prefer Six Flags' regular wait when fresh — QT lags around open. waitMinutes: liveIsOpen ? (flMatch?.regularMinutes ?? liveMatch.waitMinutes) : 0, hasFastLane: Boolean(flMatch?.hasFastLane), fastLaneMinutes: liveIsOpen ? (flMatch?.fastLaneMinutes ?? null) : null, lastUpdated: liveMatch.lastUpdated, } : null, todayLocal, today, last7d, last30d, coverage: { daysWith7d, daysWith30d, todaySampleCount: today.length, }, }); }); export default app;