Files
SixFlagsSuperCalendar/backend/src/routes/ride-history.ts
T
josh e888261ed9
Build and Deploy / Lint, typecheck, test (push) Successful in 35s
Build and Deploy / Build & Push (push) Successful in 1m8s
feat: prefer Six Flags regular waits, show Fast Lane at 0, mark outages on chart
Three usability fixes after a day of using the ride detail page.

1. Six Flags is now the primary source for regular wait times. SF's
   /wait-times endpoint reports regular waits alongside Fast Lane, and it
   updates more promptly than Queue-Times around park-open. The sampler
   and the live /rides + ride-history routes all prefer SF's regularWaittime
   when its createdDateTime is non-empty; Queue-Times remains the fallback
   and the authoritative isOpen source.

2. The today chart's Fast Lane line now stays visible when its value is 0
   (walk-on). Y-axis bottom padding ensures the line sits clearly above the
   X-axis frame instead of being clipped against it. The tooltip shows
   "walk-on" instead of "0 min" for that case.

3. Outages are now explicit on the chart instead of just being gaps.
   computeOutages walks today's samples to find contiguous closed runs and
   numbers them chronologically. Each outage renders as a translucent pink
   ReferenceArea with a "#N" label. The custom tooltip detects when the
   cursor is over an outage span and shows "Outage #N — Hh Mm" (e.g.
   "Outage #2 — 1h 28m") in place of the wait/Fast Lane rows.

Includes a seed-test-samples.ts dev script for eyeballing the chart with
synthetic outage data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:54:02 -04:00

128 lines
4.4 KiB
TypeScript

/**
* 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;