"use client"; /** * Live's time scrubber — call-density bars over the selected window, tinted * by the worst severity in each bucket, playhead pinned to NOW. Range * presets change which window is densitized; the playhead itself does not * move in this build — history scrub needs `resolved_at` on incidents, * which doesn't exist yet (UI_REDESIGN.md chunk 13, blocked on backend, * tracked in DEFERRED.md). This is real call density from live data, not a * fixture — only the scrub interaction is deferred. */ import { useMemo, useState } from "react"; import type { CallRecord, IncidentRecord } from "@/lib/types"; import { isKnownSeverity, SEVERITY_COLORS, severityRank } from "@/lib/severity"; const RANGES = [ { label: "1h", ms: 60 * 60 * 1000 }, { label: "6h", ms: 6 * 60 * 60 * 1000 }, { label: "24h", ms: 24 * 60 * 60 * 1000 }, { label: "7d", ms: 7 * 24 * 60 * 60 * 1000 }, ] as const; const BUCKETS = 48; /** * `incidents` (optional) adds one band per active incident from its start to * its last update, tinted by severity — click a band to drill into it (live * ops wall). The playhead still does not move; see the header. * * `loadedSince` (server-26#239): ISO time of the oldest call the caller's * window loaded when that window came back FULL (null = everything in range * was loaded). The axis before it is hatched and labelled "not loaded", so a * 7d view never reads as a quiet week for time nobody fetched. */ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident, loadedSince = null, windowSize }: { calls: CallRecord[]; incidents?: IncidentRecord[]; selectedIncidentId?: string | null; onSelectIncident?: (id: string) => void; loadedSince?: string | null; /** Size of the caller's call window, for the "not loaded" tooltip. */ windowSize?: number; }) { const [rangeIdx, setRangeIdx] = useState(2); // default 24h const range = RANGES[rangeIdx]; const buckets = useMemo(() => { const now = Date.now(); const start = now - range.ms; const bucketMs = range.ms / BUCKETS; const counts = Array.from({ length: BUCKETS }, () => ({ count: 0, worstRank: -1 })); for (const c of calls) { const t = new Date(c.started_at).getTime(); if (Number.isNaN(t) || t < start || t > now) continue; const idx = Math.min(BUCKETS - 1, Math.floor((t - start) / bucketMs)); counts[idx].count += 1; const rank = severityRank(c.severity); if (rank > counts[idx].worstRank) counts[idx].worstRank = rank; } return counts; // eslint-disable-next-line react-hooks/exhaustive-deps }, [calls, rangeIdx]); const maxCount = Math.max(1, ...buckets.map((b) => b.count)); // Share of the axis (0-100) before the loaded window starts; 0 = all loaded. const unloaded = useMemo(() => { const t = loadedSince ? new Date(loadedSince).getTime() : NaN; if (Number.isNaN(t)) return { pct: 0, firstLoaded: 0, since: null as Date | null }; const start = Date.now() - range.ms; if (t <= start) return { pct: 0, firstLoaded: 0, since: null as Date | null }; const frac = Math.min(1, (t - start) / range.ms); // Buckets wholly before the window are unknown, not empty: drawn blank. return { pct: frac * 100, firstLoaded: Math.floor(frac * BUCKETS), since: new Date(t) }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadedSince, rangeIdx, calls]); const sinceLabel = unloaded.since ? unloaded.since.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }) : ""; const unloadedTitle = unloaded.since ? `Not loaded: only the latest ${windowSize ?? "few hundred"} calls are on this page (back to ${sinceLabel}). Earlier activity is unknown, not quiet.` : ""; const bands = useMemo(() => { const now = Date.now(); const start = now - range.ms; return incidents .map((inc) => { const a = Math.max(start, new Date(inc.started_at).getTime()); const b = Math.min(now, new Date(inc.updated_at || inc.started_at).getTime()); if (Number.isNaN(a) || Number.isNaN(b) || b < start) return null; return { inc, left: ((a - start) / range.ms) * 100, width: Math.max(0.6, ((Math.max(a, b) - a) / range.ms) * 100) }; }) .filter((x): x is { inc: IncidentRecord; left: number; width: number } => x !== null) .sort((x, y) => severityRank(x.inc.severity) - severityRank(y.inc.severity)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [incidents, rangeIdx]); return (
{bands.length > 0 && (
{bands.map(({ inc, left, width }) => { const color = isKnownSeverity(inc.severity) ? SEVERITY_COLORS[inc.severity] : "var(--ink-muted)"; const sel = inc.incident_id === selectedIncidentId; return (
)} {unloaded.pct > 0 && (
{unloaded.pct >= 8 && ( not loaded )}
)} {buckets.map((b, i) => { if (i < unloaded.firstLoaded) { return