feat: prefer Six Flags regular waits, show Fast Lane at 0, mark outages on chart
Build and Deploy / Lint, typecheck, test (push) Successful in 35s
Build and Deploy / Build & Push (push) Successful in 1m8s

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:
2026-05-30 18:54:02 -04:00
parent 5d9daee627
commit e888261ed9
9 changed files with 510 additions and 34 deletions
+97
View File
@@ -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;
}
+17 -6
View File
@@ -58,6 +58,8 @@ interface FastLaneEntry {
norm: string;
compact: string;
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. */
fastLaneMinutes: number | null;
}
@@ -83,6 +85,9 @@ export function parseWaitTimes(json: WTResponse): FastLaneResult | null {
norm,
compact: norm.replace(/\s/g, ""),
isFastLane: Boolean(d.isFastLane),
regularMinutes: d.regularWaittime?.createdDateTime
? d.regularWaittime.waitTime
: null,
fastLaneMinutes: d.fastlaneWaittime?.createdDateTime
? d.fastlaneWaittime.waitTime
: null,
@@ -123,16 +128,18 @@ export async function fetchFastLaneWaits(
}
/**
* Find the Fast Lane data for a ride by name. Mirrors the isCoasterMatch
* strategy (exact normalized → compact ≥5 → prefix ≥5 with conjunction guard)
* so Queue-Times and Six Flags name conventions line up.
* Find the Six Flags wait-times row for a ride by name. Mirrors the
* isCoasterMatch strategy (exact normalized → compact ≥5 → prefix ≥5 with
* 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(
rideName: string,
result: FastLaneResult,
): { hasFastLane: boolean; fastLaneMinutes: number | null } | null {
): { hasFastLane: boolean; fastLaneMinutes: number | null; regularMinutes: number | null } | null {
const norm = normalizeForMatch(rideName);
const compact = norm.replace(/\s/g, "");
@@ -159,5 +166,9 @@ export function lookupFastLane(
}
if (!match) return null;
return { hasFastLane: match.isFastLane, fastLaneMinutes: match.fastLaneMinutes };
return {
hasFastLane: match.isFastLane,
fastLaneMinutes: match.fastLaneMinutes,
regularMinutes: match.regularMinutes,
};
}