feat: add per-ride history charts with wait time and uptime tracking
Build and Deploy / Build & Push (push) Successful in 3m7s
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>
This commit is contained in:
@@ -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) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
{closedRides.map((ride) => <RideRow key={ride.name} ride={ride} fastLaneMode={fastLaneMode} />)}
|
||||
{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({ 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 (
|
||||
<div style={{
|
||||
<Link href={`/park/${parkId}/ride/${slug}`} className="ride-row-link" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -207,6 +211,9 @@ function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean
|
||||
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={{
|
||||
@@ -277,6 +284,6 @@ function RideRow({ ride, fastLaneMode }: { ride: LiveRide; fastLaneMode: boolean
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
padding: "12px 16px",
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 8,
|
||||
minWidth: 140,
|
||||
}}>
|
||||
<span style={{ fontSize: "0.65rem", textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: "1.5rem", fontWeight: 700, color: fg, lineHeight: 1 }}>
|
||||
{pct}%
|
||||
</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "var(--color-text-dim)" }}>
|
||||
{sampleCount.toLocaleString()} sample{sampleCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ width: "100%", height: 280 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#737373"
|
||||
tick={{ fontSize: 11, fill: "#737373" }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#737373"
|
||||
tick={{ fontSize: 11, fill: "#737373" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#1c1c1c",
|
||||
border: "1px solid #333",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.75rem",
|
||||
color: "#f5f5f5",
|
||||
}}
|
||||
labelStyle={{ color: "#b0b0b0" }}
|
||||
formatter={(value, name) => {
|
||||
if (value === null || value === undefined) return ["—", name];
|
||||
return [`${value} min`, name];
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
||||
<Line
|
||||
name="Wait"
|
||||
type="monotone"
|
||||
dataKey="wait"
|
||||
stroke="#4ade80"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{hasFastLane && (
|
||||
<Line
|
||||
name="Fast Lane"
|
||||
type="monotone"
|
||||
dataKey="fl"
|
||||
stroke="#ff4d8d"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ width: "100%", height: 240 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="day" stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} />
|
||||
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#1c1c1c",
|
||||
border: "1px solid #333",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.75rem",
|
||||
color: "#f5f5f5",
|
||||
}}
|
||||
labelStyle={{ color: "#b0b0b0" }}
|
||||
formatter={(value, name) => {
|
||||
if (value === null || value === undefined) return ["—", name];
|
||||
return [`${value} min`, name];
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
||||
<Bar name="Avg" dataKey="avg" fill={showFastLane ? "#ff4d8d" : "#4ade80"} radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
<Bar name="Max" dataKey="max" fill={showFastLane ? "#3d0f22" : "#0a1a0d"} stroke={showFastLane ? "#ff4d8d" : "#22c55e"} strokeWidth={1} radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user