From c06429e4d53b98c6cd2cbef882891043a8218cd5 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 15 Jun 2026 13:50:15 +0200 Subject: [PATCH] =?UTF-8?q?Add=20paginate=20helper=20(todo=20=C2=A71);=20p?= =?UTF-8?q?age=20model=20with=20row=20window=20+=20ellipsis=20sequence=20f?= =?UTF-8?q?or=20pagination.ejs,=20clamped/guarded=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/paginate.test.ts | 43 +++++++++++++++++++++++++++ src/paginate.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ todo.md | 2 +- 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/paginate.test.ts create mode 100644 src/paginate.ts diff --git a/README.md b/README.md index 863e848..813b789 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, po src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model +src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) diff --git a/src/paginate.test.ts b/src/paginate.test.ts new file mode 100644 index 0000000..34c7f36 --- /dev/null +++ b/src/paginate.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { paginate, type PageModel } from "./paginate.ts"; + +// Compact view of the page sequence: ellipsis → "…", current → "[n]", else the number. +const shape = (m: PageModel): (number | string | null)[] => + m.pages.map((p) => (p.ellipsis ? "…" : p.current ? `[${p.page}]` : p.page)); + +test("paginate computes the page model: counts, row window, prev/next, page sequence", () => { + const m = paginate(1284, 3, 12); + assert.deepEqual( + { from: m.from, next: m.next, page: m.page, pageCount: m.pageCount, pageSize: m.pageSize, prev: m.prev, to: m.to, total: m.total }, + { from: 25, next: 4, page: 3, pageCount: 107, pageSize: 12, prev: 2, to: 36, total: 1284 }, + ); + assert.deepEqual(shape(m), [1, 2, "[3]", 4, "…", 107]); +}); + +test("paginate clamps out-of-range input, handles the empty list and guards sizes", () => { + // Page past the end clamps to the last page; no next. + const last = paginate(50, 99, 25); + assert.deepEqual([last.page, last.pageCount, last.from, last.to, last.prev, last.next], [2, 2, 26, 50, 1, null]); + assert.deepEqual(shape(last), [1, "[2]"]); + + // Empty list → one empty page, row window 0–0, no prev/next. + const empty = paginate(0, 1, 25); + assert.deepEqual([empty.page, empty.pageCount, empty.from, empty.to, empty.prev, empty.next], [1, 1, 0, 0, null, null]); + assert.deepEqual(shape(empty), ["[1]"]); + + // page < 1 → 1; pageSize < 1 coerces to 1; non-finite page → 1. + assert.equal(paginate(50, 0, 25).page, 1); + assert.equal(paginate(10, 1, 0).pageCount, 10); + assert.equal(paginate(50, Number.NaN, 25).page, 1); +}); + +test("paginate windows the sequence: single gaps fill, wider gaps ellipsize, siblings/boundaries tune it", () => { + // A one-page gap is filled, never collapsed to an ellipsis. + assert.deepEqual(shape(paginate(70, 4, 10)), [1, 2, 3, "[4]", 5, 6, 7]); + // Gaps on both sides → an ellipsis each side. + assert.deepEqual(shape(paginate(200, 10, 10)), [1, "…", 9, "[10]", 11, "…", 20]); + // Wider sibling window and more boundary pages. + assert.deepEqual(shape(paginate(200, 10, 10, { siblings: 2 })), [1, "…", 8, 9, "[10]", 11, 12, "…", 20]); + assert.deepEqual(shape(paginate(200, 10, 10, { boundaries: 2 })), [1, 2, "…", 9, "[10]", 11, "…", 19, 20]); +}); diff --git a/src/paginate.ts b/src/paginate.ts new file mode 100644 index 0000000..8bc7de3 --- /dev/null +++ b/src/paginate.ts @@ -0,0 +1,70 @@ +// paginate (todo §1): pagination math → the model pagination.ejs renders. Pure and +// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each +// page number to an href. Inputs are clamped/guarded so it never produces a broken model: +// page is pinned to [1, pageCount], total/pageSize coerced to sane integers. + +export interface PageItem { + current: boolean; + ellipsis: boolean; // a gap; `page` is null + page: number | null; +} + +export interface PageModel { + from: number; // 1-based index of the first row on this page (0 when empty) + next: number | null; // next page, or null on the last page + page: number; // clamped current page (≥ 1) + pageCount: number; // total pages (≥ 1) + pageSize: number; // effective page size (≥ 1) + pages: PageItem[]; // page-number sequence with ellipsis gaps + prev: number | null; // previous page, or null on the first page + to: number; // 1-based index of the last row on this page (0 when empty) + total: number; // effective total rows (≥ 0) +} + +export interface PaginateOptions { + boundaries?: number; // pages always shown at each end (default 1) + siblings?: number; // pages shown each side of the current page (default 1) +} + +export function paginate(total: number, page: number, pageSize: number, options: PaginateOptions = {}): PageModel { + const t = Math.max(0, Math.floor(total) || 0); + const size = Math.max(1, Math.floor(pageSize) || 0); + const pageCount = Math.max(1, Math.ceil(t / size)); + const reqPage = Number.isFinite(page) ? Math.floor(page) : 1; + const current = Math.min(Math.max(reqPage, 1), pageCount); + return { + from: t === 0 ? 0 : (current - 1) * size + 1, + next: current < pageCount ? current + 1 : null, + page: current, + pageCount, + pageSize: size, + pages: pageItems(current, pageCount, options.siblings ?? 1, options.boundaries ?? 1), + prev: current > 1 ? current - 1 : null, + to: Math.min(current * size, t), + total: t, + }; +} + +const item = (page: number, current = false): PageItem => ({ current, ellipsis: false, page }); +const gap = (): PageItem => ({ current: false, ellipsis: true, page: null }); + +// First/last `boundaries` pages + a `siblings`-wide window around current, deduped and sorted; +// gaps wider than one page become an ellipsis, a lone missing page is shown instead. +function pageItems(current: number, pageCount: number, siblings: number, boundaries: number): PageItem[] { + const show = new Set(); + const add = (n: number): void => { if (n >= 1 && n <= pageCount) show.add(n); }; + for (let i = 1; i <= boundaries; i++) { add(i); add(pageCount - i + 1); } + for (let n = current - siblings; n <= current + siblings; n++) add(n); + + const items: PageItem[] = []; + let prev = 0; + for (const n of [...show].sort((a, b) => a - b)) { + if (prev) { + if (n - prev === 2) items.push(item(prev + 1)); // single hole → show the page + else if (n - prev > 2) items.push(gap()); + } + items.push(item(n, n === current)); + prev = n; + } + return items; +} diff --git a/todo.md b/todo.md index 5496de7..ce14f46 100644 --- a/todo.md +++ b/todo.md @@ -36,7 +36,7 @@ everything via Docker. - [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `
` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults. - [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix. - [x] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix. -- [ ] Helper `paginate(total, page, pageSize)` → page model. +- [x] Helper `paginate(total, page, pageSize)` → page model. → `src/paginate.ts`: pure, URL-free math feeding `pagination.ejs`; caller maps page numbers → hrefs. Returns `{ from, to, page, pageCount, pageSize, prev, next, total, pages }`. Inputs clamped/guarded (page pinned to [1,pageCount], total/pageSize coerced to sane ints, empty list ⇒ 1 page / 0–0). `pages` = first/last `boundaries` + `siblings`-wide window around current, sorted/deduped, with ellipsis for gaps >1 (a lone hole is shown, not collapsed); `siblings`/`boundaries` overridable. `paginate.test.ts` covers model/clamp/empty/windowing. - [ ] Replace placeholder `index` with the app-shell dashboard. - [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics. - [ ] Go over all HTML and CSS and make adjust it to be as sematic as we can, css classes, ids html elements and all, then add semantic DOM as a priority in this project.