- NEW lane: Gate A notice pinned below the rows (it scrolled off with >3 entries, same rule as the rail, #46); rows at text-xs for the wall. - Mobile chip moved off MapView's centred "Live" chip, which it covered. - Keyword cues re-resolve to the incident once correlation links the call, so the lane row is clickable and the pin pulses. - Enabling notifications sends one confirmation notification; a browser that grants permission but throws on construct (mobile Chrome) now shows "unsupported" instead of a switched-on control that never delivers. - useActiveIncidentsState: includeMetadataChanges so `loaded` flips on a metadata-only server sync (quiet org / offline start) instead of waiting for the first incident change and swallowing it into the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
4.2 KiB
TypeScript
88 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
|
|
* minor that is escalating sits under stale Majors; this lane is the
|
|
* recency view of the same FILTERED incidents, newest first, with why each
|
|
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
|
|
*
|
|
* Desktop: overlay at the map's top-left, right of the zoom stack and above
|
|
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
|
|
* just short of it and its rows scroll). The Gate A notice is pinned below
|
|
* the rows, never inside the scroll area — same rule as the rail (#46).
|
|
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
|
|
* centre slot would cover it), right of the fit-all button; it expands.
|
|
* Hidden when there is nothing new.
|
|
*/
|
|
import { useState } from "react";
|
|
import type { LaneEntry } from "@/lib/attention";
|
|
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
|
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
|
|
|
function ago(at: number, now: number): string {
|
|
const m = Math.max(0, Math.floor((now - at) / 60_000));
|
|
return m < 1 ? "now" : `${m}m`;
|
|
}
|
|
|
|
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
|
|
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
|
|
const body = (
|
|
<>
|
|
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
|
|
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
|
|
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</span>
|
|
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
|
|
</>
|
|
);
|
|
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
|
|
return (
|
|
<li>
|
|
{e.incidentId ? (
|
|
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
|
|
title={`${e.title} — ${e.reason}`}>
|
|
{body}
|
|
</button>
|
|
) : (
|
|
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
if (!entries.length) return null;
|
|
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
|
|
return (
|
|
<>
|
|
<section aria-label="New in the last 15 minutes"
|
|
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
|
|
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
|
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
|
|
</h2>
|
|
<ul className="min-h-0 overflow-y-auto px-0.5">
|
|
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
|
|
</ul>
|
|
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
|
</section>
|
|
|
|
<div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
|
|
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
|
|
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
|
|
● {entries.length} new · 15 min {open ? "▴" : "▾"}
|
|
</button>
|
|
{open && (
|
|
<div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
|
|
<ul className="max-h-40 overflow-y-auto">
|
|
{entries.map((e) => (
|
|
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
|
|
))}
|
|
</ul>
|
|
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|