phase: 175_history_pagination
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:
+41
-14
@@ -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)
|
||||
|
||||
+10
-2
@@ -1723,10 +1723,18 @@ class SavedChatRow(BaseModel):
|
||||
|
||||
|
||||
class SavedChatList(BaseModel):
|
||||
"""``GET /api/chats`` response: saved chats, latest activity first
|
||||
(``updated_at desc, id desc``)."""
|
||||
"""``GET /api/chats`` response: one PAGE of saved chats (phase
|
||||
175, task 01 — ``limit``/``offset``), latest activity first
|
||||
(``updated_at desc, id desc``).
|
||||
|
||||
``total`` is the FULL count across all saved chats (not the
|
||||
page's length) — one small count query per request; the wire
|
||||
shape always carries it."""
|
||||
|
||||
chats: list[SavedChatRow]
|
||||
#: Required (no default): the API must always compute the full
|
||||
#: count server-side — there is no wire shape without it.
|
||||
total: int
|
||||
|
||||
|
||||
class SharedChatOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user