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:
+168
-9
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1061,6 +1061,26 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Phase 175 (TODO.md L3): the list pager — visible only when
|
||||
there is more than one page (total > the 25-row page size —
|
||||
history.js toggles `hidden`), so a one-page list never shows a
|
||||
dead control. The buttons reuse the .history-refresh pill
|
||||
language (the house inline-SVG glyph + visible label +
|
||||
aria-label; the ≤640px full-width block joins
|
||||
.history-refresh). #history-pager-status carries "Page N of M";
|
||||
page-change outcome lines land in #history-status (the existing
|
||||
live region). -->
|
||||
<div class="history-pager" id="history-pager" hidden>
|
||||
<button type="button" class="history-pager-btn" id="history-pager-prev" aria-label="Previous page of saved chats">
|
||||
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<span class="history-pager-label">Prev</span>
|
||||
</button>
|
||||
<span class="history-pager-status" id="history-pager-status"></span>
|
||||
<button type="button" class="history-pager-btn" id="history-pager-next" aria-label="Next page of saved chats">
|
||||
<span class="history-pager-label">Next</span>
|
||||
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user