feat: prefer Six Flags regular waits, show Fast Lane at 0, mark outages on chart
Three usability fixes after a day of using the ride detail page. 1. Six Flags is now the primary source for regular wait times. SF's /wait-times endpoint reports regular waits alongside Fast Lane, and it updates more promptly than Queue-Times around park-open. The sampler and the live /rides + ride-history routes all prefer SF's regularWaittime when its createdDateTime is non-empty; Queue-Times remains the fallback and the authoritative isOpen source. 2. The today chart's Fast Lane line now stays visible when its value is 0 (walk-on). Y-axis bottom padding ensures the line sits clearly above the X-axis frame instead of being clipped against it. The tooltip shows "walk-on" instead of "0 min" for that case. 3. Outages are now explicit on the chart instead of just being gaps. computeOutages walks today's samples to find contiguous closed runs and numbers them chronologically. Each outage renders as a translucent pink ReferenceArea with a "#N" label. The custom tooltip detects when the cursor is over an outage span and shows "Outage #N — Hh Mm" (e.g. "Outage #2 — 1h 28m") in place of the wait/Fast Lane rows. Includes a seed-test-samples.ts dev script for eyeballing the chart with synthetic outage data. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceArea,
|
||||
} from "recharts";
|
||||
import { computeOutages, outageLookup, formatOutageDuration } from "@/lib/outage";
|
||||
|
||||
export interface TodaySample {
|
||||
recordedAt: string;
|
||||
@@ -22,16 +33,25 @@ const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||
});
|
||||
|
||||
export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
||||
// Map samples: closed periods → null so Recharts breaks the line.
|
||||
const outages = computeOutages(samples);
|
||||
const outageByTime = outageLookup(samples, outages);
|
||||
|
||||
// X-axis time is rendered in the viewer's local timezone (Intl with no
|
||||
// tz arg) so an Eastern-time user sees ET regardless of which park.
|
||||
const data = samples.map((s) => ({
|
||||
time: TIME_FMT.format(new Date(s.recordedAt)),
|
||||
wait: s.isOpen ? s.waitMinutes : null,
|
||||
fl: s.isOpen ? s.fastLaneMinutes : null,
|
||||
}));
|
||||
// Each point also carries its outage (if any) so the custom tooltip can
|
||||
// render "Outage #N — Hh Mm" without re-scanning.
|
||||
const data = samples.map((s) => {
|
||||
const time = TIME_FMT.format(new Date(s.recordedAt));
|
||||
const outage = !s.isOpen ? outageByTime.get(time) ?? null : null;
|
||||
return {
|
||||
time,
|
||||
wait: s.isOpen ? s.waitMinutes : null,
|
||||
fl: s.isOpen ? s.fastLaneMinutes : null,
|
||||
outageN: outage?.n ?? null,
|
||||
outageDurationMin: outage?.durationMin ?? 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 (
|
||||
@@ -51,22 +71,32 @@ export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
||||
tick={{ fontSize: 11, fill: "#737373" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, "auto"]}
|
||||
padding={{ bottom: 6 }}
|
||||
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];
|
||||
}}
|
||||
/>
|
||||
{/* Outage markers — render before the lines so they sit underneath. */}
|
||||
{outages.map((o) => (
|
||||
<ReferenceArea
|
||||
key={`outage-${o.n}`}
|
||||
x1={o.startTimeLabel}
|
||||
x2={o.endTimeLabel}
|
||||
fill="#ff4d8d"
|
||||
fillOpacity={0.08}
|
||||
stroke="#ff4d8d"
|
||||
strokeOpacity={0.35}
|
||||
strokeDasharray="3 3"
|
||||
ifOverflow="extendDomain"
|
||||
label={{
|
||||
value: `#${o.n}`,
|
||||
position: "insideTop",
|
||||
fill: "#ff4d8d",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Tooltip content={<RideTooltip hasFastLane={hasFastLane} />} />
|
||||
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
||||
<Line
|
||||
name="Wait"
|
||||
@@ -95,3 +125,78 @@ export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom tooltip ─────────────────────────────────────────────────────────
|
||||
// When hovering on an outage span, replace the standard wait/Fast Lane rows
|
||||
// with a single "Outage #N — duration" line. Otherwise show the per-series
|
||||
// values, formatting 0 explicitly as "walk-on".
|
||||
|
||||
interface TooltipDatum {
|
||||
outageN: number | null;
|
||||
outageDurationMin: number | null;
|
||||
}
|
||||
|
||||
interface TooltipEntry {
|
||||
value?: number | null;
|
||||
name?: string | number;
|
||||
dataKey?: string | number;
|
||||
color?: string;
|
||||
payload?: TooltipDatum;
|
||||
}
|
||||
|
||||
interface RideTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string | number;
|
||||
hasFastLane: boolean;
|
||||
}
|
||||
|
||||
function RideTooltip({ active, payload, label, hasFastLane }: RideTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const datum = payload[0]?.payload;
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "#1c1c1c",
|
||||
border: "1px solid #333",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.75rem",
|
||||
color: "#f5f5f5",
|
||||
padding: "8px 10px",
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
if (datum?.outageN && datum.outageDurationMin !== null) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ color: "#ff4d8d", fontWeight: 600 }}>
|
||||
Outage #{datum.outageN} — {formatOutageDuration(datum.outageDurationMin)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 4 }}>{label}</div>
|
||||
{payload.map((entry, i) => {
|
||||
const v = entry.value;
|
||||
const display =
|
||||
v === null || v === undefined
|
||||
? "—"
|
||||
: v === 0
|
||||
? "walk-on"
|
||||
: `${v} min`;
|
||||
const dotColor = entry.color ?? "#fff";
|
||||
return (
|
||||
<div key={String(entry.dataKey ?? i)} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ width: 8, height: 2, background: dotColor, borderRadius: 1 }} />
|
||||
<span style={{ color: "#b0b0b0", flex: 1 }}>{entry.name}</span>
|
||||
<span style={{ fontWeight: 600 }}>{display}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasFastLane && null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user