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:
co-authored by
Claude Opus 5.5
parent
c043298902
commit
6479174022
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user