Files
Jyotisha/frontend/src/lib/secondary-page-data.ts
T
jesse-uxandClaude Code 4d801e53c3
Independent Staging Quality Gate / validate (push) Successful in 16m28s
Independent Staging Quality Gate / publish (push) Successful in 3m33s
fix: integrate people archive, report reader and western chart corrections
Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:41:16 +08:00

410 lines
15 KiB
TypeScript

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<T> = {
peek: () => T | null;
write: (value: T) => T;
refresh: () => Promise<T>;
prefetch: () => void;
reset: () => void;
};
function createMemoryCache<T>(load: () => Promise<T>): MemoryCache<T> {
let value: T | null = null;
let inflight: Promise<T> | 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<T> => {
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<ChartPageSnapshot> } | 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<ChartPageSnapshot> {
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<ChartPageSnapshot> {
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<EphemerisPageSnapshot>(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<string, unknown>;
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<ReportsPageSnapshot>(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<ChartPageSnapshot> {
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<EphemerisPageSnapshot> {
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<ReportsPageSnapshot> {
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();
}