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:
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only: seed today's `ride_wait_samples` for one ride with a mix of
|
||||||
|
* open + outage data so the WaitTimeTodayChart renders something we can
|
||||||
|
* eyeball. Idempotent for a given ride: wipes then reinserts today's rows.
|
||||||
|
*/
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const PARK_ID = process.argv[2] ?? "greatamerica";
|
||||||
|
const RIDE_SLUG = process.argv[3] ?? "raging-bull";
|
||||||
|
|
||||||
|
const db = new Database(path.join(__dirname, "..", "data", "parks.db"));
|
||||||
|
const ride = db
|
||||||
|
.prepare("SELECT park_id, qt_ride_id, name FROM rides WHERE park_id = ? AND slug = ?")
|
||||||
|
.get(PARK_ID, RIDE_SLUG) as { park_id: string; qt_ride_id: number; name: string } | undefined;
|
||||||
|
|
||||||
|
if (!ride) {
|
||||||
|
console.error(`No ride found for ${PARK_ID}/${RIDE_SLUG} — trigger sampler first.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("Seeding for:", ride.name);
|
||||||
|
|
||||||
|
const TZ_OFFSET_HOURS = -5; // Central daylight = UTC-5 (May 30, 2026 is CDT)
|
||||||
|
const today = new Date();
|
||||||
|
const yyyy = today.getUTCFullYear();
|
||||||
|
const mm = String(today.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(today.getUTCDate()).padStart(2, "0");
|
||||||
|
const todayLocal = `${yyyy}-${mm}-${dd}`;
|
||||||
|
|
||||||
|
// Wipe today's rows for this ride.
|
||||||
|
db.prepare(
|
||||||
|
"DELETE FROM ride_wait_samples WHERE park_id = ? AND qt_ride_id = ? AND local_date = ?",
|
||||||
|
).run(ride.park_id, ride.qt_ride_id, todayLocal);
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO ride_wait_samples
|
||||||
|
(park_id, qt_ride_id, recorded_at, local_date, local_time, is_open, wait_minutes, fast_lane_minutes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build samples at 5-min cadence from 10:00 local through 18:00 local with
|
||||||
|
// two outages: 12:15-12:45 (#1, 30 min) and 15:30-17:00 (#2, 1h 30m).
|
||||||
|
const startLocalMin = 10 * 60;
|
||||||
|
const endLocalMin = 18 * 60;
|
||||||
|
const o1Start = 12 * 60 + 15;
|
||||||
|
const o1End = 12 * 60 + 45;
|
||||||
|
const o2Start = 15 * 60 + 30;
|
||||||
|
const o2End = 17 * 60;
|
||||||
|
|
||||||
|
const dateBase = new Date(`${todayLocal}T00:00:00Z`).getTime();
|
||||||
|
let count = 0;
|
||||||
|
for (let m = startLocalMin; m <= endLocalMin; m += 5) {
|
||||||
|
const isOpen = !((m >= o1Start && m < o1End) || (m >= o2Start && m < o2End));
|
||||||
|
const wait = isOpen ? 15 + Math.floor((m - startLocalMin) / 30) * 5 : null;
|
||||||
|
const fl = isOpen ? (m < 12 * 60 ? 0 : 5) : null; // walk-on morning, 5 min afternoon
|
||||||
|
const localTime = `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||||
|
const utcMin = m - TZ_OFFSET_HOURS * 60;
|
||||||
|
const recordedAt = new Date(dateBase + utcMin * 60_000).toISOString();
|
||||||
|
insert.run(
|
||||||
|
ride.park_id,
|
||||||
|
ride.qt_ride_id,
|
||||||
|
recordedAt,
|
||||||
|
todayLocal,
|
||||||
|
localTime,
|
||||||
|
isOpen ? 1 : 0,
|
||||||
|
wait,
|
||||||
|
fl,
|
||||||
|
);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(`Inserted ${count} samples for ${todayLocal}`);
|
||||||
|
db.close();
|
||||||
@@ -103,7 +103,10 @@ app.get("/:parkId/rides/:slug", (c) => {
|
|||||||
live: liveMatch
|
live: liveMatch
|
||||||
? {
|
? {
|
||||||
isOpen: liveIsOpen,
|
isOpen: liveIsOpen,
|
||||||
waitMinutes: liveIsOpen ? liveMatch.waitMinutes : 0,
|
// Prefer Six Flags' regular wait when fresh — QT lags around open.
|
||||||
|
waitMinutes: liveIsOpen
|
||||||
|
? (flMatch?.regularMinutes ?? liveMatch.waitMinutes)
|
||||||
|
: 0,
|
||||||
hasFastLane: Boolean(flMatch?.hasFastLane),
|
hasFastLane: Boolean(flMatch?.hasFastLane),
|
||||||
fastLaneMinutes: liveIsOpen ? (flMatch?.fastLaneMinutes ?? null) : null,
|
fastLaneMinutes: liveIsOpen ? (flMatch?.fastLaneMinutes ?? null) : null,
|
||||||
lastUpdated: liveMatch.lastUpdated,
|
lastUpdated: liveMatch.lastUpdated,
|
||||||
|
|||||||
@@ -63,8 +63,15 @@ app.get("/:id/rides", async (c) => {
|
|||||||
rides: liveRides.rides.map((r) => {
|
rides: liveRides.rides.map((r) => {
|
||||||
const match = lookupFastLane(r.name, fl);
|
const match = lookupFastLane(r.name, fl);
|
||||||
if (!match) return r;
|
if (!match) return r;
|
||||||
|
// Prefer Six Flags' regular wait when fresh — QT lags around
|
||||||
|
// park-open. Keep QT's value when SF has no regular data.
|
||||||
|
const waitMinutes =
|
||||||
|
r.isOpen && match.regularMinutes !== null
|
||||||
|
? match.regularMinutes
|
||||||
|
: r.waitMinutes;
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
waitMinutes,
|
||||||
hasFastLane: match.hasFastLane,
|
hasFastLane: match.hasFastLane,
|
||||||
fastLaneMinutes: r.isOpen ? match.fastLaneMinutes : null,
|
fastLaneMinutes: r.isOpen ? match.fastLaneMinutes : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,10 +92,15 @@ async function samplePark(park: Park, now: Date): Promise<{
|
|||||||
for (const ride of liveRides!.rides) {
|
for (const ride of liveRides!.rides) {
|
||||||
if (!ride.qtRideId) continue;
|
if (!ride.qtRideId) continue;
|
||||||
const slug = slugifyRideName(ride.name);
|
const slug = slugifyRideName(ride.name);
|
||||||
const flMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null;
|
const sfMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null;
|
||||||
const hasFastLane = Boolean(flMatch?.hasFastLane);
|
const hasFastLane = Boolean(sfMatch?.hasFastLane);
|
||||||
|
// Prefer Six Flags' regular wait when it's fresh — QT often lags
|
||||||
|
// around park-open. Fall back to QT's value when SF has no data.
|
||||||
|
const waitMinutes = ride.isOpen
|
||||||
|
? (sfMatch?.regularMinutes ?? ride.waitMinutes)
|
||||||
|
: null;
|
||||||
const fastLaneMinutes =
|
const fastLaneMinutes =
|
||||||
ride.isOpen && flMatch ? flMatch.fastLaneMinutes : null;
|
ride.isOpen && sfMatch ? sfMatch.fastLaneMinutes : null;
|
||||||
|
|
||||||
upsertRide(
|
upsertRide(
|
||||||
park.id,
|
park.id,
|
||||||
@@ -115,7 +120,7 @@ async function samplePark(park: Park, now: Date): Promise<{
|
|||||||
localDate,
|
localDate,
|
||||||
localTime,
|
localTime,
|
||||||
ride.isOpen,
|
ride.isOpen,
|
||||||
ride.isOpen ? ride.waitMinutes : null,
|
waitMinutes,
|
||||||
fastLaneMinutes,
|
fastLaneMinutes,
|
||||||
);
|
);
|
||||||
samplesWritten++;
|
samplesWritten++;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
"use client";
|
"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 {
|
export interface TodaySample {
|
||||||
recordedAt: string;
|
recordedAt: string;
|
||||||
@@ -22,16 +33,25 @@ const TIME_FMT = new Intl.DateTimeFormat([], {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
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
|
// 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.
|
// tz arg) so an Eastern-time user sees ET regardless of which park.
|
||||||
const data = samples.map((s) => ({
|
// Each point also carries its outage (if any) so the custom tooltip can
|
||||||
time: TIME_FMT.format(new Date(s.recordedAt)),
|
// render "Outage #N — Hh Mm" without re-scanning.
|
||||||
wait: s.isOpen ? s.waitMinutes : null,
|
const data = samples.map((s) => {
|
||||||
fl: s.isOpen ? s.fastLaneMinutes : null,
|
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));
|
const tickInterval = Math.max(1, Math.floor(data.length / 8));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,22 +71,32 @@ export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
|||||||
tick={{ fontSize: 11, fill: "#737373" }}
|
tick={{ fontSize: 11, fill: "#737373" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
padding={{ bottom: 6 }}
|
||||||
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
|
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
{/* Outage markers — render before the lines so they sit underneath. */}
|
||||||
contentStyle={{
|
{outages.map((o) => (
|
||||||
background: "#1c1c1c",
|
<ReferenceArea
|
||||||
border: "1px solid #333",
|
key={`outage-${o.n}`}
|
||||||
borderRadius: 6,
|
x1={o.startTimeLabel}
|
||||||
fontSize: "0.75rem",
|
x2={o.endTimeLabel}
|
||||||
color: "#f5f5f5",
|
fill="#ff4d8d"
|
||||||
}}
|
fillOpacity={0.08}
|
||||||
labelStyle={{ color: "#b0b0b0" }}
|
stroke="#ff4d8d"
|
||||||
formatter={(value, name) => {
|
strokeOpacity={0.35}
|
||||||
if (value === null || value === undefined) return ["—", name];
|
strokeDasharray="3 3"
|
||||||
return [`${value} min`, name];
|
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 }} />
|
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
||||||
<Line
|
<Line
|
||||||
name="Wait"
|
name="Wait"
|
||||||
@@ -95,3 +125,78 @@ export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Outage detection for the ride detail page's Today chart.
|
||||||
|
*
|
||||||
|
* An outage is a contiguous run of samples with `isOpen === false`. We number
|
||||||
|
* them chronologically (1-based) and report their duration so the chart can
|
||||||
|
* render `<ReferenceArea>` bands with hover tooltips like "Outage #4 — 1h 28m".
|
||||||
|
*
|
||||||
|
* Pure module — no DOM, no Recharts — so it's cheap to unit test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OutageSample {
|
||||||
|
recordedAt: string; // ISO 8601 UTC
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Outage {
|
||||||
|
n: number;
|
||||||
|
startISO: string;
|
||||||
|
endISO: string;
|
||||||
|
/** Formatted HH:MM in the viewer's local timezone — matches the chart X-axis labels. */
|
||||||
|
startTimeLabel: string;
|
||||||
|
endTimeLabel: string;
|
||||||
|
durationMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk samples in chronological order and produce one Outage per contiguous
|
||||||
|
* run of `!isOpen`. If the day ends mid-outage, the last sample becomes the
|
||||||
|
* outage's `endISO` (still in progress).
|
||||||
|
*/
|
||||||
|
export function computeOutages(samples: OutageSample[]): Outage[] {
|
||||||
|
const outages: Outage[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let n = 0;
|
||||||
|
while (i < samples.length) {
|
||||||
|
if (samples[i].isOpen) { i++; continue; }
|
||||||
|
const startSample = samples[i];
|
||||||
|
while (i < samples.length && !samples[i].isOpen) i++;
|
||||||
|
// i now points at the first open sample after the run, or samples.length.
|
||||||
|
const endSample = i < samples.length ? samples[i] : samples[samples.length - 1];
|
||||||
|
const startMs = Date.parse(startSample.recordedAt);
|
||||||
|
const endMs = Date.parse(endSample.recordedAt);
|
||||||
|
const durationMin = Math.max(0, Math.round((endMs - startMs) / 60_000));
|
||||||
|
n += 1;
|
||||||
|
outages.push({
|
||||||
|
n,
|
||||||
|
startISO: startSample.recordedAt,
|
||||||
|
endISO: endSample.recordedAt,
|
||||||
|
startTimeLabel: TIME_FMT.format(new Date(startSample.recordedAt)),
|
||||||
|
endTimeLabel: TIME_FMT.format(new Date(endSample.recordedAt)),
|
||||||
|
durationMin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return outages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a minute count as "47m" / "1h 28m" / "2h".
|
||||||
|
*/
|
||||||
|
export function formatOutageDuration(min: number): string {
|
||||||
|
if (min < 60) return `${min}m`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lookup from time-label (HH:MM) → outage for each closed sample.
|
||||||
|
* The chart uses this to attach outage metadata to each data point so the
|
||||||
|
* custom tooltip can render outage-aware text without re-scanning.
|
||||||
|
*/
|
||||||
|
export function outageLookup(
|
||||||
|
samples: OutageSample[],
|
||||||
|
outages: Outage[],
|
||||||
|
): Map<string, Outage> {
|
||||||
|
const byTime = new Map<string, Outage>();
|
||||||
|
let oIdx = 0;
|
||||||
|
let runStart: number | null = null;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const s = samples[i];
|
||||||
|
if (!s.isOpen) {
|
||||||
|
if (runStart === null) runStart = i;
|
||||||
|
const outage = outages[oIdx];
|
||||||
|
if (outage) byTime.set(TIME_FMT.format(new Date(s.recordedAt)), outage);
|
||||||
|
} else if (runStart !== null) {
|
||||||
|
runStart = null;
|
||||||
|
oIdx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byTime;
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ interface FastLaneEntry {
|
|||||||
norm: string;
|
norm: string;
|
||||||
compact: string;
|
compact: string;
|
||||||
isFastLane: boolean;
|
isFastLane: boolean;
|
||||||
|
/** Current regular wait in minutes from Six Flags; null when the endpoint has no data. */
|
||||||
|
regularMinutes: number | null;
|
||||||
/** Current Fast Lane wait in minutes; null when the endpoint has no data. */
|
/** Current Fast Lane wait in minutes; null when the endpoint has no data. */
|
||||||
fastLaneMinutes: number | null;
|
fastLaneMinutes: number | null;
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,9 @@ export function parseWaitTimes(json: WTResponse): FastLaneResult | null {
|
|||||||
norm,
|
norm,
|
||||||
compact: norm.replace(/\s/g, ""),
|
compact: norm.replace(/\s/g, ""),
|
||||||
isFastLane: Boolean(d.isFastLane),
|
isFastLane: Boolean(d.isFastLane),
|
||||||
|
regularMinutes: d.regularWaittime?.createdDateTime
|
||||||
|
? d.regularWaittime.waitTime
|
||||||
|
: null,
|
||||||
fastLaneMinutes: d.fastlaneWaittime?.createdDateTime
|
fastLaneMinutes: d.fastlaneWaittime?.createdDateTime
|
||||||
? d.fastlaneWaittime.waitTime
|
? d.fastlaneWaittime.waitTime
|
||||||
: null,
|
: null,
|
||||||
@@ -123,16 +128,18 @@ export async function fetchFastLaneWaits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the Fast Lane data for a ride by name. Mirrors the isCoasterMatch
|
* Find the Six Flags wait-times row for a ride by name. Mirrors the
|
||||||
* strategy (exact normalized → compact ≥5 → prefix ≥5 with conjunction guard)
|
* isCoasterMatch strategy (exact normalized → compact ≥5 → prefix ≥5 with
|
||||||
* so Queue-Times and Six Flags name conventions line up.
|
* conjunction guard) so Queue-Times and Six Flags name conventions line up.
|
||||||
*
|
*
|
||||||
* Returns the matched ride's Fast Lane info, or null when no ride matches.
|
* Returns both the regular and Fast Lane wait when a match exists, or null
|
||||||
|
* when no ride matches. (Function name is historical — it originally only
|
||||||
|
* exposed Fast Lane data.)
|
||||||
*/
|
*/
|
||||||
export function lookupFastLane(
|
export function lookupFastLane(
|
||||||
rideName: string,
|
rideName: string,
|
||||||
result: FastLaneResult,
|
result: FastLaneResult,
|
||||||
): { hasFastLane: boolean; fastLaneMinutes: number | null } | null {
|
): { hasFastLane: boolean; fastLaneMinutes: number | null; regularMinutes: number | null } | null {
|
||||||
const norm = normalizeForMatch(rideName);
|
const norm = normalizeForMatch(rideName);
|
||||||
const compact = norm.replace(/\s/g, "");
|
const compact = norm.replace(/\s/g, "");
|
||||||
|
|
||||||
@@ -159,5 +166,9 @@ export function lookupFastLane(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return { hasFastLane: match.isFastLane, fastLaneMinutes: match.fastLaneMinutes };
|
return {
|
||||||
|
hasFastLane: match.isFastLane,
|
||||||
|
fastLaneMinutes: match.fastLaneMinutes,
|
||||||
|
regularMinutes: match.regularMinutes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ function ride(
|
|||||||
name: string,
|
name: string,
|
||||||
isFastLane: boolean,
|
isFastLane: boolean,
|
||||||
fastLaneMinutes: number | null,
|
fastLaneMinutes: number | null,
|
||||||
|
regularMinutes: number | null = 20,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
name,
|
name,
|
||||||
isFastLane,
|
isFastLane,
|
||||||
regularWaittime: { createdDateTime: "May 29, 2026 19:00:00", waitTime: 20 },
|
regularWaittime:
|
||||||
|
regularMinutes === null
|
||||||
|
? { createdDateTime: "", waitTime: 0 }
|
||||||
|
: { createdDateTime: "May 29, 2026 19:00:00", waitTime: regularMinutes },
|
||||||
fastlaneWaittime:
|
fastlaneWaittime:
|
||||||
fastLaneMinutes === null
|
fastLaneMinutes === null
|
||||||
? { createdDateTime: "", waitTime: 0 }
|
? { createdDateTime: "", waitTime: 0 }
|
||||||
@@ -54,15 +58,18 @@ test("matches across trademark symbols, THE prefix, possessives", () => {
|
|||||||
assert.deepEqual(lookupFastLane("BATMAN™ The Ride", r), {
|
assert.deepEqual(lookupFastLane("BATMAN™ The Ride", r), {
|
||||||
hasFastLane: true,
|
hasFastLane: true,
|
||||||
fastLaneMinutes: 5,
|
fastLaneMinutes: 5,
|
||||||
|
regularMinutes: 20,
|
||||||
});
|
});
|
||||||
assert.deepEqual(lookupFastLane("THE RIDDLER™'s Revenge", r), {
|
assert.deepEqual(lookupFastLane("THE RIDDLER™'s Revenge", r), {
|
||||||
hasFastLane: true,
|
hasFastLane: true,
|
||||||
fastLaneMinutes: 10,
|
fastLaneMinutes: 10,
|
||||||
|
regularMinutes: 20,
|
||||||
});
|
});
|
||||||
// Prefix match: "Apocalypse" is a prefix of "Apocalypse the Ride".
|
// Prefix match: "Apocalypse" is a prefix of "Apocalypse the Ride".
|
||||||
assert.deepEqual(lookupFastLane("Apocalypse", r), {
|
assert.deepEqual(lookupFastLane("Apocalypse", r), {
|
||||||
hasFastLane: true,
|
hasFastLane: true,
|
||||||
fastLaneMinutes: 15,
|
fastLaneMinutes: 15,
|
||||||
|
regularMinutes: 20,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +78,7 @@ test("a non-Fast-Lane ride resolves to hasFastLane: false", () => {
|
|||||||
assert.deepEqual(lookupFastLane("Bucaneer", r), {
|
assert.deepEqual(lookupFastLane("Bucaneer", r), {
|
||||||
hasFastLane: false,
|
hasFastLane: false,
|
||||||
fastLaneMinutes: null,
|
fastLaneMinutes: null,
|
||||||
|
regularMinutes: 20,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +87,34 @@ test("empty fastlane createdDateTime yields fastLaneMinutes: null", () => {
|
|||||||
assert.deepEqual(lookupFastLane("Batman: The Ride", r), {
|
assert.deepEqual(lookupFastLane("Batman: The Ride", r), {
|
||||||
hasFastLane: true,
|
hasFastLane: true,
|
||||||
fastLaneMinutes: null,
|
fastLaneMinutes: null,
|
||||||
|
regularMinutes: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regular wait is surfaced when regularWaittime.createdDateTime is fresh", () => {
|
||||||
|
const r = result(ride("Goliath", true, null, 35));
|
||||||
|
assert.deepEqual(lookupFastLane("Goliath", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: null,
|
||||||
|
regularMinutes: 35,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("walk-on regular wait (0) is surfaced, not null", () => {
|
||||||
|
const r = result(ride("Buccaneer", false, null, 0));
|
||||||
|
assert.deepEqual(lookupFastLane("Buccaneer", r), {
|
||||||
|
hasFastLane: false,
|
||||||
|
fastLaneMinutes: null,
|
||||||
|
regularMinutes: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty regular createdDateTime yields regularMinutes: null", () => {
|
||||||
|
const r = result(ride("Tatsu", true, 10, null));
|
||||||
|
assert.deepEqual(lookupFastLane("Tatsu", r), {
|
||||||
|
hasFastLane: true,
|
||||||
|
fastLaneMinutes: 10,
|
||||||
|
regularMinutes: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Outage detection tests for the ride detail page's Today chart.
|
||||||
|
*
|
||||||
|
* Pure unit tests against lib/outage.ts — no DOM, no Recharts.
|
||||||
|
*
|
||||||
|
* Run with: npm test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { computeOutages, formatOutageDuration, outageLookup } from "../lib/outage";
|
||||||
|
import type { OutageSample } from "../lib/outage";
|
||||||
|
|
||||||
|
/** Build a sample at the given UTC time (just a convenience). */
|
||||||
|
function s(t: string, isOpen: boolean): OutageSample {
|
||||||
|
return { recordedAt: `2026-05-30T${t}:00Z`, isOpen };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── computeOutages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("no outages when every sample is open", () => {
|
||||||
|
const out = computeOutages([s("12:00", true), s("12:05", true), s("12:10", true)]);
|
||||||
|
assert.deepEqual(out, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single outage in the middle of the day → #1, duration matches", () => {
|
||||||
|
// closed at 13:00, 13:05, 13:10; reopens at 13:15
|
||||||
|
const out = computeOutages([
|
||||||
|
s("12:55", true),
|
||||||
|
s("13:00", false),
|
||||||
|
s("13:05", false),
|
||||||
|
s("13:10", false),
|
||||||
|
s("13:15", true),
|
||||||
|
s("13:20", true),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].n, 1);
|
||||||
|
assert.equal(out[0].durationMin, 15);
|
||||||
|
assert.equal(out[0].startISO, "2026-05-30T13:00:00Z");
|
||||||
|
assert.equal(out[0].endISO, "2026-05-30T13:15:00Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two separate outages → numbered 1 then 2 in chronological order", () => {
|
||||||
|
const out = computeOutages([
|
||||||
|
s("12:00", true),
|
||||||
|
s("12:05", false), s("12:10", false),
|
||||||
|
s("12:15", true),
|
||||||
|
s("14:00", false), s("14:05", false), s("14:10", false),
|
||||||
|
s("14:15", true),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.equal(out[0].n, 1);
|
||||||
|
assert.equal(out[0].durationMin, 10);
|
||||||
|
assert.equal(out[1].n, 2);
|
||||||
|
assert.equal(out[1].durationMin, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outage at start of day is still #1", () => {
|
||||||
|
const out = computeOutages([
|
||||||
|
s("10:00", false), s("10:05", false),
|
||||||
|
s("10:10", true),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].n, 1);
|
||||||
|
assert.equal(out[0].durationMin, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outage that never reopens uses the last sample as the end", () => {
|
||||||
|
const out = computeOutages([
|
||||||
|
s("16:00", true),
|
||||||
|
s("16:05", false), s("16:10", false), s("16:15", false),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].n, 1);
|
||||||
|
// start 16:05 → end 16:15 → 10 min (in-progress at end of day)
|
||||||
|
assert.equal(out[0].durationMin, 10);
|
||||||
|
assert.equal(out[0].endISO, "2026-05-30T16:15:00Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty sample array → no outages", () => {
|
||||||
|
assert.deepEqual(computeOutages([]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single closed sample → 0-minute outage, still numbered #1", () => {
|
||||||
|
const out = computeOutages([s("12:00", true), s("12:05", false), s("12:10", true)]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].n, 1);
|
||||||
|
assert.equal(out[0].durationMin, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── outageLookup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("outageLookup tags each closed sample's time label with its outage", () => {
|
||||||
|
const samples = [
|
||||||
|
s("12:00", true),
|
||||||
|
s("12:05", false), s("12:10", false),
|
||||||
|
s("12:15", true),
|
||||||
|
s("14:00", false), s("14:05", false),
|
||||||
|
s("14:10", true),
|
||||||
|
];
|
||||||
|
const outages = computeOutages(samples);
|
||||||
|
const lookup = outageLookup(samples, outages);
|
||||||
|
|
||||||
|
// 12:05 and 12:10 belong to outage #1; 14:00 and 14:05 belong to #2.
|
||||||
|
// The exact HH:MM labels depend on the runner's local timezone, so we
|
||||||
|
// assert membership using the same TIME_FMT that outage.ts uses.
|
||||||
|
const TIME_FMT = new Intl.DateTimeFormat([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
const fmt = (iso: string) => TIME_FMT.format(new Date(iso));
|
||||||
|
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T12:05:00Z"))?.n, 1);
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T12:10:00Z"))?.n, 1);
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T14:00:00Z"))?.n, 2);
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T14:05:00Z"))?.n, 2);
|
||||||
|
// Open samples are not in the lookup.
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T12:00:00Z")), undefined);
|
||||||
|
assert.equal(lookup.get(fmt("2026-05-30T14:10:00Z")), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── formatOutageDuration ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("formatOutageDuration renders minutes-only under 1 hour", () => {
|
||||||
|
assert.equal(formatOutageDuration(0), "0m");
|
||||||
|
assert.equal(formatOutageDuration(1), "1m");
|
||||||
|
assert.equal(formatOutageDuration(47), "47m");
|
||||||
|
assert.equal(formatOutageDuration(59), "59m");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatOutageDuration renders exact hours as 'Xh'", () => {
|
||||||
|
assert.equal(formatOutageDuration(60), "1h");
|
||||||
|
assert.equal(formatOutageDuration(120), "2h");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatOutageDuration renders hours + minutes", () => {
|
||||||
|
assert.equal(formatOutageDuration(88), "1h 28m");
|
||||||
|
assert.equal(formatOutageDuration(150), "2h 30m");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user