/** * Keyset paging for 「过往的报告」 (2026-09-30, BUG-1124): newest first by * (created_at desc, id desc), `limit + 1` rows to know whether more exist. * Modelled on session-cursor.ts; reports page on created_at, not updated_at. */ const reportIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** One batch under 「加载更多」 (product: 10 at a time). Also the first page. */ export const REPORT_PAGE_SIZE = 10; export const REPORT_PAGE_LIMIT_MAX = 50; export type ReportCursor = { readonly createdAt: string; readonly id: string; }; export function clampReportLimit(raw: string | null | undefined): number { if (raw == null || raw === "") return REPORT_PAGE_SIZE; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) return REPORT_PAGE_SIZE; return Math.min(REPORT_PAGE_LIMIT_MAX, Math.max(1, Math.trunc(parsed))); } export function encodeReportCursor(createdAt: string, id: string): string { return `${createdAt},${id}`; } export function parseReportCursor(raw: string | null | undefined): ReportCursor | null { if (!raw) return null; const comma = raw.lastIndexOf(","); if (comma <= 0 || comma === raw.length - 1) return null; const createdAt = raw.slice(0, comma); const id = raw.slice(comma + 1); if (!Number.isFinite(Date.parse(createdAt))) return null; if (!reportIdPattern.test(id)) return null; return { createdAt, id }; } /** PostgREST `or=` filter: strictly older than the cursor row. */ export function reportCursorFilter(cursor: ReportCursor): string { const createdAt = JSON.stringify(cursor.createdAt); const id = JSON.stringify(cursor.id); return `created_at.lt.${createdAt},and(created_at.eq.${createdAt},id.lt.${id})`; } function rawCreatedAt(value: unknown): string { if (typeof value === "string") return Number.isFinite(Date.parse(value)) ? value : ""; if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); return ""; } /** The cursor after the `limit`-th row when a `limit + 1`-th row came back, else null. */ export function nextReportCursor( rows: readonly unknown[], limit: number, ): string | null { if (rows.length <= limit) return null; const last = rows[limit - 1]; if (!last || typeof last !== "object") return null; const row = last as { id?: unknown; created_at?: unknown }; const createdAt = rawCreatedAt(row.created_at); if (!createdAt || typeof row.id !== "string") return null; return encodeReportCursor(createdAt, row.id); } type Keyed = { readonly id: string; readonly createdAt: string }; /** Newest first, as the endpoint orders them. */ function olderThan(left: Keyed, right: Keyed): boolean { const l = Date.parse(left.createdAt); const r = Date.parse(right.createdAt); if (l !== r) return l < r; return left.id < right.id; } /** * A fresh first page merged over the rows already on screen: the first page * wins for every row it carries; rows the user loaded with 「加载更多」 that are * older than the first page's last row stay. Used by the 3s poll and 刷新, so * a refresh never throws away pages the user opened. */ export function mergeFirstReportPage( firstPage: readonly T[], loaded: readonly T[], ): T[] { const tail = firstPage[firstPage.length - 1]; if (!tail) return []; const ids = new Set(firstPage.map((row) => row.id)); return [...firstPage, ...loaded.filter((row) => !ids.has(row.id) && olderThan(row, tail))]; } /** One more page appended after what is on screen; duplicates are dropped. */ export function appendReportPage(loaded: readonly T[], page: readonly T[]): T[] { const ids = new Set(loaded.map((row) => row.id)); return [...loaded, ...page.filter((row) => !ids.has(row.id))]; }