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:
2026-04-04 12:56:20 -04:00
parent da083c125c
commit 819e716197
3 changed files with 210 additions and 155 deletions

View File

@@ -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({

View 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>
);
}

View File

@@ -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,
}); });
} }