8 Commits
Author SHA1 Message Date
logan e79b8bc37d Merge pull request 'Incident date filter matched nothing' (#168) from fix/incident-date-filter into main
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:15:31 -04:00
Logan CusanoandClaude Opus 5.5 c72c28f5dc frontend: incident date filter matched nothing
Incident started_at is an isoformat() string (incident_correlator.py,
routers/incidents.py), not a Firestore timestamp like calls. The range
bounds were Dates, which Firestore compares by type, so any date range
returned zero incidents. Bounds are now UTC ISO strings in the same
"+00:00" shape, which order lexicographically by time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:15:23 -04:00
logan 02b5b7b5a5 Merge pull request 'Archive Load more skipped 150 of every 200 calls' (#166) from fix/archive-paging-skip into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 3m45s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:05:37 -04:00
Logan CusanoandClaude Opus 5.5 40014a47a3 c2-core: Archive "Load more" skipped 150 of every 200 calls
/calls/search scans a 200-row window and returns 50, but the next cursor
was always the last SCANNED row — so with an unfiltered list each page
jumped past the 150 matches it had already read and not shown. Resume
after the last RETURNED row when matches overflow the page; keep the
last-scanned cursor only when the page holds every match (the sparse-
filter case that cursor exists for). Same fix for /calls/eval-queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:05:31 -04:00
logan 6c0e7a4f8e Merge pull request 'Date range picker on Incidents and Archive; fix Archive Load more' (#165) from feat/date-range into main
Build & Deploy / Build & push images (push) Successful in 4m22s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:04:06 -04:00
Logan CusanoandClaude Opus 5.5 6479174022 frontend: date range picker on Incidents and Archive; fix Archive paging
Incidents and Archive get a from/to date range (native date inputs,
local-day bounds). Incidents filters in the Firestore query; Archive
passes date_from/date_to to GET /calls/search, which applies them as a
started_at range — both ride the existing org_id/started_at index.

Also fixes /calls/search and /calls/eval-queue paging: the cursor went to
Firestore as a raw ISO string against a timestamp field, which compares
by type rather than time, so "Load more" re-read the first page. Cursor
and range bounds are now parsed to datetimes (400 on garbage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:03:40 -04:00
logan c043298902 Merge pull request 'Incidents search/filter/load-more; Archive viewable by viewers' (#164) from feat/archive-search-viewers into main
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 4s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-23 23:54:35 -04:00
Logan CusanoandClaude Opus 5.5 fa194e0f0a frontend: search, filters and load-more on Incidents; open Archive to viewers
Incidents page: text search (title, location, summary, units, vehicles,
tags, location mentions), status and type filters, and a Load more button
that pages the Firestore query 100 at a time. Filtering runs over the
loaded window, and the page says so when older incidents exist.

Archive (/calls): readable by every org member, not just admins.
GET /calls/search now takes any Firebase token scoped to the caller's org
— the Firestore rules already let members read every call in their org,
so this widens nothing. Attach/detach stays admin-only (UI and routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:53:46 -04:00
7 changed files with 317 additions and 42 deletions
+62 -15
View File
@@ -5,6 +5,7 @@ from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import ( from app.internal.auth import (
require_admin_token, require_admin_token,
require_firebase_token,
require_service_or_firebase_token, require_service_or_firebase_token,
resolve_caller_org_id, resolve_caller_org_id,
reprocess_limiter, reprocess_limiter,
@@ -22,6 +23,42 @@ class EvalTranscriptUpdate(BaseModel):
router = APIRouter(prefix="/calls", tags=["calls"]) router = APIRouter(prefix="/calls", tags=["calls"])
def _parse_ts(value: Optional[str], field: str) -> Optional[datetime]:
"""ISO string from a query param → aware datetime, or 400.
started_at is stored as a Firestore timestamp, so a cursor or range bound
passed through as the raw string compares by *type* (every string sorts
after every timestamp) rather than by time — a string cursor made "Load
more" return the first page again.
"""
if not value:
return None
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _next_cursor(rows: list[dict], matches: list[dict], page: list[dict], window: int) -> Optional[str]:
"""Where the next page of a bounded-window scan starts.
More matches than fit on the page → resume right after the last row
returned, or every match between it and the end of the window is skipped
(a 200-row window shown 50 at a time lost 150 calls per "Load more").
Otherwise resume after the last row SCANNED, not the last match — a page
whose last match sits early in the window would re-scan everything after
it and loop forever on a sparse filter. A short window is the end.
"""
if len(matches) > len(page):
last = page[-1].get("started_at")
elif len(rows) == window:
last = rows[-1].get("started_at")
else:
return None
return last.isoformat() if hasattr(last, "isoformat") else last
@router.get("") @router.get("")
async def list_calls( async def list_calls(
node_id: Optional[str] = Query(None), node_id: Optional[str] = Query(None),
@@ -54,7 +91,9 @@ async def search_calls(
link: str = Query("any", pattern="^(any|orphan|linked)$"), link: str = Query("any", pattern="^(any|orphan|linked)$"),
transcript: str = Query("any", pattern="^(any|yes|no)$"), transcript: str = Query("any", pattern="^(any|yes|no)$"),
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"), q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
decoded: dict = Depends(require_admin_token), date_from: Optional[str] = Query(None, description="ISO timestamp, inclusive lower bound on started_at"),
date_to: Optional[str] = Query(None, description="ISO timestamp, inclusive upper bound on started_at"),
decoded: dict = Depends(require_firebase_token),
): ):
""" """
Paged, filterable call archive — the backend for the /calls page. Paged, filterable call archive — the backend for the /calls page.
@@ -72,6 +111,11 @@ async def search_calls(
`window_exhausted` says the scan hit its cap before filling the page, so an `window_exhausted` says the scan hit its cap before filling the page, so an
empty result means "not in this window", not "none exist". empty result means "not in this window", not "none exist".
Open to every org member (viewer included), not just admins: the Firestore
rules already let any member read every call doc in their org
(firestore.rules `calls` → docInMyOrg), so this route exposes nothing a
viewer's browser couldn't already read directly.
""" """
org_id = await resolve_caller_org_id(decoded) org_id = await resolve_caller_org_id(decoded)
if org_id is None: if org_id is None:
@@ -82,13 +126,24 @@ async def search_calls(
if not org_id: if not org_id:
raise HTTPException(403, "No organization scope for this caller.") raise HTTPException(403, "No organization scope for this caller.")
cursor_dt = _parse_ts(cursor, "cursor")
from_dt = _parse_ts(date_from, "date_from")
to_dt = _parse_ts(date_to, "date_to")
# A range on the ordered field rides the same org_id/started_at index.
conditions: list[tuple[str, str, object]] = [("org_id", "==", org_id)]
if from_dt:
conditions.append(("started_at", ">=", from_dt))
if to_dt:
conditions.append(("started_at", "<=", to_dt))
window = max(limit * 10, 200) window = max(limit * 10, 200)
rows = await fstore.collection_where( rows = await fstore.collection_where(
"calls", "calls",
[("org_id", "==", org_id)], conditions,
order_by=[("started_at", "DESCENDING")], order_by=[("started_at", "DESCENDING")],
limit_to=window, limit_to=window,
start_after={"started_at": cursor} if cursor else None, start_after={"started_at": cursor_dt} if cursor_dt else None,
) )
needle = (q or "").strip().lower() needle = (q or "").strip().lower()
@@ -117,13 +172,7 @@ async def search_calls(
matches = [c for c in rows if _keep(c)] matches = [c for c in rows if _keep(c)]
page = matches[:limit] page = matches[:limit]
# Cursor advances over the SCANNED window, not the filtered page — otherwise next_cursor = _next_cursor(rows, matches, page, window)
# a page whose last match sits early in the window would re-scan everything
# after it on the next request and loop forever on a sparse filter.
next_cursor = None
if len(rows) == window:
last_scanned = rows[-1].get("started_at")
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
return { return {
"calls": [with_playback_url(c) for c in page], "calls": [with_playback_url(c) for c in page],
@@ -163,13 +212,14 @@ async def eval_queue(
if not org_id: if not org_id:
raise HTTPException(403, "No organization scope for this caller.") raise HTTPException(403, "No organization scope for this caller.")
cursor_dt = _parse_ts(cursor, "cursor")
window = max(limit * 20, 300) window = max(limit * 20, 300)
rows = await fstore.collection_where( rows = await fstore.collection_where(
"calls", "calls",
[("org_id", "==", org_id)], [("org_id", "==", org_id)],
order_by=[("started_at", "DESCENDING")], order_by=[("started_at", "DESCENDING")],
limit_to=window, limit_to=window,
start_after={"started_at": cursor} if cursor else None, start_after={"started_at": cursor_dt} if cursor_dt else None,
) )
def _eligible(c: dict) -> bool: def _eligible(c: dict) -> bool:
@@ -179,10 +229,7 @@ async def eval_queue(
matches = [c for c in rows if _eligible(c)] matches = [c for c in rows if _eligible(c)]
page = matches[:limit] page = matches[:limit]
next_cursor = None next_cursor = _next_cursor(rows, matches, page, window)
if len(rows) == window:
last_scanned = rows[-1].get("started_at")
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
return { return {
"calls": [with_playback_url(c) for c in page], "calls": [with_playback_url(c) for c in page],
+59
View File
@@ -0,0 +1,59 @@
"""calls._parse_ts — cursor/date bounds must reach Firestore as datetimes.
A raw ISO string compared against a timestamp field sorts by type, not time,
which made the Archive's "Load more" return the first page again.
"""
from datetime import datetime, timezone
import pytest
from fastapi import HTTPException
from app.routers.calls import _next_cursor, _parse_ts
def test_empty_is_none():
assert _parse_ts(None, "cursor") is None
assert _parse_ts("", "cursor") is None
def test_z_suffix_parses_as_utc():
assert _parse_ts("2026-09-20T12:00:00Z", "date_from") == datetime(2026, 9, 20, 12, tzinfo=timezone.utc)
def test_naive_is_assumed_utc():
assert _parse_ts("2026-09-20T12:00:00", "date_to").tzinfo == timezone.utc
def test_round_trips_isoformat_cursor():
dt = datetime(2026, 9, 20, 12, 30, 5, 123456, tzinfo=timezone.utc)
assert _parse_ts(dt.isoformat(), "cursor") == dt
def test_garbage_is_400():
with pytest.raises(HTTPException) as exc:
_parse_ts("yesterday", "date_from")
assert exc.value.status_code == 400
# ── _next_cursor ──────────────────────────────────────────────────────────
def _rows(n):
return [{"started_at": datetime(2026, 9, 20, 12, i // 60, i % 60, tzinfo=timezone.utc)} for i in range(n)]
def test_cursor_resumes_after_last_returned_row_when_matches_overflow():
rows = _rows(200)
page = rows[:50]
assert _next_cursor(rows, rows, page, 200) == page[-1]["started_at"].isoformat()
def test_cursor_resumes_after_window_when_page_holds_every_match():
rows = _rows(200)
matches = rows[:3]
assert _next_cursor(rows, matches, matches, 200) == rows[-1]["started_at"].isoformat()
def test_short_window_is_the_end():
rows = _rows(20)
assert _next_cursor(rows, rows[:5], rows[:5], 200) is None
+28 -14
View File
@@ -6,8 +6,9 @@
// never correlated was invisible. That is the wrong way round when correlation // never correlated was invisible. That is the wrong way round when correlation
// quality is the thing under development — the orphans are the evidence. // quality is the thing under development — the orphans are the evidence.
// //
// Admin-only, because it exposes every call in the org regardless of node // Readable by every org member — the Firestore rules already let any member
// ownership and carries the manual attribution controls. // read every call in their org. The manual attribution controls stay
// admin-only, matching the admin gate on the link/unlink routes.
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
type LinkFilter = "any" | "orphan" | "linked"; type LinkFilter = "any" | "orphan" | "linked";
type TranscriptFilter = "any" | "yes" | "no"; type TranscriptFilter = "any" | "yes" | "no";
@@ -68,11 +70,13 @@ function ArchiveRow({
call, call,
systemName, systemName,
incidents, incidents,
canEdit,
onChanged, onChanged,
}: { }: {
call: CallRecord; call: CallRecord;
systemName?: string; systemName?: string;
incidents: IncidentRecord[]; incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void; onChanged: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -178,18 +182,18 @@ function ArchiveRow({
<div key={id} className="flex items-center gap-2 text-xs"> <div key={id} className="flex items-center gap-2 text-xs">
<span className="text-ink-muted">attached to</span> <span className="text-ink-muted">attached to</span>
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span> <span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
<button {canEdit && <button
onClick={() => detach(id)} onClick={() => detach(id)}
disabled={busy} disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0" className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
> >
detach detach
</button> </button>}
</div> </div>
); );
})} })}
<div className="flex flex-wrap items-center gap-2"> {canEdit && <div className="flex flex-wrap items-center gap-2">
<select <select
value={attachTo} value={attachTo}
onChange={(e) => setAttachTo(e.target.value)} onChange={(e) => setAttachTo(e.target.value)}
@@ -208,7 +212,7 @@ function ArchiveRow({
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}> <Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"} {busy ? "Saving…" : "Attach"}
</Button> </Button>
</div> </div>}
</div> </div>
{error && <ErrorBanner message={error} />} {error && <ErrorBanner message={error} />}
@@ -219,8 +223,9 @@ function ArchiveRow({
} }
export default function ArchivePage() { export default function ArchivePage() {
const { isAdmin, loading: authLoading } = useAuth(); const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems(); const { systems } = useSystems();
const { incidents } = useIncidents(200); const { incidents } = useIncidents(200);
@@ -235,10 +240,12 @@ export default function ArchivePage() {
const [systemId, setSystemId] = useState(""); const [systemId, setSystemId] = useState("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [submittedQ, setSubmittedQ] = useState(""); const [submittedQ, setSubmittedQ] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/"); if (!authLoading && !canView) router.replace("/");
}, [authLoading, isAdmin, router]); }, [authLoading, canView, router]);
const load = useCallback( const load = useCallback(
async (nextCursor: string | null, append: boolean) => { async (nextCursor: string | null, append: boolean) => {
@@ -252,6 +259,8 @@ export default function ArchivePage() {
transcript, transcript,
system_id: systemId || undefined, system_id: systemId || undefined,
q: submittedQ || undefined, q: submittedQ || undefined,
date_from: dayStart(dateFrom)?.toISOString(),
date_to: dayEnd(dateTo)?.toISOString(),
}); });
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls)); setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
setCursor(res.next_cursor); setCursor(res.next_cursor);
@@ -262,14 +271,14 @@ export default function ArchivePage() {
setLoading(false); setLoading(false);
} }
}, },
[link, transcript, systemId, submittedQ], [link, transcript, systemId, submittedQ, dateFrom, dateTo],
); );
// Reload from the top whenever a filter changes. // Reload from the top whenever a filter changes.
useEffect(() => { useEffect(() => {
if (authLoading || !isAdmin) return; if (authLoading || !canView) return;
load(null, false); load(null, false);
}, [authLoading, isAdmin, load]); }, [authLoading, canView, load]);
const systemName = useMemo(() => { const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name])); const m = new Map(systems.map((s) => [s.system_id, s.name]));
@@ -277,7 +286,7 @@ export default function ArchivePage() {
}, [systems]); }, [systems]);
// Every hook runs before this guard — see the note in app/nodes/page.tsx. // Every hook runs before this guard — see the note in app/nodes/page.tsx.
if (authLoading || !isAdmin) return null; if (authLoading || !canView) return null;
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length; const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length; const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
@@ -286,7 +295,9 @@ export default function ArchivePage() {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Archive" title="Archive"
description="Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong." description={isAdmin
? "Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
: "Every call on the account, correlated or not."}
/> />
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -329,6 +340,8 @@ export default function ArchivePage() {
))} ))}
</select> </select>
<DateRange from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
<form <form
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }} onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
className="flex items-center gap-2 ml-auto" className="flex items-center gap-2 ml-auto"
@@ -373,6 +386,7 @@ export default function ArchivePage() {
call={call} call={call}
systemName={systemName(call.system_id)} systemName={systemName(call.system_id)}
incidents={incidents} incidents={incidents}
canEdit={isAdmin}
onChanged={() => load(null, false)} onChanged={() => load(null, false)}
/> />
))} ))}
+89 -10
View File
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
import { isKnownSeverity, severityRank } from "@/lib/severity"; import { isKnownSeverity, severityRank } from "@/lib/severity";
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark"; import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
import { TypeGlyph } from "@/components/marks/TypeGlyph"; import { TypeGlyph } from "@/components/marks/TypeGlyph";
@@ -27,6 +28,23 @@ const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 }; const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
type StatusFilter = "any" | "active" | "resolved";
const INCIDENT_TYPES = ["fire", "police", "ems", "accident", "other"];
// Firestore holds the paging; text/type/status filtering runs over the loaded
// window, so "Load more" also widens what the search can find.
const PAGE_SIZE = 100;
function matchesSearch(inc: IncidentRecord, needle: string): boolean {
if (!needle) return true;
const hay = [
inc.title, inc.location, inc.summary, inc.type,
...(inc.units ?? []), ...(inc.vehicles ?? []), ...(inc.tags ?? []),
...(inc.location_mentions ?? []),
].filter(Boolean).join(" ").toLowerCase();
return hay.includes(needle);
}
// The Firestore client surfaces a missing composite index or an undeployed // The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something // ruleset as a raw multi-line string with a console URL in it — not something
@@ -178,11 +196,19 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
export default function IncidentsPage() { export default function IncidentsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { incidents, loading, error } = useIncidents(); const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all"); const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent"); const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
const [typeFilter, setTypeFilter] = useState("");
const [search, setSearch] = useState("");
const onAirIncidentIds = useMemo(() => { const onAirIncidentIds = useMemo(() => {
const s = new Set<string>(); const s = new Set<string>();
@@ -194,12 +220,24 @@ export default function IncidentsPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const threshold = FILTER_THRESHOLD[severityFilter]; const threshold = FILTER_THRESHOLD[severityFilter];
const list = incidents.filter((i) => severityRank(i.severity) >= threshold); const needle = search.trim().toLowerCase();
const list = incidents.filter((i) =>
severityRank(i.severity) >= threshold &&
(statusFilter === "any" || i.status === statusFilter) &&
(!typeFilter || i.type === typeFilter) &&
matchesSearch(i, needle)
);
if (sortMode === "severity") { if (sortMode === "severity") {
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)); return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
} }
return list; // useIncidents() already orders by started_at desc return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode]); }, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
function clearFilters() {
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
}
const hiddenCount = incidents.length - filtered.length; const hiddenCount = incidents.length - filtered.length;
const activeCount = filtered.filter((i) => i.status === "active").length; const activeCount = filtered.filter((i) => i.status === "active").length;
@@ -249,7 +287,39 @@ export default function IncidentsPage() {
</button> </button>
))} ))}
</div> </div>
<label className="flex items-center gap-2 text-xs text-ink-muted"> <input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search title, location, units…"
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-full sm:w-64 focus:outline-none focus:border-accent"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="any">Any status</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
>
<option value="">All types</option>
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<DateRange
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
/>
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
Sort Sort
<select <select
value={sortMode} value={sortMode}
@@ -270,7 +340,8 @@ export default function IncidentsPage() {
<> <>
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter. {hiddenCount} of {incidents.length} loaded incident{incidents.length !== 1 ? "s" : ""} hidden by filters
{hasMore && " — load more to search further back"}.
</p> </p>
)} )}
@@ -302,19 +373,27 @@ export default function IncidentsPage() {
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
<EmptyState <EmptyState
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"} title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
description={ description={
incidents.length === 0 incidents.length === 0 && !filtersActive
? "Incidents appear automatically once calls start correlating." ? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold." : "Try clearing a filter, or load older incidents."
} }
action={ action={
incidents.length > 0 && severityFilter !== "all" ? ( filtersActive ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button> <Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : undefined ) : undefined
} }
/> />
)} )}
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
Load more
</Button>
</div>
)}
</> </>
)} )}
+57
View File
@@ -0,0 +1,57 @@
"use client";
// A from/to pair of native date inputs. Values are the inputs' own
// "YYYY-MM-DD" strings; dayStart/dayEnd turn them into the local-midnight
// bounds a started_at range query needs, so "to" includes the whole day.
export function dayStart(ymd: string): Date | undefined {
if (!ymd) return undefined;
const [y, m, d] = ymd.split("-").map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0);
}
export function dayEnd(ymd: string): Date | undefined {
if (!ymd) return undefined;
const [y, m, d] = ymd.split("-").map(Number);
return new Date(y, m - 1, d, 23, 59, 59, 999);
}
const inputClass =
"bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent";
export function DateRange({
from,
to,
onChange,
}: {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}) {
return (
<div className="flex items-center gap-2 text-xs text-ink-muted">
<input
type="date"
aria-label="From date"
value={from}
max={to || undefined}
onChange={(e) => onChange(e.target.value, to)}
className={inputClass}
/>
<span>to</span>
<input
type="date"
aria-label="To date"
value={to}
min={from || undefined}
onChange={(e) => onChange(from, e.target.value)}
className={inputClass}
/>
{(from || to) && (
<button onClick={() => onChange("", "")} className="text-ink-muted hover:text-ink-2">
clear
</button>
)}
</div>
);
}
+2
View File
@@ -82,6 +82,8 @@ export const c2api = {
link?: "any" | "orphan" | "linked"; link?: "any" | "orphan" | "linked";
transcript?: "any" | "yes" | "no"; transcript?: "any" | "yes" | "no";
q?: string; q?: string;
date_from?: string;
date_to?: string;
}) => { }) => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
+20 -3
View File
@@ -11,12 +11,19 @@ const toISO = (v: unknown): string =>
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ?? (v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
(typeof v === "string" ? v : new Date().toISOString()); (typeof v === "string" ? v : new Date().toISOString());
export function useIncidents(limitCount = 100) { export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// A full page means there may be older incidents past the limit; a short
// page means the query reached the end of the collection.
const [hasMore, setHasMore] = useState(false);
const { orgId } = useAuth(); const { orgId } = useAuth();
// Stable ms values so the effect dependency doesn't fire on every render
const dateFromMs = dateFrom?.getTime();
const dateToMs = dateTo?.getTime();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -34,9 +41,18 @@ export function useIncidents(limitCount = 100) {
return; return;
} }
// A range on the ordered field rides the existing org_id/started_at index.
// Incident started_at is stored as a Python isoformat() STRING
// ("2026-09-20T12:00:00.123456+00:00", incident_correlator.py), not a
// Firestore timestamp — unlike calls. A Date bound compares by type and
// matches nothing, so the bounds go in as UTC ISO strings in the same
// shape, which then compare lexicographically in time order.
const isoBound = (ms: number) => new Date(ms).toISOString().replace("Z", "+00:00");
const q = query( const q = query(
collection(db, "incidents"), collection(db, "incidents"),
where("org_id", "==", orgId), where("org_id", "==", orgId),
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
limit(limitCount) limit(limitCount)
); );
@@ -49,6 +65,7 @@ export function useIncidents(limitCount = 100) {
updated_at: toISO(data.updated_at), updated_at: toISO(data.updated_at),
} as IncidentRecord; } as IncidentRecord;
})); }));
setHasMore(snap.size >= limitCount);
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { }, (err: FirestoreError) => {
console.error("useIncidents:", err); console.error("useIncidents:", err);
@@ -61,9 +78,9 @@ export function useIncidents(limitCount = 100) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount, orgId]); }, [limitCount, dateFromMs, dateToMs, orgId]);
return { incidents, loading, error }; return { incidents, loading, error, hasMore };
} }
export function useIncident(incidentId: string | null) { export function useIncident(incidentId: string | null) {