import { fetchChartView } from "@/lib/chart-view-client"; import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject"; import type { ChartViewResponse } from "@/lib/chart-view-contract"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; export const REPORTS_WAITING_COPY = "报告列表还没拿到。"; export const EPHEMERIS_WAITING_COPY = "这一天的星历还没拿到。"; export type ChartPageSnapshot = | { kind: "view"; view: ChartViewResponse } | { kind: "unauthenticated" }; export type EphemerisPageSnapshot = | { kind: "ready"; payload: EphemerisOkResponse } | { kind: "unauthorized" }; export type ReportListItem = Readonly<{ id: string; reportType: string; presentationMode: string; depth: string; themes: readonly string[]; status: "generating" | "ready" | "failed"; failureCode: string | null; failureSummary?: string | null; createdAt: string; completedAt: string | null; cardSummary?: string | null; }>; export type ReportsPageSnapshot = | { kind: "ready"; reports: readonly ReportListItem[] } | { kind: "unauthorized" } | { kind: "error"; reports: readonly ReportListItem[] }; type MemoryCache = { peek: () => T | null; write: (value: T) => T; refresh: () => Promise; prefetch: () => void; reset: () => void; }; function createMemoryCache(load: () => Promise): MemoryCache { let value: T | null = null; let inflight: Promise | null = null; let epoch = 0; let scope = ""; const currentScope = () => JSON.stringify([readCurrentSubjectAccountId(), readCurrentSubjectId()]); const syncScope = () => { const next = currentScope(); if (scope === next) return; scope = next; epoch += 1; value = null; inflight = null; }; const refresh = (): Promise => { syncScope(); if (inflight) return inflight; const requestEpoch = epoch; const requestScope = scope; const request = load() .then((next) => { syncScope(); if (scope !== requestScope) throw new Error("secondary_page_scope_changed"); if (epoch === requestEpoch) value = next; return next; }) .finally(() => { if (inflight === request) inflight = null; }); inflight = request; return request; }; return { peek: () => { syncScope(); return value; }, write: (next) => { syncScope(); value = next; return next; }, refresh, prefetch: () => { void refresh(); }, reset: () => { epoch += 1; value = null; inflight = null; }, }; } export type ChartSnapshotIdentity = { accountId: string; fingerprint: string; subjectId?: string; }; type ChartCacheSlot = { identity: ChartSnapshotIdentity | null; snapshot: ChartPageSnapshot; }; let chartEpoch = 0; let chartAccountReadGeneration = 0; let pinnedChartIdentity: ChartSnapshotIdentity | null = null; let chartSlot: ChartCacheSlot | null = null; let chartInflight: { epoch: number; promise: Promise } | null = null; function subjectOf(identity: { subjectId?: string } | null): string { return identity?.subjectId || "self"; } function sameChartIdentity(left: ChartSnapshotIdentity | null, right: ChartSnapshotIdentity | null): boolean { return Boolean( left && right && left.accountId === right.accountId && left.fingerprint === right.fingerprint && subjectOf(left) === subjectOf(right), ); } export function readChartSnapshotIdentity(snapshot: ChartPageSnapshot): ChartSnapshotIdentity | null { if (snapshot.kind !== "view") return null; const accountId = typeof snapshot.view.accountId === "string" ? snapshot.view.accountId : ""; const fingerprint = typeof snapshot.view.profileFingerprint === "string" ? snapshot.view.profileFingerprint : ""; if (!accountId || !fingerprint) return null; return { accountId, fingerprint, subjectId: snapshot.view.subjectId || "self" }; } function snapshotFromChartResult(result: { httpStatus: number; body: ChartViewResponse | null }): ChartPageSnapshot { if (result.httpStatus === 401 || result.body?.status === "unauthenticated") return { kind: "unauthenticated" }; return { kind: "view", view: result.body ?? chartViewFailureResponse("response_schema_failure"), }; } function pinnedChartFailure(): ChartPageSnapshot { const pin = pinnedChartIdentity; return { kind: "view", view: { ...chartViewFailureResponse("response_schema_failure"), ...(pin ? { accountId: pin.accountId, profileFingerprint: pin.fingerprint } : {}), }, }; } function rememberChartSnapshot(snapshot: ChartPageSnapshot): ChartPageSnapshot { const identity = readChartSnapshotIdentity(snapshot); chartSlot = { snapshot, identity: identity ?? (pinnedChartIdentity && snapshot.kind === "view" ? { ...pinnedChartIdentity } : null), }; return snapshot; } function chartSnapshotVisible(): ChartPageSnapshot | null { if (!chartSlot) return null; if (chartSlot.snapshot.kind === "unauthenticated") return chartSlot.snapshot; if (!pinnedChartIdentity) return chartSlot.snapshot; if (!chartSlot.identity) { return chartSlot.snapshot.kind === "view" && chartSlot.snapshot.view.status !== "ok" ? chartSlot.snapshot : null; } return chartSnapshotMatchesPin(chartSlot.snapshot) ? chartSlot.snapshot : null; } function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean { if (!pinnedChartIdentity) return true; if (snapshot.kind === "unauthenticated") return true; const identity = readChartSnapshotIdentity(snapshot); if (!identity) return snapshot.kind === "view" && snapshot.view.status !== "ok"; if (!pinnedChartIdentity.fingerprint) return subjectOf(identity) === subjectOf(pinnedChartIdentity); return sameChartIdentity(identity, pinnedChartIdentity); } async function loadChartSnapshot(started: number, signal: AbortSignal): Promise { const subjectId = readCurrentSubjectId(); if (!pinnedChartIdentity || subjectOf(pinnedChartIdentity) !== subjectId) { pinnedChartIdentity = { accountId: pinnedChartIdentity?.accountId || "", fingerprint: "", subjectId, }; chartSlot = null; } const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId })); if (readCurrentSubjectId() !== subjectId) return refreshChartPage(); if (started !== chartEpoch) return refreshChartPage(); let snapshot = first; if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) { snapshot = snapshotFromChartResult(await fetchChartView({ signal, subjectId: readCurrentSubjectId() })); if (started !== chartEpoch) return refreshChartPage(); } if (!chartSnapshotMatchesPin(snapshot)) return rememberChartSnapshot(pinnedChartFailure()); const current = chartSnapshotVisible(); // A warm page can finish a requested layer while this D1-only refresh is // still in flight. Its unrequested placeholders must not erase that layer. if (snapshot.kind === "view" && snapshot.view.status === "ok" && current?.kind === "view" && current.view.status === "ok" && sameChartIdentity(readChartSnapshotIdentity(snapshot), readChartSnapshotIdentity(current))) { const prior = current.view; snapshot = { kind: "view", view: { ...snapshot.view, ...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}), dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) }, ...(prior.western.status === "ok" ? { western: prior.western } : {}), ...(prior.qizheng.status === "ok" ? { qizheng: prior.qizheng } : {}), } }; } return rememberChartSnapshot(snapshot); } function refreshChartSnapshot(): Promise { if (chartInflight && chartInflight.epoch === chartEpoch) return chartInflight.promise; const started = chartEpoch; // The shared request owns the deadline, including sidebar prefetch and the // identity retry. A mounting hook cannot extend a hung prefetch's lifetime. const signal = AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS); const promise = loadChartSnapshot(started, signal).catch((error: unknown) => { if (started !== chartEpoch) return refreshChartPage(); const cached = chartSnapshotVisible(); if (cached) return cached; const status = chartClientFailureStatus(error); console.warn("chart_page_fetch_failed", { status }); return rememberChartSnapshot({ kind: "view", view: status === "client_timeout" ? chartViewFailureResponse("client_timeout") : { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }, }); }).finally(() => { if (chartInflight?.promise === promise) chartInflight = null; }); chartInflight = { epoch: started, promise }; return promise; } export function beginChartCacheAccountRead(): number { return chartAccountReadGeneration; } export function accountChartPinStillCurrent(started: number): boolean { return started === chartAccountReadGeneration; } export function pinChartSnapshotIdentity(identity: ChartSnapshotIdentity): void { if (!identity.accountId || !identity.fingerprint) return; if (sameChartIdentity(pinnedChartIdentity, identity)) return; pinnedChartIdentity = { accountId: identity.accountId, fingerprint: identity.fingerprint }; chartEpoch += 1; chartAccountReadGeneration += 1; if (!sameChartIdentity(chartSlot?.identity ?? null, pinnedChartIdentity)) { chartSlot = null; } } let ephemerisSubject = ""; const ephemerisCache = createMemoryCache(async () => { const subjectId = readCurrentSubjectId(); const response = await fetch(`/api/ephemeris?subject=${encodeURIComponent(subjectId)}`, { credentials: "same-origin", headers: { Accept: "application/json" }, cache: "no-store", }); if (response.status === 401) { ephemerisSubject = subjectId; return { kind: "unauthorized" }; } const json: unknown = await response.json().catch(() => null); const payload = parseEphemerisOkResponse(json); if (!payload) throw new Error("ephemeris_page_bad_payload"); if (readCurrentSubjectId() !== subjectId) throw new Error("ephemeris_subject_changed"); ephemerisSubject = subjectId; return { kind: "ready", payload }; }); export function readReports(value: unknown): ReportListItem[] { if (!value || typeof value !== "object" || !("reports" in value)) return []; const reports = (value as { reports?: unknown }).reports; if (!Array.isArray(reports)) return []; return reports.flatMap((entry) => { if (!entry || typeof entry !== "object") return []; const row = entry as Record; if (typeof row.id !== "string" || !["generating", "ready", "failed"].includes(String(row.status))) { return []; } return [{ id: row.id, reportType: typeof row.reportType === "string" ? row.reportType : "personal_full", presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default", depth: typeof row.depth === "string" ? row.depth : "standard", themes: Array.isArray(row.themes) ? row.themes.filter((theme): theme is string => typeof theme === "string") : [], status: row.status as ReportListItem["status"], failureCode: typeof row.failureCode === "string" ? row.failureCode : null, failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null, createdAt: typeof row.createdAt === "string" ? row.createdAt : "", completedAt: row.completedAt == null ? null : String(row.completedAt), cardSummary: typeof row.cardSummary === "string" && row.cardSummary.trim() ? row.cardSummary : null, }]; }); } let reportsSubject = ""; const reportsCache = createMemoryCache(async () => { const subjectId = readCurrentSubjectId(); const response = await fetch(`/api/reports?subject=${encodeURIComponent(subjectId)}`, { credentials: "same-origin", headers: { Accept: "application/json" }, cache: "no-store", }); const json: unknown = await response.json().catch(() => null); if (readCurrentSubjectId() !== subjectId) throw new Error("reports_subject_changed"); if (response.status === 401) { reportsSubject = subjectId; return { kind: "unauthorized" }; } if (!response.ok) { reportsSubject = subjectId; return { kind: "error", reports: [] }; } reportsSubject = subjectId; return { kind: "ready", reports: readReports(json) }; }); export function peekChartPage(): ChartPageSnapshot | null { return chartSnapshotVisible(); } export function writeChartPage(snapshot: ChartPageSnapshot): ChartPageSnapshot { if (pinnedChartIdentity && !chartSnapshotMatchesPin(snapshot)) { const current = chartSnapshotVisible(); if (current) return current; return rememberChartSnapshot(pinnedChartFailure()); } return rememberChartSnapshot(snapshot); } export function refreshChartPage(): Promise { return refreshChartSnapshot(); } export function peekEphemerisPage(): EphemerisPageSnapshot | null { if (ephemerisSubject !== readCurrentSubjectId()) return null; return ephemerisCache.peek(); } export function writeEphemerisPage(snapshot: EphemerisPageSnapshot): EphemerisPageSnapshot { if (snapshot.kind === "ready") ephemerisSubject = snapshot.payload.subjectId || readCurrentSubjectId(); return ephemerisCache.write(snapshot); } export function invalidateEphemerisPage(): void { ephemerisSubject = ""; ephemerisCache.reset(); } export function refreshEphemerisPage(): Promise { return ephemerisCache.refresh(); } export function peekReportsPage(): ReportsPageSnapshot | null { if (reportsSubject !== readCurrentSubjectId()) return null; return reportsCache.peek(); } export function writeReportsPage(snapshot: ReportsPageSnapshot): ReportsPageSnapshot { reportsSubject = readCurrentSubjectId(); return reportsCache.write(snapshot); } export function invalidateReportsPage(): void { reportsSubject = ""; reportsCache.reset(); } export function refreshReportsPage(): Promise { return reportsCache.refresh(); } export function prefetchSecondaryPage(href: string): void { if (!currentSubjectCatalogReady()) return; if (href === "/chart" || href.startsWith("/chart?")) void refreshChartPage(); else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch(); else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch(); } export function resetSecondaryPageDataForTests(): void { chartEpoch += 1; chartAccountReadGeneration += 1; pinnedChartIdentity = null; chartSlot = null; chartInflight = null; ephemerisSubject = ""; reportsSubject = ""; ephemerisCache.reset(); reportsCache.reset(); }