- LiveView: the selected incident merges useCallsByIncident (org_id + incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed to IncidentPanel and MapView, so a long incident keeps its full path and spine past the 500-call window. Stale rows from the previous selection are re-checked against the current id before merging. - #inc= not in the active set is fetched with useIncident: a resolved one is selected, drawn on the map and shown under a "Resolved HH:MM" banner; a missing or other-org id shows "Incident not found". Auto-tour stays on active incidents. - TimeScrubber: new loadedSince prop (oldest call when the window came back full). The axis before it is hatched and labelled "not loaded" instead of reading as a quiet stretch. - useCalls returns hasMore (full page, counted before the duplicate_of filter); useIncident resets loading on a new id so a lookup doesn't flash "not found". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
"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 (
|
|
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
|
<div className="flex-1 h-12 flex items-end gap-[2px] relative">
|
|
{bands.length > 0 && (
|
|
<div className="absolute left-0 right-0 -top-3 h-2" aria-label="Incident bands">
|
|
{bands.map(({ inc, left, width }) => {
|
|
const color = isKnownSeverity(inc.severity) ? SEVERITY_COLORS[inc.severity] : "var(--ink-muted)";
|
|
const sel = inc.incident_id === selectedIncidentId;
|
|
return (
|
|
<button key={inc.incident_id} type="button" title={inc.title ?? "Incident"}
|
|
onClick={() => onSelectIncident?.(inc.incident_id)}
|
|
className={`absolute top-0 h-2 rounded-sm ${sel ? "ring-1 ring-ink" : ""}`}
|
|
style={{ left: `${left}%`, width: `${width}%`, background: color, opacity: sel ? 1 : 0.55 }} />
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{unloaded.pct > 0 && (
|
|
<div
|
|
role="img"
|
|
aria-label={unloadedTitle}
|
|
title={unloadedTitle}
|
|
className="absolute left-0 top-0 bottom-0 z-[1] flex items-center justify-center overflow-hidden rounded-sm border border-dashed border-line"
|
|
style={{
|
|
width: `${unloaded.pct}%`,
|
|
background: "repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 7px)",
|
|
}}
|
|
>
|
|
{unloaded.pct >= 8 && (
|
|
<span className="px-1.5 rounded bg-surface/90 text-[10px] font-mono uppercase tracking-wide text-ink-muted whitespace-nowrap">
|
|
not loaded
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{buckets.map((b, i) => {
|
|
if (i < unloaded.firstLoaded) {
|
|
return <div key={i} aria-hidden="true" style={{ flex: 1, minWidth: 2 }} />;
|
|
}
|
|
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
|
const color =
|
|
b.worstRank === 3
|
|
? SEVERITY_COLORS.major
|
|
: b.worstRank === 2
|
|
? SEVERITY_COLORS.moderate
|
|
: b.worstRank >= 0
|
|
? "var(--ink-2)"
|
|
: "var(--line-strong)";
|
|
return (
|
|
<div
|
|
key={i}
|
|
title={b.count > 0 ? `${b.count} call${b.count !== 1 ? "s" : ""}` : undefined}
|
|
style={{ height: `${heightPct}%`, background: color, flex: 1, borderRadius: 1, minWidth: 2 }}
|
|
/>
|
|
);
|
|
})}
|
|
{/* Playhead — pinned to NOW; doesn't move yet (see file header) */}
|
|
<div className="relative w-0">
|
|
<div className="absolute right-0 -top-14 bottom-0 w-[2px] bg-accent" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{RANGES.map((r, i) => (
|
|
<button
|
|
key={r.label}
|
|
onClick={() => setRangeIdx(i)}
|
|
className={`px-2 py-1 rounded text-xs font-mono transition-colors ${
|
|
i === rangeIdx ? "bg-accent text-white" : "text-ink-muted hover:text-ink-2"
|
|
}`}
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
<span className="ml-2 text-xs text-ink-muted font-mono">NOW</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|