4f838d99c1
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>
290 lines
9.7 KiB
TypeScript
290 lines
9.7 KiB
TypeScript
"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({ parkId, liveRides, parkOpenToday, isWeatherDelay }: LiveRidePanelProps) {
|
|
const { rides } = liveRides;
|
|
const hasCoasters = rides.some((r) => r.isCoaster);
|
|
const hasFastLane = rides.some((r) => r.hasFastLane);
|
|
const [coastersOnly, setCoastersOnly] = useState(false);
|
|
const [fastLaneMode, setFastLaneMode] = useState(false);
|
|
|
|
// Pre-select coaster filter if Coaster Mode is enabled on the homepage.
|
|
useEffect(() => {
|
|
if (hasCoasters && localStorage.getItem("coasterMode") === "true") {
|
|
setCoastersOnly(true);
|
|
}
|
|
}, [hasCoasters]);
|
|
|
|
// Restore Fast Lane mode from a previous visit.
|
|
useEffect(() => {
|
|
if (hasFastLane && localStorage.getItem("fastLaneMode") === "true") {
|
|
setFastLaneMode(true);
|
|
}
|
|
}, [hasFastLane]);
|
|
|
|
const toggleFastLane = () => {
|
|
setFastLaneMode((v) => {
|
|
const next = !v;
|
|
localStorage.setItem("fastLaneMode", String(next));
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const visible = coastersOnly ? rides.filter((r) => r.isCoaster) : rides;
|
|
const openRides = visible.filter((r) => r.isOpen);
|
|
const closedRides = visible.filter((r) => !r.isOpen);
|
|
const anyOpen = openRides.length > 0;
|
|
|
|
return (
|
|
<div>
|
|
{/* ── Toolbar: summary + coaster toggle ────────────────────────────── */}
|
|
<div style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
marginBottom: 16,
|
|
flexWrap: "wrap",
|
|
}}>
|
|
{/* Open count badge */}
|
|
{anyOpen ? (
|
|
<div style={{
|
|
background: "var(--color-open-bg)",
|
|
border: "1px solid var(--color-open-border)",
|
|
borderRadius: 20,
|
|
padding: "4px 12px",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
color: "var(--color-open-hours)",
|
|
flexShrink: 0,
|
|
}}>
|
|
{openRides.length} open
|
|
</div>
|
|
) : isWeatherDelay ? (
|
|
<div style={{
|
|
background: "var(--color-weather-bg)",
|
|
border: "1px solid var(--color-weather-border)",
|
|
borderRadius: 20,
|
|
padding: "4px 12px",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
color: "var(--color-weather-text)",
|
|
flexShrink: 0,
|
|
}}>
|
|
⛈ Weather Delay — all rides currently closed
|
|
</div>
|
|
) : (
|
|
<div style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: 20,
|
|
padding: "4px 12px",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 500,
|
|
color: "var(--color-text-muted)",
|
|
flexShrink: 0,
|
|
}}>
|
|
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
|
|
</div>
|
|
)}
|
|
|
|
{/* Closed count badge — always shown when there are closed rides */}
|
|
{closedRides.length > 0 && (
|
|
<div style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: 20,
|
|
padding: "4px 12px",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 500,
|
|
color: "var(--color-text-muted)",
|
|
flexShrink: 0,
|
|
}}>
|
|
{closedRides.length} {anyOpen ? "closed / down" : "rides total"}
|
|
</div>
|
|
)}
|
|
|
|
{/* Toggle group — pushed to the right */}
|
|
{(hasCoasters || hasFastLane) && (
|
|
<div style={{ marginLeft: "auto", display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
{/* Fast Lane toggle — swaps shown waits to Fast Lane numbers */}
|
|
{hasFastLane && (
|
|
<button
|
|
onClick={toggleFastLane}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
padding: "4px 12px",
|
|
borderRadius: 20,
|
|
border: fastLaneMode
|
|
? "1px solid var(--color-accent)"
|
|
: "1px solid var(--color-border)",
|
|
background: fastLaneMode
|
|
? "var(--color-accent-muted)"
|
|
: "var(--color-surface)",
|
|
color: fastLaneMode
|
|
? "var(--color-accent)"
|
|
: "var(--color-text-muted)",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
⚡ Fast Lane
|
|
</button>
|
|
)}
|
|
|
|
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
|
{hasCoasters && (
|
|
<button
|
|
onClick={() => setCoastersOnly((v) => !v)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
padding: "4px 12px",
|
|
borderRadius: 20,
|
|
border: coastersOnly
|
|
? "1px solid var(--color-accent)"
|
|
: "1px solid var(--color-border)",
|
|
background: coastersOnly
|
|
? "var(--color-accent-muted)"
|
|
: "var(--color-surface)",
|
|
color: coastersOnly
|
|
? "var(--color-accent)"
|
|
: "var(--color-text-muted)",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
transition: "background 150ms ease, border-color 150ms ease, color 150ms ease",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
🎢 Coasters only
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Ride grid ────────────────────────────────────────────────────── */}
|
|
<div style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
gap: 6,
|
|
}}>
|
|
{openRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
|
|
{closedRides.map((ride) => <RideRow key={ride.name} parkId={parkId} ride={ride} fastLaneMode={fastLaneMode} />)}
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 10,
|
|
padding: "8px 12px",
|
|
background: "var(--color-surface)",
|
|
border: `1px solid ${ride.isOpen ? "var(--color-open-border)" : "var(--color-border)"}`,
|
|
borderRadius: 8,
|
|
opacity: ride.isOpen ? 1 : 0.6,
|
|
textDecoration: "none",
|
|
color: "inherit",
|
|
cursor: "pointer",
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
|
<span style={{
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
background: ride.isOpen ? "var(--color-open-text)" : "var(--color-text-dim)",
|
|
flexShrink: 0,
|
|
}} />
|
|
<span title={ride.name} style={{
|
|
fontSize: "0.8rem",
|
|
color: ride.isOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
|
fontWeight: ride.isOpen ? 500 : 400,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{ride.name}
|
|
</span>
|
|
</div>
|
|
{/* Fast Lane mode: swap the shown wait to the Fast Lane number */}
|
|
{fastLaneMode ? (
|
|
ride.isOpen && fastLaneActive ? (
|
|
<span style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--color-accent)",
|
|
fontWeight: 600,
|
|
flexShrink: 0,
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{flWait > 0 ? `⚡ ${flWait} min` : "⚡ walk-on"}
|
|
</span>
|
|
) : ride.isOpen ? (
|
|
<span style={{
|
|
fontSize: "0.68rem",
|
|
color: "var(--color-text-muted)",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
opacity: 0.7,
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
no Fast Lane
|
|
</span>
|
|
) : null
|
|
) : (
|
|
<>
|
|
{showWait && (
|
|
<span style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--color-open-hours)",
|
|
fontWeight: 600,
|
|
flexShrink: 0,
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{ride.waitMinutes} min
|
|
</span>
|
|
)}
|
|
{ride.isOpen && !showWait && (
|
|
<span style={{
|
|
fontSize: "0.68rem",
|
|
color: "var(--color-open-text)",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
opacity: 0.7,
|
|
}}>
|
|
walk-on
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|