/** * Paging the catalogue: every decision worth testing, as pure functions. * * Pure because that is the only thing this project can unit-test — vitest runs * in a node environment with no jsdom and no testing-library, so a hook or a * component can only be exercised through Playwright. Keeping the rules here * means the rules have tests and the React wrapper is thin enough not to need * any. Same split `filters.ts` already uses for the URL. * * Paging is client-side. `GET /api/items` still returns everything and the * client still asks for everything; moving it server-side is a separate change * and should be measured rather than assumed, since the filter endpoints * already do work per request. See #269. */ /** * Ten, because it is the conservative default and someone who wants a denser * grid can say so once and be remembered. */ export const DEFAULT_PAGE_SIZE = 10; /** The sizes offered in the control. A value outside this list is not honoured. */ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; export const PAGE_SIZE_STORAGE_KEY = 'catalogue:pageSize'; /** * A page size, from whatever was supplied. * * Checked against the list rather than merely parsed as a number. A stored or * hand-edited 5000 would render the entire catalogue in one page, which is the * exact failure #269 exists to prevent, so an unrecognised size is refused * rather than clamped — clamping would quietly honour a value nobody offered. */ export function readPageSize(raw: string | null): number { const parsed = Number(raw); const allowed = (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed); return raw !== null && raw !== '' && allowed ? parsed : DEFAULT_PAGE_SIZE; } /** * The page a URL asks for. * * Falls back to the first page for anything unreadable, which is the rule * `filtersFromSearchParams` already applies to a mangled filter: a bad link * lands somewhere sensible rather than on an error. */ export function pageFromSearchParams(params: URLSearchParams): number { const parsed = Number(params.get('page')); return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1; } /** * The page actually shown, given how much there is to show. * * A shared link to page 7 of a catalogue that has since shrunk to two pages * shows the last page rather than an empty grid — an empty page would read as * "this shop has nothing", which is the confusion the empty state exists to * avoid. */ export function clampPage(page: number, total: number, pageSize: number): number { const lastPage = Math.max(1, Math.ceil(total / pageSize)); return Math.min(Math.max(1, page), lastPage); } /** One page of items. Past the end this is empty rather than an error. */ export function pageSlice(items: readonly T[], page: number, pageSize: number): T[] { const start = (page - 1) * pageSize; return items.slice(start, start + pageSize); } /** * The remembered page size. * * Every access is guarded. `localStorage` is absent when there is no window at * all and throws outright in some privacy modes, and neither is a reason for a * customer to lose the catalogue — the worst acceptable outcome of a broken * preference is the default. */ export function readStoredPageSize(storage: Pick | null): number { if (storage === null) return DEFAULT_PAGE_SIZE; try { return readPageSize(storage.getItem(PAGE_SIZE_STORAGE_KEY)); } catch { return DEFAULT_PAGE_SIZE; } } /** Remembers a choice, or quietly does not. See `readStoredPageSize`. */ export function writeStoredPageSize( storage: Pick | null, size: number ): void { if (storage === null) return; try { storage.setItem(PAGE_SIZE_STORAGE_KEY, String(size)); } catch { // A preference that cannot be remembered is not worth an error. The // customer's current page size still works for this visit. } }