phase: 175_history_pagination
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
This commit is contained in:
2026-10-11 04:12:53 -04:00
parent 938d6cf09d
commit 375169bbfc
31 changed files with 2339 additions and 61 deletions
+41 -14
View File
@@ -27,9 +27,15 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
(phase 14 — raw text, never HTML), so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
History's Share and Stale columns render without a second fetch),
Routes: ``GET`` (list — paginated, phase 175 task 01: ``limit``
(default 25, clamped 1–100) + ``offset`` (default 0); out-of-range
values 422 via FastAPI's field-message validation — no hand-rolled
checks — and the body reports the full ``total`` (one small count
query per request) so a client can walk the pages; rows are one
PAGE in the History page's table order, latest activity first
(``updated_at desc, id desc``); no payloads, but the phase-51
``share_url`` and the phase-53 ``stale`` flag so the History's
Share and Stale columns render without a second fetch),
``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract;
@@ -74,9 +80,9 @@ from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import select, text
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.config import get_settings
@@ -173,20 +179,41 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def list_chats(
limit: int = Query(25, ge=1, le=100), # noqa: B008
offset: int = Query(0, ge=0), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order, admin-only (the owner's
History surface; phase 55 moved the gate per-route). Each row
carries the
phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries."""
"""One PAGE of the saved chats (phase 175, task 01), latest
activity first (``updated_at desc, id desc``) — the History
page's table order, admin-only (the owner's History surface;
phase 55 moved the gate per-route).
``limit`` (default 25, clamped 1–100) and ``offset`` (default 0)
are FastAPI-validated: ``limit=0`` / ``limit=101`` / ``offset=-1``
422 with the house field-message contract (no hand-rolled checks).
A bare ``GET`` is the first 25 rows; an offset beyond the end is
honest — an empty ``chats`` list with the real ``total``.
``total`` is the FULL count across all saved chats: one small
``count`` query per request (two small queries total; the
once-per-request ``current_sources_version`` read is untouched),
so a client can walk the pages and know when it has seen them
all. Each row carries the phase-53 ``stale`` flag: the current
generation is read ONCE per request (one PK read of the seeded
row) and compared against every row's stamp in the serializer
helpers — no per-row queries."""
current_version = current_sources_version(db)
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
select(SavedChat)
.order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
.offset(offset)
.limit(limit)
).all()
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
total = db.scalar(select(func.count(SavedChat.id))) or 0
return SavedChatList(
chats=[_to_row(row, current_version) for row in rows],
total=total,
)
@router.post("", response_model=SavedChatOut, status_code=201)