/** URL-state sorting for server-rendered index tables (operators, countries, markets, projects, cloud regions…). */ export type SearchParams = Record; export function spGet(sp: SearchParams, key: string): string | undefined { const v = sp[key]; const s = Array.isArray(v) ? v[0] : v; return s === undefined || s === "" ? undefined : s; } export function spInt(sp: SearchParams, key: string, fallback = 1): number { const n = Number(spGet(sp, key)); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; } /** Build an href for the same page with params merged (undefined removes; `page` reset unless kept). */ export function hrefWith(base: string, sp: SearchParams, patch: Record, opts: { keepPage?: boolean; anchor?: string } = {}): string { const out = new URLSearchParams(); for (const [k, v] of Object.entries(sp)) { if (v === undefined) continue; if (k === "page" && !opts.keepPage) continue; out.set(k, Array.isArray(v) ? v[0] ?? "" : v); } for (const [k, v] of Object.entries(patch)) { if (v === undefined || v === null || v === "") out.delete(k); else out.set(k, String(v)); } const qs = out.toString(); return `${base}${qs ? `?${qs}` : ""}${opts.anchor ? `#${opts.anchor}` : ""}`; } export interface SortState { key: K; order: "asc" | "desc"; } /** Read ?sort=&order= against an allow-list. */ export function parseSort(sp: SearchParams, allowed: readonly K[], fallback: SortState): SortState { const s = spGet(sp, "sort"); const o = spGet(sp, "order"); const key = (allowed as readonly string[]).includes(s ?? "") ? (s as K) : fallback.key; const order = o === "asc" || o === "desc" ? o : key === fallback.key ? fallback.order : defaultOrderFor(key); return { key, order }; } function defaultOrderFor(key: string): "asc" | "desc" { return key === "name" || key === "code" || key === "provider" || key === "country" || key === "region" ? "asc" : "desc"; } /** Props for given the current state. */ export function sortTh(base: string, sp: SearchParams, state: SortState, key: K): { sort: "asc" | "desc" | null; sortHref: string } { const active = state.key === key; const next = active ? (state.order === "asc" ? "desc" : "asc") : defaultOrderFor(key); return { sort: active ? state.order : null, sortHref: hrefWith(base, sp, { sort: key, order: next }) }; } /** Generic in-memory comparator honouring nulls last. */ export function compareBy(get: (t: T) => number | string | null | undefined, order: "asc" | "desc") { return (a: T, b: T): number => { const va = get(a); const vb = get(b); const na = va === null || va === undefined || va === ""; const nb = vb === null || vb === undefined || vb === ""; if (na && nb) return 0; if (na) return 1; if (nb) return -1; let c: number; if (typeof va === "number" && typeof vb === "number") c = va - vb; else c = String(va).localeCompare(String(vb), "en", { sensitivity: "base", numeric: true }); return order === "asc" ? c : -c; }; } /** Slice for client-agnostic pagination of an in-memory list. */ export function paginate(items: T[], page: number, perPage: number): { items: T[]; page: number; pages: number } { const pages = Math.max(1, Math.ceil(items.length / perPage)); const p = Math.min(Math.max(1, page), pages); return { items: items.slice((p - 1) * perPage, p * perPage), page: p, pages }; }