New components/LiveView.tsx renders the default landing at "/": full-bleed MapView (rail + legend from chunk 5) plus a new TimeScrubber strip below it — real call-density bars over the selected 1h/6h/24h/7d window, tinted by the worst severity in each bucket, playhead pinned to NOW. The playhead doesn't scrub yet; that needs `resolved_at` on incidents, which doesn't exist server-side (blocked chunk 13, in DEFERRED.md) — the density data itself is live, not a fixture. Distinguishes the two empty states UI_REDESIGN.md §4 calls out: a configured-but-quiet org (nodes online, zero active incidents) now shows "Listening — last check-in Xm ago" instead of rendering nothing, separate from the zero-node case (chunk 10's Activation screen). app/page.tsx's HomePage now renders LiveView directly for a signed-in, provisioned user instead of the chunk-4 interim redirect to /incidents. Per UI_REDESIGN.md chunk 6.
92 lines
3.4 KiB
TypeScript
92 lines
3.4 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 } from "@/lib/types";
|
|
import { 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;
|
|
|
|
export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
|
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));
|
|
|
|
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]">
|
|
{buckets.map((b, i) => {
|
|
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>
|
|
);
|
|
}
|