diff --git a/app/globals.css b/app/globals.css index 0de1000..217bb1f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -110,6 +110,15 @@ background: var(--color-surface-hover) !important; } +/* ── Ride row link hover (LiveRidePanel) ────────────────────────────────── */ +.ride-row-link { + transition: background 120ms ease, border-color 120ms ease; +} +.ride-row-link:hover { + background: var(--color-surface-hover) !important; + border-color: var(--color-accent) !important; +} + /* ── Park month calendar — responsive row heights ───────────────────────── */ /* Mobile: fixed uniform rows so narrow columns don't cause height variance */ .park-calendar-grid { diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx index ca27f75..fd558b3 100644 --- a/app/park/[id]/page.tsx +++ b/app/park/[id]/page.tsx @@ -138,6 +138,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) { {liveRides ? ( ; + searchParams: Promise<{ tab?: string }>; +} + +function parseTab(raw: string | undefined): Tab { + if (raw === "7d" || raw === "30d") return raw; + return "today"; +} + +export default async function RideDetailPage({ params, searchParams }: PageProps) { + const { id, slug } = await params; + const { tab: tabParam } = await searchParams; + + const park = PARK_MAP.get(id); + if (!park) notFound(); + + const tab = parseTab(tabParam); + + const res = await fetch(`${BACKEND_URL}/api/parks/${id}/rides/${slug}`, { + next: { revalidate: 60 }, + }); + + if (res.status === 404) { + return ; + } + + if (!res.ok) { + return ; + } + + const data: ApiResponse = await res.json(); + const { ride, live, today, last7d, last30d, coverage } = data; + + const last30dUptime = last30d.length + ? last30d.reduce((s, d) => s + d.uptimePct * d.sampleCount, 0) / + Math.max(1, last30d.reduce((s, d) => s + d.sampleCount, 0)) + : 0; + const totalSamples30d = last30d.reduce((s, d) => s + d.sampleCount, 0); + + return ( +
+ {/* ── Header ─────────────────────────────────────────────────────────── */} +
+ + ← {park.shortName} + +
+ + {ride.name} + + {ride.isCoaster && ( + 🎢 Coaster + )} +
+ +
+ + {/* ── Current state + uptime row ────────────────────────────────────── */} +
+ + {last30d.length > 0 && ( + + )} +
+ + {/* ── Range tabs ────────────────────────────────────────────────────── */} +
+ +
+ + {/* ── Charts ────────────────────────────────────────────────────────── */} +
+ {tab === "today" && ( + + )} + {tab === "7d" && ( + + )} + {tab === "30d" && ( + + )} +
+ + {/* ── Attribution ───────────────────────────────────────────────────── */} +
+ Wait times via{" "} + + queue-times.com + + {ride.hasFastLane && <> · Fast Lane via sixflags.com} + {" · "}Tracking since {formatDate(ride.firstSeen)} +
+
+
+ ); +} + +// ── Sub-components ───────────────────────────────────────────────────────── + +function CurrentStatePill({ + live, + hasFastLane, +}: { + live: ApiResponse["live"]; + hasFastLane: boolean; +}) { + if (!live) { + return ( +
+ + Current + +
+ Offline +
+
+ ); + } + + const fg = live.isOpen ? "var(--color-open-text)" : "var(--color-text-muted)"; + const bg = live.isOpen ? "var(--color-open-bg)" : "var(--color-surface)"; + const border = live.isOpen ? "var(--color-open-border)" : "var(--color-border)"; + + return ( +
+ + Right now + +
+ {!live.isOpen ? "Closed" : live.waitMinutes > 0 ? `${live.waitMinutes} min` : "Walk-on"} +
+ {hasFastLane && live.isOpen && live.fastLaneMinutes !== null && ( +
+ ⚡ {live.fastLaneMinutes > 0 ? `${live.fastLaneMinutes} min` : "walk-on"} +
+ )} +
+ ); +} + +function RangeTabs({ + parkId, + slug, + active, + coverage, +}: { + parkId: string; + slug: string; + active: Tab; + coverage: ApiResponse["coverage"]; +}) { + const tabs: { id: Tab; label: string; enabled: boolean }[] = [ + { id: "today", label: "Today", enabled: true }, + { id: "7d", label: "7 days", enabled: coverage.daysWith7d >= 1 }, + { id: "30d", label: "30 days", enabled: coverage.daysWith30d >= 1 }, + ]; + + return ( +
+ {tabs.map((t) => { + const isActive = t.id === active; + const style: React.CSSProperties = { + padding: "8px 16px", + fontSize: "0.78rem", + fontWeight: 600, + color: isActive ? "var(--color-text)" : t.enabled ? "var(--color-text-muted)" : "var(--color-text-dim)", + background: "transparent", + border: "none", + borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent", + marginBottom: -1, + textDecoration: "none", + cursor: t.enabled ? "pointer" : "not-allowed", + }; + if (!t.enabled) { + return {t.label}; + } + return ( + + {t.label} + + ); + })} +
+ ); +} + +function TodayPanel({ + samples, + hasFastLane, + sampleCount, + parkId, + firstSeen, +}: { + samples: TodaySample[]; + hasFastLane: boolean; + sampleCount: number; + parkId: string; + firstSeen: string; +}) { + if (sampleCount < 12) { + return ( + + ); + } + return ( +
+ Wait time today + +
+ ); +} + +function RangePanel({ + data, + hasFastLane, + days, + minDays, + windowLabel, + firstSeen, +}: { + data: DailyAggregate[]; + hasFastLane: boolean; + days: number; + minDays: number; + windowLabel: string; + firstSeen: string; +}) { + if (days < minDays) { + return ( + + ); + } + + return ( +
+
+ Regular wait — avg & max per day + +
+ {hasFastLane && ( +
+ Fast Lane wait — avg & max per day + +
+ )} +
+ ); +} + +function ChartHeading({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
+ {title} +
+ {body} +
+ ); +} + +function NoHistoryYet({ parkId, parkName, slug }: { parkId: string; parkName: string; slug: string }) { + return ( +
+
+ + ← {parkName} + +

+ No history yet for {decodeURIComponent(slug).replace(/-/g, " ")} +

+

+ We start tracking a ride the first time we see it open in the live feed. Check back after the park is open — once we've recorded an hour of samples, charts will appear here. +

+
+
+ ); +} + +function ErrorState({ parkId, parkName }: { parkId: string; parkName: string }) { + return ( +
+
+ + ← {parkName} + +

+ Could not load ride history +

+

+ The backend is unreachable. Try again in a moment. +

+
+
+ ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return iso; + } +} diff --git a/backend/package.json b/backend/package.json index 9ad9c90..a7f39a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts" }, "dependencies": { "@hono/node-server": "^2.0.0", diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index b78b5e4..9257a2a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -28,6 +28,41 @@ export function getDb(): Database.Database { } catch { // Column already exists } + + // Per-ride canonical record. PK is (park_id, qt_ride_id) so renames + // don't fragment history — the slug just provides pretty URLs. + _db.exec(` + CREATE TABLE IF NOT EXISTS rides ( + park_id TEXT NOT NULL, + qt_ride_id INTEGER NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + is_coaster INTEGER NOT NULL DEFAULT 0, + has_fast_lane INTEGER NOT NULL DEFAULT 0, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + PRIMARY KEY (park_id, qt_ride_id) + ) + `); + _db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_rides_slug ON rides (park_id, slug)`); + + // Time-series wait time samples. recorded_at is UTC; local_date/local_time + // are pre-bucketed in the park's IANA timezone at insert time so reads are + // pure SQL and DST-safe. + _db.exec(` + CREATE TABLE IF NOT EXISTS ride_wait_samples ( + park_id TEXT NOT NULL, + qt_ride_id INTEGER NOT NULL, + recorded_at TEXT NOT NULL, + local_date TEXT NOT NULL, + local_time TEXT NOT NULL, + is_open INTEGER NOT NULL, + wait_minutes INTEGER, + fast_lane_minutes INTEGER, + PRIMARY KEY (park_id, qt_ride_id, recorded_at) + ) + `); + return _db; } diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 2c10479..876c28d 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -161,3 +161,222 @@ export function getParkDayCount(): number { export function transact(fn: () => void): void { getDb().transaction(fn)(); } + +// ─── Ride history ──────────────────────────────────────────────────────────── + +export interface RideRow { + parkId: string; + qtRideId: number; + slug: string; + name: string; + isCoaster: boolean; + hasFastLane: boolean; + firstSeen: string; + lastSeen: string; +} + +interface RideDbRow { + park_id: string; + qt_ride_id: number; + slug: string; + name: string; + is_coaster: number; + has_fast_lane: number; + first_seen: string; + last_seen: string; +} + +function rowToRide(row: RideDbRow): RideRow { + return { + parkId: row.park_id, + qtRideId: row.qt_ride_id, + slug: row.slug, + name: row.name, + isCoaster: row.is_coaster === 1, + hasFastLane: row.has_fast_lane === 1, + firstSeen: row.first_seen, + lastSeen: row.last_seen, + }; +} + +/** + * Insert a ride if new, otherwise update its mutable fields (name, slug, + * has_fast_lane, last_seen). is_coaster is sticky once set true. + */ +export function upsertRide( + parkId: string, + qtRideId: number, + slug: string, + name: string, + isCoaster: boolean, + hasFastLane: boolean, + now: string, +): void { + getDb() + .prepare( + `INSERT INTO rides (park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (park_id, qt_ride_id) DO UPDATE SET + slug = excluded.slug, + name = excluded.name, + is_coaster = MAX(rides.is_coaster, excluded.is_coaster), + has_fast_lane = MAX(rides.has_fast_lane, excluded.has_fast_lane), + last_seen = excluded.last_seen`, + ) + .run(parkId, qtRideId, slug, name, isCoaster ? 1 : 0, hasFastLane ? 1 : 0, now, now); +} + +export function insertSample( + parkId: string, + qtRideId: number, + recordedAt: string, + localDate: string, + localTime: string, + isOpen: boolean, + waitMinutes: number | null, + fastLaneMinutes: number | null, +): void { + getDb() + .prepare( + `INSERT OR IGNORE INTO ride_wait_samples + (park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + parkId, + qtRideId, + recordedAt, + localDate, + localTime, + isOpen ? 1 : 0, + waitMinutes, + fastLaneMinutes, + ); +} + +export function getRideBySlug(parkId: string, slug: string): RideRow | null { + const row = getDb() + .prepare( + `SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen + FROM rides + WHERE park_id = ? AND slug = ?`, + ) + .get(parkId, slug) as RideDbRow | undefined; + return row ? rowToRide(row) : null; +} + +export function listRidesForPark(parkId: string): RideRow[] { + const rows = getDb() + .prepare( + `SELECT park_id, qt_ride_id, slug, name, is_coaster, has_fast_lane, first_seen, last_seen + FROM rides + WHERE park_id = ? + ORDER BY name`, + ) + .all(parkId) as RideDbRow[]; + return rows.map(rowToRide); +} + +export interface DailySample { + localTime: string; + isOpen: boolean; + waitMinutes: number | null; + fastLaneMinutes: number | null; +} + +export function getRideSamplesForDay( + parkId: string, + qtRideId: number, + localDate: string, +): DailySample[] { + const rows = getDb() + .prepare( + `SELECT local_time, is_open, wait_minutes, fast_lane_minutes + FROM ride_wait_samples + WHERE park_id = ? AND qt_ride_id = ? AND local_date = ? + ORDER BY local_time`, + ) + .all(parkId, qtRideId, localDate) as { + local_time: string; + is_open: number; + wait_minutes: number | null; + fast_lane_minutes: number | null; + }[]; + return rows.map((r) => ({ + localTime: r.local_time, + isOpen: r.is_open === 1, + waitMinutes: r.wait_minutes, + fastLaneMinutes: r.fast_lane_minutes, + })); +} + +export interface DailyAggregate { + localDate: string; + avgWait: number | null; + maxWait: number | null; + avgFastLane: number | null; + maxFastLane: number | null; + uptimePct: number; + sampleCount: number; +} + +export function getRideDailyAggregates( + parkId: string, + qtRideId: number, + sinceLocalDate: string, +): DailyAggregate[] { + const rows = getDb() + .prepare( + `SELECT local_date, + AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait, + MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait, + AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl, + MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl, + CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct, + COUNT(*) AS sample_count + FROM ride_wait_samples + WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ? + GROUP BY local_date + ORDER BY local_date`, + ) + .all(parkId, qtRideId, sinceLocalDate) as { + local_date: string; + avg_wait: number | null; + max_wait: number | null; + avg_fl: number | null; + max_fl: number | null; + uptime_pct: number; + sample_count: number; + }[]; + return rows.map((r) => ({ + localDate: r.local_date, + avgWait: r.avg_wait, + maxWait: r.max_wait, + avgFastLane: r.avg_fl, + maxFastLane: r.max_fl, + uptimePct: r.uptime_pct, + sampleCount: r.sample_count, + })); +} + +/** + * Number of distinct local_date values for a ride in the given window. + * Used to decide whether 7d/30d charts have enough data to render. + */ +export function countRideDays(parkId: string, qtRideId: number, sinceLocalDate: string): number { + const row = getDb() + .prepare( + `SELECT COUNT(DISTINCT local_date) AS days + FROM ride_wait_samples + WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ?`, + ) + .get(parkId, qtRideId, sinceLocalDate) as { days: number }; + return row.days; +} + +export function getRideSampleCount(): number { + const row = getDb() + .prepare(`SELECT COUNT(*) AS count FROM ride_wait_samples`) + .get() as { count: number }; + return row.count; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index f329ad7..2bdfa44 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { startScheduler } from "./services/scheduler"; import calendarRoutes from "./routes/calendar"; import parksRoutes from "./routes/parks"; import ridesRoutes from "./routes/rides"; +import rideHistoryRoutes from "./routes/ride-history"; import statusRoutes from "./routes/status"; import scrapeRoutes from "./routes/scrape"; @@ -22,6 +23,7 @@ app.use("*", cors()); app.route("/api/calendar", calendarRoutes); app.route("/api/parks", parksRoutes); app.route("/api/parks", ridesRoutes); +app.route("/api/parks", rideHistoryRoutes); app.route("/api/status", statusRoutes); app.route("/api/scrape", scrapeRoutes); diff --git a/backend/src/routes/ride-history.ts b/backend/src/routes/ride-history.ts new file mode 100644 index 0000000..f25c794 --- /dev/null +++ b/backend/src/routes/ride-history.ts @@ -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; diff --git a/backend/src/routes/rides.ts b/backend/src/routes/rides.ts index 8afccfc..47856b8 100644 --- a/backend/src/routes/rides.ts +++ b/backend/src/routes/rides.ts @@ -7,12 +7,9 @@ import { fetchLiveRides } from "../../../lib/scrapers/queuetimes"; import { scrapeRidesForDay } from "../../../lib/scrapers/sixflags"; import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes"; import { getDayData } from "../db/queries"; -import { TtlCache } from "../services/cache"; +import { liveRidesCache, fastLaneCache } from "../services/live-cache"; +import { slugifyRideName } from "../../../lib/ride-slug"; import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes"; -import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes"; - -const liveRidesCache = new TtlCache(5 * 60 * 1000); -const fastLaneCache = new TtlCache(5 * 60 * 1000); const app = new Hono(); @@ -68,6 +65,15 @@ app.get("/:id/rides", async (c) => { }; } } + + // Attach URL slug to each live ride so the frontend can build links + // without re-slugifying. Same algorithm the sampler uses for the rides table. + if (liveRides) { + liveRides = { + ...liveRides, + rides: liveRides.rides.map((r) => ({ ...r, slug: slugifyRideName(r.name) })), + }; + } } const isWeatherDelay = diff --git a/backend/src/routes/scrape.ts b/backend/src/routes/scrape.ts index 7deb7ee..508eaf3 100644 --- a/backend/src/routes/scrape.ts +++ b/backend/src/routes/scrape.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "../services/scraper"; +import { sampleAllOpenParks } from "../services/wait-sampler"; const app = new Hono(); @@ -23,8 +24,11 @@ app.post("/trigger", async (c) => { case "force": result = await scrapeFullYear(true); break; + case "samples": + result = await sampleAllOpenParks(); + break; default: - return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force" }, 400); + return c.json({ error: "Invalid scope. Use: today, month, upcoming, full, force, samples" }, 400); } return c.json(result); diff --git a/backend/src/services/live-cache.ts b/backend/src/services/live-cache.ts new file mode 100644 index 0000000..1834f0e --- /dev/null +++ b/backend/src/services/live-cache.ts @@ -0,0 +1,17 @@ +/** + * Shared in-memory caches for live ride data. + * + * Both the on-demand `/api/parks/:id/rides` route and the Tier-5 wait + * sampler hit the same upstream APIs (Queue-Times + Six Flags wait-times). + * Sharing the cache means the sampler "warms" it every 5 minutes, so + * subsequent user requests hit a fresh cache without re-fetching. + */ + +import { TtlCache } from "./cache"; +import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes"; +import type { FastLaneResult } from "../../../lib/scrapers/sixflags-waittimes"; + +const FIVE_MIN = 5 * 60 * 1000; + +export const liveRidesCache = new TtlCache(FIVE_MIN); +export const fastLaneCache = new TtlCache(FIVE_MIN); diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 381e564..afcfc8d 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -1,5 +1,6 @@ import cron from "node-cron"; import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper"; +import { sampleAllOpenParks } from "./wait-sampler"; import { getParkDayCount } from "../db/queries"; let initialized = false; @@ -32,11 +33,25 @@ export function startScheduler(): void { await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err)); }); + // Tier 5: Wait-time samples — every 5 minutes for parks open today + cron.schedule("*/5 * * * *", async () => { + try { + const r = await sampleAllOpenParks(); + console.log( + `[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` + + `${r.weatherDelayed} weather-delayed, ${r.errors} errors`, + ); + } catch (err) { + console.error("[scheduler] tier-5 error:", err); + } + }); + console.log("[scheduler] cron jobs registered"); console.log(" tier-1: today — hourly (Mar-Dec)"); console.log(" tier-2: current month — every 6h"); console.log(" tier-3: upcoming — 3 AM + 3 PM"); console.log(" tier-4: full year — 3 AM daily"); + console.log(" tier-5: wait samples — every 5 min"); const existingRows = getParkDayCount(); if (existingRows < 50) { diff --git a/backend/src/services/wait-sampler.ts b/backend/src/services/wait-sampler.ts new file mode 100644 index 0000000..169383d --- /dev/null +++ b/backend/src/services/wait-sampler.ts @@ -0,0 +1,157 @@ +/** + * Tier-5 wait-time sampler. Runs every 5 minutes via cron. + * + * For each park whose `park_days` row marks it open today: + * 1. Fetch live rides (Queue-Times) and Fast Lane waits (Six Flags) — + * reusing the shared TtlCache so we don't double-hit upstreams. + * 2. Detect the "weather delay" case (rides exist but all closed); skip + * writes for that park so it doesn't pollute uptime stats. + * 3. Upsert each ride into `rides` and INSERT OR IGNORE a sample row. + * + * Park-local date/time are computed at insert time via Intl.DateTimeFormat + * with the park's IANA timezone — DST-safe, automatic. + * + * Parks are fanned out in chunks of 6 to bound concurrency. + */ + +import { PARKS } from "../../../lib/parks"; +import type { Park } from "../../../lib/scrapers/types"; +import { QUEUE_TIMES_IDS } from "../../../lib/queue-times-map"; +import { getCoasterSet } from "../../../lib/coaster-data"; +import { getTodayLocal } from "../../../lib/env"; +import { fetchLiveRides } from "../../../lib/scrapers/queuetimes"; +import { fetchFastLaneWaits, lookupFastLane } from "../../../lib/scrapers/sixflags-waittimes"; +import { slugifyRideName } from "../../../lib/ride-slug"; +import { formatLocalDate, formatLocalTime } from "../../../lib/timezone"; +import { liveRidesCache, fastLaneCache } from "./live-cache"; +import { getDayData, upsertRide, insertSample, transact } from "../db/queries"; + +const PARALLEL_CHUNK = 6; + +export interface SampleRunResult { + parksSampled: number; + parksSkipped: number; + ridesUpserted: number; + samplesWritten: number; + weatherDelayed: number; + errors: number; +} + +async function samplePark(park: Park, now: Date): Promise<{ + ridesUpserted: number; + samplesWritten: number; + weatherDelayed: boolean; + error: boolean; +}> { + const queueTimesId = QUEUE_TIMES_IDS[park.id]; + if (!queueTimesId) { + return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false }; + } + + try { + // Live rides — reuse cache; fetch on miss. + let liveRides = liveRidesCache.get(park.id); + if (liveRides === null) { + const coasterSet = getCoasterSet(park.id); + liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null); + if (liveRides) liveRidesCache.set(park.id, liveRides); + } + if (!liveRides || liveRides.rides.length === 0) { + return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: false }; + } + + // Weather-delay heuristic — skip writing so uptime stays honest. + const anyOpen = liveRides.rides.some((r) => r.isOpen); + if (!anyOpen) { + return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: true, error: false }; + } + + // Fast Lane — reuse cache; fetch on miss. + let fastLane = fastLaneCache.get(park.id); + if (fastLane === null) { + fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null); + if (fastLane) fastLaneCache.set(park.id, fastLane); + } + + const recordedAt = now.toISOString(); + const localDate = formatLocalDate(now, park.timezone); + const localTime = formatLocalTime(now, park.timezone); + + let ridesUpserted = 0; + let samplesWritten = 0; + + transact(() => { + for (const ride of liveRides!.rides) { + if (!ride.qtRideId) continue; + const slug = slugifyRideName(ride.name); + const flMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null; + const hasFastLane = Boolean(flMatch?.hasFastLane); + const fastLaneMinutes = + ride.isOpen && flMatch ? flMatch.fastLaneMinutes : null; + + upsertRide( + park.id, + ride.qtRideId, + slug, + ride.name, + ride.isCoaster, + hasFastLane, + recordedAt, + ); + ridesUpserted++; + + insertSample( + park.id, + ride.qtRideId, + recordedAt, + localDate, + localTime, + ride.isOpen, + ride.isOpen ? ride.waitMinutes : null, + fastLaneMinutes, + ); + samplesWritten++; + } + }); + + return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false }; + } catch (err) { + console.error(`[wait-sampler] error sampling ${park.id}:`, err); + return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true }; + } +} + +export async function sampleAllOpenParks(): Promise { + const today = getTodayLocal(); + const now = new Date(); + const result: SampleRunResult = { + parksSampled: 0, + parksSkipped: 0, + ridesUpserted: 0, + samplesWritten: 0, + weatherDelayed: 0, + errors: 0, + }; + + // Filter to parks open today. + const openParks = PARKS.filter((park) => { + const day = getDayData(park.id, today); + return day?.isOpen ?? false; + }); + result.parksSkipped = PARKS.length - openParks.length; + + // Fan out in bounded chunks so we don't blast 24 requests in parallel. + for (let i = 0; i < openParks.length; i += PARALLEL_CHUNK) { + const chunk = openParks.slice(i, i + PARALLEL_CHUNK); + const chunkResults = await Promise.all(chunk.map((park) => samplePark(park, now))); + for (const r of chunkResults) { + if (r.error) result.errors++; + else if (r.weatherDelayed) result.weatherDelayed++; + else if (r.samplesWritten > 0) result.parksSampled++; + result.ridesUpserted += r.ridesUpserted; + result.samplesWritten += r.samplesWritten; + } + } + + return result; +} diff --git a/backend/tests/wait-aggregation.test.ts b/backend/tests/wait-aggregation.test.ts new file mode 100644 index 0000000..2c9b25c --- /dev/null +++ b/backend/tests/wait-aggregation.test.ts @@ -0,0 +1,193 @@ +/** + * Aggregation query tests. + * + * Spins up an in-memory better-sqlite3 instance with the production schema, + * seeds known samples, and verifies the daily aggregation produces the right + * avg / max / uptime / sample_count. Locks the SQL semantics so a refactor + * can't silently change the meaning of "uptime" or how closed samples are + * filtered. + * + * Run with: npm --prefix backend test + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; + +const SCHEMA = ` + CREATE TABLE ride_wait_samples ( + park_id TEXT NOT NULL, + qt_ride_id INTEGER NOT NULL, + recorded_at TEXT NOT NULL, + local_date TEXT NOT NULL, + local_time TEXT NOT NULL, + is_open INTEGER NOT NULL, + wait_minutes INTEGER, + fast_lane_minutes INTEGER, + PRIMARY KEY (park_id, qt_ride_id, recorded_at) + ); +`; + +const AGGREGATE_QUERY = ` + SELECT local_date, + AVG(CASE WHEN is_open = 1 THEN wait_minutes END) AS avg_wait, + MAX(CASE WHEN is_open = 1 THEN wait_minutes END) AS max_wait, + AVG(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS avg_fl, + MAX(CASE WHEN is_open = 1 THEN fast_lane_minutes END) AS max_fl, + CAST(SUM(is_open) AS REAL) / COUNT(*) AS uptime_pct, + COUNT(*) AS sample_count + FROM ride_wait_samples + WHERE park_id = ? AND qt_ride_id = ? AND local_date >= ? + GROUP BY local_date + ORDER BY local_date +`; + +interface Sample { + parkId: string; + qtRideId: number; + recordedAt: string; + localDate: string; + localTime: string; + isOpen: boolean; + waitMinutes: number | null; + fastLaneMinutes: number | null; +} + +function setup(samples: Sample[]) { + const db = new Database(":memory:"); + db.exec(SCHEMA); + const stmt = db.prepare( + `INSERT INTO ride_wait_samples + (park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const s of samples) { + stmt.run( + s.parkId, + s.qtRideId, + s.recordedAt, + s.localDate, + s.localTime, + s.isOpen ? 1 : 0, + s.waitMinutes, + s.fastLaneMinutes, + ); + } + return db; +} + +interface AggregateRow { + local_date: string; + avg_wait: number | null; + max_wait: number | null; + avg_fl: number | null; + max_fl: number | null; + uptime_pct: number; + sample_count: number; +} + +test("avg and max are computed only over open samples", () => { + const db = setup([ + s("p", 1, "2026-05-29", "10:00", true, 10, null), + s("p", 1, "2026-05-29", "10:05", true, 20, null), + s("p", 1, "2026-05-29", "10:10", true, 30, null), + s("p", 1, "2026-05-29", "10:15", false, null, null), + s("p", 1, "2026-05-29", "10:20", true, 40, null), + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[]; + assert.equal(rows.length, 1); + assert.equal(rows[0].max_wait, 40); + assert.equal(rows[0].avg_wait, (10 + 20 + 30 + 40) / 4); + assert.equal(rows[0].sample_count, 5); +}); + +test("uptime_pct is open_samples / total_samples", () => { + const db = setup([ + s("p", 1, "2026-05-29", "10:00", true, 10, null), + s("p", 1, "2026-05-29", "10:05", true, 20, null), + s("p", 1, "2026-05-29", "10:10", false, null, null), + s("p", 1, "2026-05-29", "10:15", false, null, null), + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[]; + assert.equal(rows[0].uptime_pct, 0.5); +}); + +test("an all-closed day reports uptime 0 and null waits", () => { + const db = setup([ + s("p", 1, "2026-05-29", "10:00", false, null, null), + s("p", 1, "2026-05-29", "10:05", false, null, null), + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[]; + assert.equal(rows.length, 1); + assert.equal(rows[0].uptime_pct, 0); + assert.equal(rows[0].avg_wait, null); + assert.equal(rows[0].max_wait, null); +}); + +test("multiple days are returned separately, ordered by local_date", () => { + const db = setup([ + s("p", 1, "2026-05-29", "10:00", true, 10, null), + s("p", 1, "2026-05-28", "10:00", true, 50, null), + s("p", 1, "2026-05-30", "10:00", true, 30, null), + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-28") as AggregateRow[]; + assert.equal(rows.length, 3); + assert.deepEqual(rows.map((r) => r.local_date), ["2026-05-28", "2026-05-29", "2026-05-30"]); + assert.deepEqual(rows.map((r) => r.max_wait), [50, 10, 30]); +}); + +test("local_date filter excludes earlier days", () => { + const db = setup([ + s("p", 1, "2026-05-20", "10:00", true, 99, null), // before window + s("p", 1, "2026-05-29", "10:00", true, 10, null), + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[]; + assert.equal(rows.length, 1); + assert.equal(rows[0].local_date, "2026-05-29"); +}); + +test("fast lane stats roll up independently of regular wait stats", () => { + const db = setup([ + s("p", 1, "2026-05-29", "10:00", true, 30, 5), + s("p", 1, "2026-05-29", "10:05", true, 40, 10), + s("p", 1, "2026-05-29", "10:10", true, 50, null), // open but no FL data + ]); + const rows = db.prepare(AGGREGATE_QUERY).all("p", 1, "2026-05-29") as AggregateRow[]; + assert.equal(rows[0].max_fl, 10); + assert.equal(rows[0].avg_fl, 7.5); // averaged over the two non-null FL samples + assert.equal(rows[0].max_wait, 50); +}); + +test("parks and rides are isolated", () => { + const db = setup([ + s("p1", 1, "2026-05-29", "10:00", true, 10, null), + s("p1", 2, "2026-05-29", "10:00", true, 99, null), + s("p2", 1, "2026-05-29", "10:00", true, 50, null), + ]); + const r = db.prepare(AGGREGATE_QUERY).all("p1", 1, "2026-05-29") as AggregateRow[]; + assert.equal(r[0].max_wait, 10); + assert.equal(r[0].sample_count, 1); +}); + +// ── Helper ─────────────────────────────────────────────────────────────────── + +function s( + parkId: string, + qtRideId: number, + localDate: string, + localTime: string, + isOpen: boolean, + waitMinutes: number | null, + fastLaneMinutes: number | null, +): Sample { + return { + parkId, + qtRideId, + recordedAt: `${localDate}T${localTime}:00Z`, + localDate, + localTime, + isOpen, + waitMinutes, + fastLaneMinutes, + }; +} diff --git a/components/LiveRidePanel.tsx b/components/LiveRidePanel.tsx index 8c709c3..eff74a5 100644 --- a/components/LiveRidePanel.tsx +++ b/components/LiveRidePanel.tsx @@ -1,15 +1,18 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes"; +import { slugifyRideName } from "@/lib/ride-slug"; interface LiveRidePanelProps { + parkId: string; liveRides: LiveRidesResult; parkOpenToday: boolean; isWeatherDelay?: boolean; } -export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) { +export function LiveRidePanel({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) { const { rides } = liveRides; const hasCoasters = rides.some((r) => r.isCoaster); const hasFastLane = rides.some((r) => r.hasFastLane); @@ -183,21 +186,22 @@ export function LiveRidePanel({ liveRides, parkOpenToday, isWeatherDelay }: Live gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 6, }}> - {openRides.map((ride) => )} - {closedRides.map((ride) => )} + {openRides.map((ride) => )} + {closedRides.map((ride) => )} ); } -function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean }) { +function RideRow({ parkId, ride, fastLaneMode }: { parkId: string; ride: LiveRide; fastLaneMode: boolean }) { const showWait = ride.isOpen && ride.waitMinutes > 0; const fastLaneActive = fastLaneMode && ride.hasFastLane; const flWait = ride.fastLaneMinutes ?? 0; + const slug = ride.slug ?? slugifyRideName(ride.name); return ( -
)} -
+ ); } diff --git a/components/charts/UptimePill.tsx b/components/charts/UptimePill.tsx new file mode 100644 index 0000000..082845c --- /dev/null +++ b/components/charts/UptimePill.tsx @@ -0,0 +1,41 @@ +interface Props { + /** Mean uptime across the window, 0–1. */ + uptime: number; + sampleCount: number; + label: string; +} + +function colorFor(uptime: number): { fg: string; bg: string; border: string } { + if (uptime >= 0.95) return { fg: "var(--color-open-text)", bg: "var(--color-open-bg)", border: "var(--color-open-border)" }; + if (uptime >= 0.8) return { fg: "var(--color-closing-text)", bg: "var(--color-closing-bg)", border: "var(--color-closing-border)" }; + return { fg: "var(--color-accent)", bg: "var(--color-accent-muted)", border: "var(--color-accent)" }; +} + +export default function UptimePill({ uptime, sampleCount, label }: Props) { + const { fg, bg, border } = colorFor(uptime); + const pct = (uptime * 100).toFixed(uptime >= 0.999 ? 0 : 1); + + return ( +
+ + {label} + + + {pct}% + + + {sampleCount.toLocaleString()} sample{sampleCount === 1 ? "" : "s"} + +
+ ); +} diff --git a/components/charts/WaitTimeTodayChart.tsx b/components/charts/WaitTimeTodayChart.tsx new file mode 100644 index 0000000..53d0325 --- /dev/null +++ b/components/charts/WaitTimeTodayChart.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"; + +export interface TodaySample { + localTime: string; + isOpen: boolean; + waitMinutes: number | null; + fastLaneMinutes: number | null; +} + +interface Props { + samples: TodaySample[]; + hasFastLane: boolean; +} + +export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) { + // Map samples: closed periods → null so Recharts breaks the line. + const data = samples.map((s) => ({ + time: s.localTime, + wait: s.isOpen ? s.waitMinutes : null, + fl: s.isOpen ? s.fastLaneMinutes : null, + })); + + // Show every Nth tick on the X axis so labels don't overlap. + const tickInterval = Math.max(1, Math.floor(data.length / 8)); + + return ( +
+ + + + + + { + if (value === null || value === undefined) return ["—", name]; + return [`${value} min`, name]; + }} + /> + + + {hasFastLane && ( + + )} + + +
+ ); +} diff --git a/components/charts/WeeklyStatsChart.tsx b/components/charts/WeeklyStatsChart.tsx new file mode 100644 index 0000000..a4f62a1 --- /dev/null +++ b/components/charts/WeeklyStatsChart.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"; + +export interface DailyAggregate { + localDate: string; + avgWait: number | null; + maxWait: number | null; + avgFastLane: number | null; + maxFastLane: number | null; + uptimePct: number; + sampleCount: number; +} + +interface Props { + data: DailyAggregate[]; + hasFastLane: boolean; + mode: "regular" | "fastLane"; +} + +function shortDay(localDate: string): string { + // "2026-05-29" → "May 29" + const [, m, d] = localDate.split("-"); + const month = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(m, 10)]; + return `${month} ${parseInt(d, 10)}`; +} + +export default function WeeklyStatsChart({ data, hasFastLane, mode }: Props) { + const showFastLane = mode === "fastLane" && hasFastLane; + const chartData = data.map((d) => ({ + day: shortDay(d.localDate), + avg: showFastLane + ? (d.avgFastLane !== null ? Math.round(d.avgFastLane) : null) + : (d.avgWait !== null ? Math.round(d.avgWait) : null), + max: showFastLane ? d.maxFastLane : d.maxWait, + })); + + return ( +
+ + + + + + { + if (value === null || value === undefined) return ["—", name]; + return [`${value} min`, name]; + }} + /> + + + + + +
+ ); +} diff --git a/lib/ride-slug.ts b/lib/ride-slug.ts new file mode 100644 index 0000000..0a76aa6 --- /dev/null +++ b/lib/ride-slug.ts @@ -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, ""); +} diff --git a/lib/scrapers/queuetimes.ts b/lib/scrapers/queuetimes.ts index cd93af6..840899d 100644 --- a/lib/scrapers/queuetimes.ts +++ b/lib/scrapers/queuetimes.ts @@ -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, diff --git a/lib/timezone.ts b/lib/timezone.ts new file mode 100644 index 0000000..34ed446 --- /dev/null +++ b/lib/timezone.ts @@ -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}`; +} diff --git a/package-lock.json b/package-lock.json index 6614e2e..5c4cae8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "^15.3.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "recharts": "^3.8.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1489,6 +1490,42 @@ "node": ">=12.4.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1503,6 +1540,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1806,6 +1855,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1841,7 +1953,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1857,6 +1969,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2852,6 +2970,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2898,9 +3025,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2980,6 +3228,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3260,6 +3514,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3722,6 +3986,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4151,6 +4421,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4193,6 +4473,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5651,9 +5940,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5698,6 +6054,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -6280,6 +6642,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6584,6 +6952,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2e56434..cbc916f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dependencies": { "next": "^15.3.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "recharts": "^3.8.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/tests/ride-slug.test.ts b/tests/ride-slug.test.ts new file mode 100644 index 0000000..f62d708 --- /dev/null +++ b/tests/ride-slug.test.ts @@ -0,0 +1,45 @@ +/** + * Slug determinism tests. The slug is a URL-safe secondary key on the + * `rides` table — same name must always produce the same slug. + * + * Run with: npm test + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { slugifyRideName } from "../lib/ride-slug"; + +const CASES: [name: string, expected: string][] = [ + ["Goliath", "goliath"], + ["X²", "x"], + ["Lex Luthor: Drop of Doom", "lex-luthor-drop-of-doom"], + ["Catwoman's Whip", "catwoman-s-whip"], + ["Façade", "facade"], + ["Le Monstre", "le-monstre"], + ["Batman™ The Ride", "batman-the-ride"], + ["THE RIDDLER's Revenge", "the-riddler-s-revenge"], + ["Joker y Harley Quinn", "joker-y-harley-quinn"], + ["Apocalypse the Ride", "apocalypse-the-ride"], + [" Leading and trailing ", "leading-and-trailing"], + ["123 Numeric", "123-numeric"], + ["!!!", ""], +]; + +for (const [name, expected] of CASES) { + test(`slugify "${name}" → "${expected}"`, () => { + assert.equal(slugifyRideName(name), expected); + }); +} + +test("slug is idempotent — slugifying the result yields the same value", () => { + for (const [name] of CASES) { + const once = slugifyRideName(name); + if (once === "") continue; + assert.equal(slugifyRideName(once), once, `Expected idempotent slug for "${name}"`); + } +}); + +test("same name always produces the same slug", () => { + const name = "Twisted Cyclone"; + assert.equal(slugifyRideName(name), slugifyRideName(name)); +}); diff --git a/tests/timezone-bucketing.test.ts b/tests/timezone-bucketing.test.ts new file mode 100644 index 0000000..5fdde2a --- /dev/null +++ b/tests/timezone-bucketing.test.ts @@ -0,0 +1,110 @@ +/** + * Timezone bucketing tests for ride wait samples. + * + * Samples are stored with UTC `recorded_at` and pre-bucketed `local_date` + * + `local_time` columns in the park's IANA timezone. These columns are what + * the aggregation queries group on, so the bucketing has to be DST-safe + * across spring forward and fall back. + * + * Run with: npm test + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatLocalDate, formatLocalTime } from "../lib/timezone"; + +// ── Basic cases ────────────────────────────────────────────────────────────── + +test("formatLocalDate produces YYYY-MM-DD in the target zone", () => { + // 2026-05-29 17:00 UTC = 2026-05-29 13:00 ET = 2026-05-29 10:00 PT + const d = new Date("2026-05-29T17:00:00Z"); + assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-29"); + assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29"); +}); + +test("formatLocalDate rolls to previous day for late UTC times in west zones", () => { + // 2026-05-30 04:00 UTC = 2026-05-29 21:00 PT + const d = new Date("2026-05-30T04:00:00Z"); + assert.equal(formatLocalDate(d, "America/Los_Angeles"), "2026-05-29"); + assert.equal(formatLocalDate(d, "America/New_York"), "2026-05-30"); +}); + +test("formatLocalTime produces HH:MM in 24-hour format", () => { + // 2026-05-29 23:30 UTC = 2026-05-29 19:30 ET = 2026-05-29 16:30 PT + const d = new Date("2026-05-29T23:30:00Z"); + assert.equal(formatLocalTime(d, "America/New_York"), "19:30"); + assert.equal(formatLocalTime(d, "America/Los_Angeles"), "16:30"); +}); + +// ── DST: spring forward (2026-03-08 in US: 2 AM → 3 AM) ────────────────────── + +test("spring forward: time before transition shows in standard offset", () => { + // 2026-03-08 07:30 UTC = 2026-03-08 02:30 EST (before transition completes) + // Actually: at 2026-03-08 07:00 UTC = 2026-03-08 03:00 EDT (after transition) + // Use a clearly-before time: + const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST + assert.equal(formatLocalDate(before, "America/New_York"), "2026-03-08"); + assert.equal(formatLocalTime(before, "America/New_York"), "01:30"); +}); + +test("spring forward: time after transition shows in DST offset", () => { + // 2026-03-08 07:30 UTC = 2026-03-08 03:30 EDT (DST in effect) + const after = new Date("2026-03-08T07:30:00Z"); + assert.equal(formatLocalDate(after, "America/New_York"), "2026-03-08"); + assert.equal(formatLocalTime(after, "America/New_York"), "03:30"); +}); + +test("spring forward: local_date is consistent across the missing hour", () => { + // The skipped hour is 02:00–03:00 EST. Samples bracketing it should still + // bucket to the same local_date. + const before = new Date("2026-03-08T06:30:00Z"); // 01:30 EST + const after = new Date("2026-03-08T07:30:00Z"); // 03:30 EDT + assert.equal(formatLocalDate(before, "America/New_York"), formatLocalDate(after, "America/New_York")); +}); + +// ── DST: fall back (2026-11-01 in US: 2 AM → 1 AM) ──────────────────────────── + +test("fall back: time before transition shows in DST offset", () => { + // 2026-11-01 05:30 UTC = 2026-11-01 01:30 EDT (before fall-back at 2 AM EDT) + const beforeFallback = new Date("2026-11-01T05:30:00Z"); + assert.equal(formatLocalDate(beforeFallback, "America/New_York"), "2026-11-01"); + assert.equal(formatLocalTime(beforeFallback, "America/New_York"), "01:30"); +}); + +test("fall back: time after transition shows in standard offset", () => { + // 2026-11-01 07:30 UTC = 2026-11-01 02:30 EST (after fall-back) + const afterFallback = new Date("2026-11-01T07:30:00Z"); + assert.equal(formatLocalDate(afterFallback, "America/New_York"), "2026-11-01"); + assert.equal(formatLocalTime(afterFallback, "America/New_York"), "02:30"); +}); + +test("fall back: the same local hour repeats but local_date stays stable", () => { + // 2026-11-01 05:30 UTC = 01:30 EDT + // 2026-11-01 06:30 UTC = 01:30 EST (second occurrence of 01:30 — fall back) + const first = new Date("2026-11-01T05:30:00Z"); + const second = new Date("2026-11-01T06:30:00Z"); + assert.equal(formatLocalTime(first, "America/New_York"), "01:30"); + assert.equal(formatLocalTime(second, "America/New_York"), "01:30"); + assert.equal(formatLocalDate(first, "America/New_York"), formatLocalDate(second, "America/New_York")); +}); + +// ── Cross-zone: a single UTC moment buckets differently per park ───────────── + +test("midnight UTC straddles the local-date boundary for west-coast parks", () => { + const utcMidnight = new Date("2026-06-15T00:00:00Z"); + // Eastern: still 2026-06-14 20:00 + assert.equal(formatLocalDate(utcMidnight, "America/New_York"), "2026-06-14"); + // Pacific: 2026-06-14 17:00 + assert.equal(formatLocalDate(utcMidnight, "America/Los_Angeles"), "2026-06-14"); +}); + +test("Mountain and Central parks bucket distinctly during the late-evening hour", () => { + // 2026-07-04 04:30 UTC + // = 2026-07-03 21:30 MDT (UTC-6) → date 2026-07-03 + // = 2026-07-03 23:30 CDT (UTC-5) → date 2026-07-03 + const d = new Date("2026-07-04T04:30:00Z"); + assert.equal(formatLocalDate(d, "America/Denver"), "2026-07-03"); + assert.equal(formatLocalDate(d, "America/Chicago"), "2026-07-03"); + assert.equal(formatLocalTime(d, "America/Denver"), "22:30"); + assert.equal(formatLocalTime(d, "America/Chicago"), "23:30"); +});