Files
Jyotisha/frontend/src/lib/report-cursor.ts
T

97 lines
3.7 KiB
TypeScript

/**
* 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<T extends Keyed>(
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<T extends Keyed>(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))];
}