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
+168 -9
View File
@@ -112,6 +112,47 @@
* — try again.` on a non-2xx), so EVERY caller of a failed load —
* the mount's first load, a re-show, the button — sees the outcome
* (the §7.4 never-stale contract; a silent empty table is gone).
*
* Phase 175 (task 02) — the list pager (TODO.md L3, owner 2026-10-10:
* "Need pagination on the history page - it gets long fast"):
*
* • the load is parameterized: `loadChats(pageOffset)` fetches
* `/api/chats?limit=<PAGE_SIZE>&offset=<pageOffset>` (the manual
* string join — the file's house style) and reads the body's
* `total` (the server's full count) into the pager via
* `updatePager(total)`. Module state: `PAGE_SIZE` (25 — the
* server's default page; A1 — no size picker in the UI, the E2E
* suite catches a drift) and the current page's `offset`
* (starts at 0 — the view opens on page 1).
* • `updatePager(total)` runs on EVERY 200 (a 0-row fetch included
* — the deleted-behind-us race): `total <= PAGE_SIZE` → the
* pager is hidden (A4 — no dead "Page 1 of 1" control; it
* disappears with the empty state); otherwise it shows, the
* status reads `Page N of M`, Prev is disabled on page 1, Next
* on the last page (the real `disabled` — the Refresh idiom;
* the edges make the click clamping trivial). A FAILED load
* (non-2xx / network) leaves the pager as it was — the
* previous state is the last known truth.
* • Prev/Next (bound in the ADMIN branch only — the SAME gate as
* Refresh, D5; the anonymous branch never touches the pager)
* move the offset by PAGE_SIZE, re-run the re-entrant load (the
* rows are REPLACED, never appended), and land the never-stale
* `Showing <first>–<last> of <total>.` line in #history-status.
* Refresh (the button AND the re-show listener) re-fetches the
* CURRENT page — `loadChats(offset)`, never 0 (A5). A local
* Delete does NOT touch the pager or the offset — the next load
* (a page click or a Refresh) corrects it (D4).
*
* • a settled load lands its OWN never-stale `Showing …` line
* (task 03): the mount is fire-and-forget (the house-style
* `loadChats(0);` line), so the announcement lives in
* `loadChats` itself, where the settled `total` is in hand. It
* lands ONLY while the pager is visible (total > PAGE_SIZE):
* a one-page list IS the whole list (A4 — no redundant line),
* and the 0-row branch shows the empty state (nothing to show).
* A page change's identical line overwrites it (changePage),
* and Refresh's `Saved chats refreshed.` overwrites it on the
* refresh path.
*/
import { fetchIsAdmin } from "./header.js";
@@ -126,6 +167,16 @@ export async function mount(root) {
// Phase 77 (task 03): the explicit refresh control — the page-head
// button (outside the table wrap, so the empty state never hides it).
const refreshBtn = root.querySelector("#history-refresh");
// Phase 175 (task 02, D3): the list pager — under the table card
// (Prev / "Page N of M" / Next). It SHIPS hidden in the markup;
// only the ADMIN branch's load lifecycle (updatePager) reveals it,
// and only while total > PAGE_SIZE (A4 — no dead "Page 1 of 1"
// control). The anonymous branch never touches it (D5 — no dead
// control beside the gate).
const pagerEl = root.querySelector("#history-pager");
const pagerPrevBtn = root.querySelector("#history-pager-prev");
const pagerNextBtn = root.querySelector("#history-pager-next");
const pagerStatusEl = root.querySelector("#history-pager-status");
/* Action feedback — the role="status" live region above the table
(the "never stale" contract: every row action lands a line here,
@@ -499,8 +550,67 @@ export async function mount(root) {
if (emptyRow) emptyRow.hidden = false;
}
/* GET /api/chats → render the rows (latest activity first — the
server's order). A 0-row fetch shows the empty-state row.
/* Phase 175 (task 02, D4): the pager's state. PAGE_SIZE matches
the server's default page (A1: no size picker in the UI — the
E2E suite catches a limit drift); `offset` is the current
page's offset (it starts at 0 — the view opens on page 1);
`total` is the server's last full count — the "of N" truth (a
failed load leaves it; the previous state is the last known
truth). */
const PAGE_SIZE = 25;
let offset = 0;
let total = 0;
/* Phase 175 (task 02, D3): the pager's single source of truth —
the load's `updatePager(total)` runs it on EVERY 200 (a 0-row
fetch included — the deleted-behind-us race):
• `total <= PAGE_SIZE` → the pager is hidden (A4 — no dead
"Page 1 of 1" control; it disappears with the empty state);
• otherwise it shows, #history-pager-status reads `Page N of M`,
Prev is disabled on page 1, Next on the last page (the real
`disabled` attribute — the Refresh idiom; the edges make the
click clamping trivial).
A FAILED load (non-2xx / network) never reaches here — the
pager keeps its previous state, the last known truth. */
function updatePager(count) {
total = count;
if (!pagerEl) return;
const pages = Math.ceil(total / PAGE_SIZE);
if (total <= PAGE_SIZE) {
pagerEl.hidden = true; // A4 — a one-page list never shows a dead control
return;
}
pagerEl.hidden = false;
if (pagerStatusEl) {
pagerStatusEl.textContent =
"Page " + (offset / PAGE_SIZE + 1) + " of " + pages;
}
if (pagerPrevBtn) pagerPrevBtn.disabled = offset === 0;
if (pagerNextBtn) pagerNextBtn.disabled = offset + PAGE_SIZE >= total;
}
/* Phase 175 (task 02, D4): a page change — the Prev/Next click has
ALREADY moved the module's offset by PAGE_SIZE (the button's
disabled edge made it a legal move), this re-runs the
re-entrant load (rows are REPLACED, never appended) and, on
success, lands the never-stale `Showing <first>–<last> of
<total>.` line in #history-status. A failed load keeps the
pager (loadChats' own error line is the feedback — the §7.4
contract). */
async function changePage() {
const ok = await loadChats(offset);
if (ok) {
announce(
"Showing " + (offset + 1) + "–" +
Math.min(offset + PAGE_SIZE, total) + " of " + total + ".",
);
}
}
/* GET /api/chats?limit=<PAGE_SIZE>&offset=<pageOffset> (phase 175:
the parameterized fetch) → render the rows (latest activity
first — the server's order). A 0-row fetch shows the
empty-state row.
Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the
list, not append a duplicate set — the data rows (every <tr>
EXCEPT the hidden #history-empty-row, which the load itself
@@ -510,8 +620,13 @@ export async function mount(root) {
"try again.") — and the load RETURNS the outcome: true when the
fetch settled (a 0-row fetch is a SUCCESS — the empty state is
the honest view), false on non-2xx / network error, so the
refresh button's handler can land its own success line. */
async function loadChats() {
refresh button's handler can land its own success line. A failed
load leaves the pager as it was (the previous state is the last
known truth).
Phase 175 (task 02): the 200 path reads the body's `total` (the
server's full count) and runs updatePager — the pager tracks
every settled load. */
async function loadChats(pageOffset) {
if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) {
if (tr !== emptyRow) tr.remove();
@@ -520,7 +635,10 @@ export async function mount(root) {
if (emptyRow) emptyRow.hidden = true;
let r;
try {
r = await fetch("/api/chats");
/* Phase 175 (task 02, D4): the parameterized fetch — the
manual string join (the file's house style): the 25-row page
(PAGE_SIZE — the server's default) at the given offset. */
r = await fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset);
} catch {
announce("Couldn't load saved chats — is the app reachable?");
showEmptyState();
@@ -531,7 +649,8 @@ export async function mount(root) {
showEmptyState();
return false;
}
const { chats } = await r.json();
const { chats, total } = await r.json();
updatePager(total); // phase 175: the pager tracks the fresh total (a 0-row fetch included — the deleted-behind-us race)
if (!chats.length) {
showEmptyState();
return true;
@@ -539,6 +658,21 @@ export async function mount(root) {
for (const chat of chats) {
tbody.appendChild(makeRow(chat));
}
/* Phase 175 (task 03, the never-stale contract's first-load
half): a settled load lands its own `Showing <first>–<last>
of <total>.` line (the same formula a page change lands —
changePage's line overwrites it with the identical text;
Refresh's `Saved chats refreshed.` overwrites it on the
refresh path). It lands ONLY while the pager is visible
(total > PAGE_SIZE): a one-page list IS the whole list
(A4 — no redundant line) and the 0-row branch above never
reaches here (the empty state is the honest view). */
if (total > PAGE_SIZE) {
announce(
"Showing " + (pageOffset + 1) + "–" +
Math.min(pageOffset + PAGE_SIZE, total) + " of " + total + ".",
);
}
return true;
}
@@ -549,7 +683,10 @@ export async function mount(root) {
async function refreshChats() {
let ok = false;
try {
ok = await loadChats();
/* Phase 175 (task 02, A5): refresh re-fetches the CURRENT
page — the module's offset, never 0 (a page-2 refresh must
land page-2 rows, not reset the list). */
ok = await loadChats(offset);
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
@@ -581,7 +718,7 @@ export async function mount(root) {
the listener can only ever re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) loadChats();
if (started) loadChats(offset); // phase 175 (A5): the re-show re-fetches the CURRENT page (never resets to 0)
});
/* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) —
the button's own lifecycle: click → disable (no double-fire while
@@ -595,6 +732,28 @@ export async function mount(root) {
void refreshChats();
});
}
/* Phase 175 (task 02, D5): the pager's Prev / Next — bound HERE,
in the admin branch only (the SAME gate as Refresh; the
anonymous branch never touches the pager — it keeps its
markup-shipped hidden state, D5). Each click moves the
module's offset by PAGE_SIZE in its direction (the button's
disabled edge made it a legal move — clamping is trivial) and
re-runs the re-entrant load; on success the `Showing …` line
lands in the live region (changePage). A local Delete does NOT
touch the pager or the offset — the next load (a page click or
a Refresh) corrects it (D4). */
if (pagerPrevBtn) {
pagerPrevBtn.addEventListener("click", () => {
offset -= PAGE_SIZE;
void changePage();
});
}
if (pagerNextBtn) {
pagerNextBtn.addEventListener("click", () => {
offset += PAGE_SIZE;
void changePage();
});
}
started = true;
loadChats();
loadChats(0); // phase 175: the view opens on page 1 (offset 0)
}
+39 -7
View File
@@ -4010,7 +4010,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
gap: 0.75rem 1.5rem;
}
#view-history .page-head .page-head-title { min-width: 0; }
.history-refresh {
.history-refresh, .history-pager-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -4028,12 +4028,38 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
cursor: pointer;
flex-shrink: 0;
}
.history-refresh:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
.history-refresh:hover, .history-pager-btn:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled, .history-pager-btn:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); below 640px it joins the visible label in the full-width
pill (the <=640 block — the icon-only squeeze is gone). */
.history-refresh svg { width: 16px; height: 16px; display: none; }
pill (the <=640 block — the icon-only squeeze is gone). The
phase-175 pager buttons join the rule (the selector union — the
same glyph language: visible label at every width, the glyph is
the mobile companion). */
.history-refresh svg, .history-pager-btn svg { width: 16px; height: 16px; display: none; }
/* ---------- History pager (phase 175, task 02 — TODO.md L3) ----------
"Need pagination on the history page - it gets long fast" (owner
2026-10-10). Root cause: the saved-chat list used to fetch and
render EVERY row; the server now ships a 25-row page + the full
count, and this row is the walk control — Prev / "Page N of M" /
Next, sitting UNDER the table card (index.html, phase 175).
Token reuse (B3): the buttons join the .history-refresh pill
through the selector unions above (the solid brand pill, the
:disabled opacity, the svg sizing — NO new palette variable), and
the row's gap is the page's OWN 0.75rem (the #view-history
.page-head row gap — no new spacing token). Hidden by default
(the markup ships `hidden`): history.js's updatePager() reveals
the row only while `total > PAGE_SIZE` (A4 — no dead "Page 1 of
1" control; it disappears with the empty state). The status span
needs no rule beyond the inherited text. Below 640px the row
stacks (the .settings-actions idiom) and the pills go full width
(the .history-refresh mobile idiom — the ≤640px block below). */
.history-pager {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ---------- History page (phase 50) ----------
/history.html: the admin-only saved-chats list (task 04). The
@@ -6486,8 +6512,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.sync-btn { width: 100%; }
.sync-cancel-btn { width: 100%; } /* phase 134: the Cancel pill stacks full-width under the Sync pill (the row already wraps) */
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
.history-refresh { width: 100%; }
.history-refresh svg { display: block; }
.history-refresh, .history-pager-btn { width: 100%; }
.history-refresh svg, .history-pager-btn svg { display: block; }
/* Phase 175 (task 02, D6): the pager row STACKS (the
.settings-actions idiom) so the full-width Prev / Next pills
don't side-by-side overflow the phone width (the pill rule's
flex-shrink: 0 would keep each at 100%); the "Page N of M"
status sits between them (the 0.75rem gap goes vertical). */
.history-pager { flex-direction: column; align-items: center; }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no