diff --git a/backend/scripts/seed-test-samples.ts b/backend/scripts/seed-test-samples.ts new file mode 100644 index 0000000..a59bc68 --- /dev/null +++ b/backend/scripts/seed-test-samples.ts @@ -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(); diff --git a/backend/src/routes/ride-history.ts b/backend/src/routes/ride-history.ts index 0eccf87..650b5d7 100644 --- a/backend/src/routes/ride-history.ts +++ b/backend/src/routes/ride-history.ts @@ -103,7 +103,10 @@ app.get("/:parkId/rides/:slug", (c) => { live: liveMatch ? { 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), fastLaneMinutes: liveIsOpen ? (flMatch?.fastLaneMinutes ?? null) : null, lastUpdated: liveMatch.lastUpdated, diff --git a/backend/src/routes/rides.ts b/backend/src/routes/rides.ts index 43fbdbb..50b6c82 100644 --- a/backend/src/routes/rides.ts +++ b/backend/src/routes/rides.ts @@ -63,8 +63,15 @@ app.get("/:id/rides", async (c) => { rides: liveRides.rides.map((r) => { const match = lookupFastLane(r.name, fl); 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 { ...r, + waitMinutes, hasFastLane: match.hasFastLane, fastLaneMinutes: r.isOpen ? match.fastLaneMinutes : null, }; diff --git a/backend/src/services/wait-sampler.ts b/backend/src/services/wait-sampler.ts index 0f6890e..ee561e6 100644 --- a/backend/src/services/wait-sampler.ts +++ b/backend/src/services/wait-sampler.ts @@ -92,10 +92,15 @@ async function samplePark(park: Park, now: Date): Promise<{ for (const ride of liveRides!.rides) { if (!ride.qtRideId) continue; const slug = slugifyRideName(ride.name); - const flMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null; - const hasFastLane = Boolean(flMatch?.hasFastLane); + const sfMatch = fastLane ? lookupFastLane(ride.name, fastLane) : null; + 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 = - ride.isOpen && flMatch ? flMatch.fastLaneMinutes : null; + ride.isOpen && sfMatch ? sfMatch.fastLaneMinutes : null; upsertRide( park.id, @@ -115,7 +120,7 @@ async function samplePark(park: Park, now: Date): Promise<{ localDate, localTime, ride.isOpen, - ride.isOpen ? ride.waitMinutes : null, + waitMinutes, fastLaneMinutes, ); samplesWritten++; diff --git a/components/charts/WaitTimeTodayChart.tsx b/components/charts/WaitTimeTodayChart.tsx index fdb9ca1..2818a5a 100644 --- a/components/charts/WaitTimeTodayChart.tsx +++ b/components/charts/WaitTimeTodayChart.tsx @@ -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" } }} /> - { - if (value === null || value === undefined) return ["—", name]; - return [`${value} min`, name]; - }} - /> + {/* Outage markers — render before the lines so they sit underneath. */} + {outages.map((o) => ( + + ))} + } /> ); } + +// ── 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 ( +
+
{label}
+
+ Outage #{datum.outageN} — {formatOutageDuration(datum.outageDurationMin)} +
+
+ ); + } + + return ( +
+
{label}
+ {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 ( +
+ + {entry.name} + {display} +
+ ); + })} + {!hasFastLane && null} +
+ ); +} diff --git a/lib/outage.ts b/lib/outage.ts new file mode 100644 index 0000000..e494dc2 --- /dev/null +++ b/lib/outage.ts @@ -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 `` 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 { + const byTime = new Map(); + 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; +} diff --git a/lib/scrapers/sixflags-waittimes.ts b/lib/scrapers/sixflags-waittimes.ts index ed9b24b..b37dcb3 100644 --- a/lib/scrapers/sixflags-waittimes.ts +++ b/lib/scrapers/sixflags-waittimes.ts @@ -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, + }; } diff --git a/tests/fast-lane-matching.test.ts b/tests/fast-lane-matching.test.ts index 6b71a5c..d45d9cc 100644 --- a/tests/fast-lane-matching.test.ts +++ b/tests/fast-lane-matching.test.ts @@ -17,12 +17,16 @@ function ride( name: string, isFastLane: boolean, fastLaneMinutes: number | null, + regularMinutes: number | null = 20, ): Record { return { id: 1, name, 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: fastLaneMinutes === null ? { createdDateTime: "", waitTime: 0 } @@ -54,15 +58,18 @@ test("matches across trademark symbols, THE prefix, possessives", () => { assert.deepEqual(lookupFastLane("BATMAN™ The Ride", r), { hasFastLane: true, fastLaneMinutes: 5, + regularMinutes: 20, }); assert.deepEqual(lookupFastLane("THE RIDDLER™'s Revenge", r), { hasFastLane: true, fastLaneMinutes: 10, + regularMinutes: 20, }); // Prefix match: "Apocalypse" is a prefix of "Apocalypse the Ride". assert.deepEqual(lookupFastLane("Apocalypse", r), { hasFastLane: true, fastLaneMinutes: 15, + regularMinutes: 20, }); }); @@ -71,6 +78,7 @@ test("a non-Fast-Lane ride resolves to hasFastLane: false", () => { assert.deepEqual(lookupFastLane("Bucaneer", r), { hasFastLane: false, fastLaneMinutes: null, + regularMinutes: 20, }); }); @@ -79,6 +87,34 @@ test("empty fastlane createdDateTime yields fastLaneMinutes: null", () => { assert.deepEqual(lookupFastLane("Batman: The Ride", r), { hasFastLane: true, 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, }); }); diff --git a/tests/outage-detection.test.ts b/tests/outage-detection.test.ts new file mode 100644 index 0000000..7ab8294 --- /dev/null +++ b/tests/outage-detection.test.ts @@ -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"); +});