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>
This commit is contained in:
Logan Cusano
2026-09-24 01:03:40 -04:00
co-authored by Claude Opus 5.5
parent c043298902
commit 6479174022
7 changed files with 161 additions and 11 deletions
+34 -3
View File
@@ -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: