From 6479174022a168ceea3799edc637d5dc65d02c4d Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Thu, 24 Sep 2026 01:03:40 -0400 Subject: [PATCH] frontend: date range picker on Incidents and Archive; fix Archive paging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drb-c2-core/app/routers/calls.py | 37 +++++++++++++-- drb-c2-core/tests/test_calls_parse_ts.py | 35 +++++++++++++++ drb-frontend/app/calls/page.tsx | 9 +++- drb-frontend/app/incidents/page.tsx | 21 ++++++--- drb-frontend/components/ui/DateRange.tsx | 57 ++++++++++++++++++++++++ drb-frontend/lib/c2api.ts | 2 + drb-frontend/lib/useIncidents.ts | 11 ++++- 7 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 drb-c2-core/tests/test_calls_parse_ts.py create mode 100644 drb-frontend/components/ui/DateRange.tsx diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index f1ad4c4..1794e6f 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -23,6 +23,23 @@ class EvalTranscriptUpdate(BaseModel): 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) + + @router.get("") async def list_calls( node_id: Optional[str] = Query(None), @@ -55,6 +72,8 @@ async def search_calls( link: str = Query("any", pattern="^(any|orphan|linked)$"), transcript: str = Query("any", pattern="^(any|yes|no)$"), 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), ): """ @@ -88,13 +107,24 @@ async def search_calls( if not org_id: 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) rows = await fstore.collection_where( "calls", - [("org_id", "==", org_id)], + conditions, order_by=[("started_at", "DESCENDING")], 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() @@ -169,13 +199,14 @@ async def eval_queue( if not org_id: raise HTTPException(403, "No organization scope for this caller.") + cursor_dt = _parse_ts(cursor, "cursor") window = max(limit * 20, 300) rows = await fstore.collection_where( "calls", [("org_id", "==", org_id)], order_by=[("started_at", "DESCENDING")], 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: diff --git a/drb-c2-core/tests/test_calls_parse_ts.py b/drb-c2-core/tests/test_calls_parse_ts.py new file mode 100644 index 0000000..b347c59 --- /dev/null +++ b/drb-c2-core/tests/test_calls_parse_ts.py @@ -0,0 +1,35 @@ +"""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 _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 diff --git a/drb-frontend/app/calls/page.tsx b/drb-frontend/app/calls/page.tsx index c4996e3..a69d3b7 100644 --- a/drb-frontend/app/calls/page.tsx +++ b/drb-frontend/app/calls/page.tsx @@ -24,6 +24,7 @@ import { Button } from "@/components/ui/Button"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonCard } from "@/components/ui/Skeleton"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; +import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange"; type LinkFilter = "any" | "orphan" | "linked"; type TranscriptFilter = "any" | "yes" | "no"; @@ -239,6 +240,8 @@ export default function ArchivePage() { const [systemId, setSystemId] = useState(""); const [q, setQ] = useState(""); const [submittedQ, setSubmittedQ] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); useEffect(() => { if (!authLoading && !canView) router.replace("/"); @@ -256,6 +259,8 @@ export default function ArchivePage() { transcript, system_id: systemId || undefined, q: submittedQ || undefined, + date_from: dayStart(dateFrom)?.toISOString(), + date_to: dayEnd(dateTo)?.toISOString(), }); setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls)); setCursor(res.next_cursor); @@ -266,7 +271,7 @@ export default function ArchivePage() { setLoading(false); } }, - [link, transcript, systemId, submittedQ], + [link, transcript, systemId, submittedQ, dateFrom, dateTo], ); // Reload from the top whenever a filter changes. @@ -335,6 +340,8 @@ export default function ArchivePage() { ))} + { setDateFrom(f); setDateTo(t); }} /> +
{ e.preventDefault(); setSubmittedQ(q.trim()); }} className="flex items-center gap-2 ml-auto" diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index a01da7a..ba49063 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonCard } from "@/components/ui/Skeleton"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; +import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange"; import { isKnownSeverity, severityRank } from "@/lib/severity"; import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark"; import { TypeGlyph } from "@/components/marks/TypeGlyph"; @@ -196,7 +197,11 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo export default function IncidentsPage() { const { isAdmin } = useAuth(); 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 [showCreate, setShowCreate] = useState(false); const [severityFilter, setSeverityFilter] = useState("all"); @@ -228,9 +233,10 @@ export default function IncidentsPage() { return list; // useIncidents() already orders by started_at desc }, [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() { setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch(""); + setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE); } const hiddenCount = incidents.length - filtered.length; @@ -308,6 +314,11 @@ export default function IncidentsPage() { {INCIDENT_TYPES.map((t) => )} + { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }} + />