feat: coaster-only toggle on live ride status panel
Queue-Times groups rides into lands (e.g. "Coasters", "Family", "Kids").
Capture that categorisation in LiveRide.isCoaster and surface it as a
toggle in the new LiveRidePanel client component.
- lib/scrapers/queuetimes.ts: add isCoaster: boolean to LiveRide,
derived from land.name.toLowerCase().includes("coaster")
- components/LiveRidePanel.tsx: client component replacing the old
inline LiveRideList; adds a "🎢 Coasters only" pill toggle that
filters the grid; toggle only appears when the park has coaster-
categorised rides; amber when active, muted when inactive
- app/park/[id]/page.tsx: swap LiveRideList for LiveRidePanel,
remove now-dead LiveRideList/LiveRideRow functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,9 @@ import { scrapeRidesForDay } from "@/lib/scrapers/sixflags";
|
|||||||
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
import { fetchLiveRides } from "@/lib/scrapers/queuetimes";
|
||||||
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
import { QUEUE_TIMES_IDS } from "@/lib/queue-times-map";
|
||||||
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
import { ParkMonthCalendar } from "@/components/ParkMonthCalendar";
|
||||||
|
import { LiveRidePanel } from "@/components/LiveRidePanel";
|
||||||
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
|
||||||
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; // used as prop type below
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -126,7 +127,7 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
|
|||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
|
|
||||||
{liveRides ? (
|
{liveRides ? (
|
||||||
<LiveRideList
|
<LiveRidePanel
|
||||||
liveRides={liveRides}
|
liveRides={liveRides}
|
||||||
parkOpenToday={!!parkOpenToday}
|
parkOpenToday={!!parkOpenToday}
|
||||||
/>
|
/>
|
||||||
@@ -207,158 +208,6 @@ function LiveBadge() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live ride list (Queue-Times data) ──────────────────────────────────────
|
|
||||||
|
|
||||||
function LiveRideList({
|
|
||||||
liveRides,
|
|
||||||
parkOpenToday,
|
|
||||||
}: {
|
|
||||||
liveRides: LiveRidesResult;
|
|
||||||
parkOpenToday: boolean;
|
|
||||||
}) {
|
|
||||||
const { rides } = liveRides;
|
|
||||||
const openRides = rides.filter((r) => r.isOpen);
|
|
||||||
const closedRides = rides.filter((r) => !r.isOpen);
|
|
||||||
const anyOpen = openRides.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Summary badge row */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
|
|
||||||
{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)",
|
|
||||||
}}>
|
|
||||||
{openRides.length} open
|
|
||||||
</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)",
|
|
||||||
}}>
|
|
||||||
{parkOpenToday ? "Not open yet — check back soon" : "No rides open"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{anyOpen && 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)",
|
|
||||||
}}>
|
|
||||||
{closedRides.length} closed / down
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-column grid */}
|
|
||||||
<div style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
||||||
gap: 6,
|
|
||||||
}}>
|
|
||||||
{openRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
|
|
||||||
{closedRides.map((ride) => <LiveRideRow key={ride.name} ride={ride} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attribution — required by Queue-Times terms */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 20,
|
|
||||||
fontSize: "0.68rem",
|
|
||||||
color: "var(--color-text-dim)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}>
|
|
||||||
Powered by{" "}
|
|
||||||
<a
|
|
||||||
href="https://queue-times.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: "var(--color-text-muted)", textDecoration: "underline" }}
|
|
||||||
>
|
|
||||||
Queue-Times.com
|
|
||||||
</a>
|
|
||||||
{" "}· Updates every 5 minutes
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LiveRideRow({ ride }: { ride: LiveRide }) {
|
|
||||||
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div 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,
|
|
||||||
}}>
|
|
||||||
<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 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>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
|
// ── Schedule ride list (Six Flags operating-hours API fallback) ────────────
|
||||||
|
|
||||||
function RideList({
|
function RideList({
|
||||||
|
|||||||
200
components/LiveRidePanel.tsx
Normal file
200
components/LiveRidePanel.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { LiveRidesResult, LiveRide } from "@/lib/scrapers/queuetimes";
|
||||||
|
|
||||||
|
interface LiveRidePanelProps {
|
||||||
|
liveRides: LiveRidesResult;
|
||||||
|
parkOpenToday: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveRidePanel({ liveRides, parkOpenToday }: LiveRidePanelProps) {
|
||||||
|
const { rides } = liveRides;
|
||||||
|
const hasCoasters = rides.some((r) => r.isCoaster);
|
||||||
|
const [coastersOnly, setCoastersOnly] = useState(false);
|
||||||
|
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<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 */}
|
||||||
|
{anyOpen && 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} closed / down
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coaster toggle — only shown when the park has categorised coasters */}
|
||||||
|
{hasCoasters && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCoastersOnly((v) => !v)}
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* ── Ride grid ────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
|
gap: 6,
|
||||||
|
}}>
|
||||||
|
{openRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
|
||||||
|
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Attribution ──────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20,
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
color: "var(--color-text-dim)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}>
|
||||||
|
Powered by{" "}
|
||||||
|
<a
|
||||||
|
href="https://queue-times.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "var(--color-text-muted)", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
Queue-Times.com
|
||||||
|
</a>
|
||||||
|
{" "}· Updates every 5 minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RideRow({ ride }: { ride: LiveRide }) {
|
||||||
|
const showWait = ride.isOpen && ride.waitMinutes > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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,
|
||||||
|
}}>
|
||||||
|
<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 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>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export interface LiveRide {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
waitMinutes: number;
|
waitMinutes: number;
|
||||||
lastUpdated: string; // ISO 8601
|
lastUpdated: string; // ISO 8601
|
||||||
|
/** True when Queue-Times placed this ride in a "Coasters" land category. */
|
||||||
|
isCoaster: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRidesResult {
|
export interface LiveRidesResult {
|
||||||
@@ -76,8 +78,10 @@ export async function fetchLiveRides(
|
|||||||
|
|
||||||
const rides: LiveRide[] = [];
|
const rides: LiveRide[] = [];
|
||||||
|
|
||||||
// Rides are nested inside lands
|
// Rides are nested inside lands. Queue-Times labels coaster sections
|
||||||
|
// with names like "Coasters", "Steel Coasters", "Wooden Coasters", etc.
|
||||||
for (const land of json.lands ?? []) {
|
for (const land of json.lands ?? []) {
|
||||||
|
const isCoaster = land.name.toLowerCase().includes("coaster");
|
||||||
for (const r of land.rides ?? []) {
|
for (const r of land.rides ?? []) {
|
||||||
if (!r.name) continue;
|
if (!r.name) continue;
|
||||||
rides.push({
|
rides.push({
|
||||||
@@ -85,6 +89,7 @@ export async function fetchLiveRides(
|
|||||||
isOpen: r.is_open,
|
isOpen: r.is_open,
|
||||||
waitMinutes: r.wait_time ?? 0,
|
waitMinutes: r.wait_time ?? 0,
|
||||||
lastUpdated: r.last_updated,
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +102,7 @@ export async function fetchLiveRides(
|
|||||||
isOpen: r.is_open,
|
isOpen: r.is_open,
|
||||||
waitMinutes: r.wait_time ?? 0,
|
waitMinutes: r.wait_time ?? 0,
|
||||||
lastUpdated: r.last_updated,
|
lastUpdated: r.last_updated,
|
||||||
|
isCoaster: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user