6 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
7 changed files with 212 additions and 22 deletions
+55 -14
View File
@@ -23,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),
@@ -55,6 +91,8 @@ 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"),
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), decoded: dict = Depends(require_firebase_token),
): ):
""" """
@@ -88,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()
@@ -123,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],
@@ -169,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:
@@ -185,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
+8 -1
View File
@@ -24,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";
@@ -239,6 +240,8 @@ 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 && !canView) router.replace("/"); if (!authLoading && !canView) router.replace("/");
@@ -256,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);
@@ -266,7 +271,7 @@ 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.
@@ -335,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"
+16 -5
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";
@@ -196,7 +197,11 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
export default function IncidentsPage() { export default function IncidentsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [pageLimit, setPageLimit] = useState(PAGE_SIZE); const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
const { incidents, loading, error, hasMore } = useIncidents(pageLimit); 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");
@@ -228,9 +233,10 @@ export default function IncidentsPage() {
return list; // useIncidents() already orders by started_at desc return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]); }, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== ""; const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
function clearFilters() { function clearFilters() {
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch(""); setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
} }
const hiddenCount = incidents.length - filtered.length; const hiddenCount = incidents.length - filtered.length;
@@ -308,6 +314,11 @@ export default function IncidentsPage() {
<option value="">All types</option> <option value="">All types</option>
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} {INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select> </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"> <label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
Sort Sort
<select <select
@@ -362,14 +373,14 @@ 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 clearing a filter, or load older incidents." : "Try clearing a filter, or load older incidents."
} }
action={ action={
incidents.length > 0 && filtersActive ? ( filtersActive ? (
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button> <Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : undefined ) : undefined
} }
+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)) {
+15 -2
View File
@@ -11,7 +11,7 @@ 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);
@@ -20,6 +20,10 @@ export function useIncidents(limitCount = 100) {
const [hasMore, setHasMore] = useState(false); 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;
@@ -37,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)
); );
@@ -65,7 +78,7 @@ export function useIncidents(limitCount = 100) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount, orgId]); }, [limitCount, dateFromMs, dateToMs, orgId]);
return { incidents, loading, error, hasMore }; return { incidents, loading, error, hasMore };
} }