Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { flushSync } from "react-dom";
|
|
|
|
import { fetchChartView } from "@/lib/chart-view-client";
|
|
import { readCurrentSubjectId, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject";
|
|
import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
|
|
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
|
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
|
|
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
|
|
import {
|
|
accountChartPinStillCurrent,
|
|
beginChartCacheAccountRead,
|
|
peekChartPage,
|
|
refreshChartPage,
|
|
writeChartPage,
|
|
type ChartPageSnapshot,
|
|
} from "@/lib/secondary-page-data";
|
|
|
|
function unavailableBody(): ChartViewResponse {
|
|
return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable };
|
|
}
|
|
|
|
function viewFromSnapshot(snapshot: ChartPageSnapshot | null): ChartViewResponse | null {
|
|
if (snapshot?.kind === "unauthenticated") {
|
|
return { status: "unauthenticated", billed: false, message: CHART_VIEW_COPY.unauthenticated };
|
|
}
|
|
return snapshot?.kind === "view" ? snapshot.view : null;
|
|
}
|
|
|
|
function viewMatchingSubject(subjectId: string): ChartViewResponse | null {
|
|
const view = viewFromSnapshot(peekChartPage());
|
|
if (!view) return null;
|
|
if ("subjectId" in view && view.subjectId && view.subjectId !== subjectId) return null;
|
|
return view;
|
|
}
|
|
|
|
// Layer responses also contain D1 and placeholders for every unrequested layer.
|
|
// Only the requested layer may replace data already shown for this identity.
|
|
function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartViewLayer): ChartViewOk {
|
|
if (current.accountId !== incoming.accountId || current.profileFingerprint !== incoming.profileFingerprint) return incoming;
|
|
const layerFailures = { ...current.layerFailures };
|
|
delete layerFailures[layer];
|
|
return {
|
|
...current,
|
|
layerFailures,
|
|
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
|
|
...(layer === "chara" ? { dasha: incoming.dasha } : {}),
|
|
...(layer === "western" ? { western: incoming.western } : {}),
|
|
...(layer === "bhava" ? { bhava: incoming.bhava } : {}),
|
|
...(layer === "transit" ? { transit: incoming.transit } : {}),
|
|
};
|
|
}
|
|
|
|
export function useChartPage() {
|
|
const subjectId = useCurrentSubject();
|
|
const subjectReady = useCurrentSubjectReady();
|
|
const [seenSubject, setSeenSubject] = useState(subjectId);
|
|
const [view, setView] = useState<ChartViewResponse | null>(() => viewMatchingSubject(subjectId));
|
|
const [pendingLayers, setPendingLayers] = useState<ReadonlySet<ChartViewLayer>>(new Set());
|
|
const [layerFailures, setLayerFailures] = useState<ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>>(new Map());
|
|
const loadedLayers = useRef(new Set<ChartViewLayer>());
|
|
const inflightLayers = useRef(new Set<ChartViewLayer>());
|
|
const mounted = useRef(false);
|
|
if (seenSubject !== subjectId) {
|
|
setSeenSubject(subjectId);
|
|
setView(null);
|
|
setPendingLayers(new Set());
|
|
setLayerFailures(new Map());
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!subjectReady) return;
|
|
mounted.current = true;
|
|
loadedLayers.current.clear();
|
|
inflightLayers.current.clear();
|
|
const cached = peekChartPage();
|
|
if (cached?.kind === "unauthenticated") {
|
|
window.location.assign("/login");
|
|
return () => { mounted.current = false; };
|
|
}
|
|
let cancelled = false;
|
|
void refreshChartPage()
|
|
.then((snapshot) => {
|
|
if (cancelled) return;
|
|
if (snapshot.kind === "unauthenticated") {
|
|
flushSync(() => setView(viewFromSnapshot(snapshot)));
|
|
window.location.assign("/login");
|
|
return;
|
|
}
|
|
setView(snapshot.view);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (cancelled) return;
|
|
const status = chartClientFailureStatus(error);
|
|
console.warn("chart_page_fetch_failed", { status });
|
|
const cachedView = viewFromSnapshot(peekChartPage());
|
|
const fallback = cachedView ?? (status === "client_timeout" ? chartViewFailureResponse("client_timeout") : unavailableBody());
|
|
if (!cachedView) writeChartPage({ kind: "view", view: fallback });
|
|
setView(fallback);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
mounted.current = false;
|
|
};
|
|
}, [subjectId, subjectReady]);
|
|
|
|
const requestLayer = useCallback((layer: ChartViewLayer) => {
|
|
if (!subjectReady || loadedLayers.current.has(layer) || inflightLayers.current.has(layer)) return;
|
|
const generation = beginChartCacheAccountRead();
|
|
const isCurrent = () => mounted.current && readCurrentSubjectId() === subjectId && accountChartPinStillCurrent(generation);
|
|
const fail = (status: ChartViewFailureStatus) => {
|
|
setLayerFailures((current) => new Map(current).set(layer, status));
|
|
};
|
|
inflightLayers.current.add(layer);
|
|
setLayerFailures((current) => {
|
|
const next = new Map(current);
|
|
next.delete(layer);
|
|
return next;
|
|
});
|
|
setPendingLayers((current) => new Set(current).add(layer));
|
|
void fetchChartView({ layers: [layer], subjectId, signal: AbortSignal.timeout(CHART_VIEW_CLIENT_TIMEOUT_MS) })
|
|
.then((result) => {
|
|
if (!isCurrent()) return;
|
|
if (result.httpStatus === 401 || result.body?.status === "unauthenticated") {
|
|
const snapshot = writeChartPage({ kind: "unauthenticated" });
|
|
flushSync(() => setView(viewFromSnapshot(snapshot)));
|
|
window.location.assign("/login");
|
|
return;
|
|
}
|
|
const body = result.body;
|
|
if (!body || body.status !== "ok") {
|
|
// BFF throttling and engine saturation are different existing classes.
|
|
fail(body?.status === "rate_limited" ? "rate_limited"
|
|
: result.httpStatus === 429 ? "engine_busy"
|
|
: body?.reason ?? (body?.status === "client_timeout" ? "client_timeout" : "unavailable"));
|
|
return;
|
|
}
|
|
const failure = body.layerFailures?.[layer];
|
|
if (failure) { fail(failure); return; }
|
|
const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`))
|
|
: layer === "chara" ? body.dasha.chara.periods.length === 0
|
|
: body[layer].status !== "ok";
|
|
if (missing) { fail("unavailable"); return; }
|
|
const current = peekChartPage();
|
|
const merged = current?.kind === "view" && current.view.status === "ok"
|
|
? mergeLayer(current.view, body, layer) : body;
|
|
const stored = writeChartPage({ kind: "view", view: merged });
|
|
if (stored.kind === "view" && stored.view.status === "ok") loadedLayers.current.add(layer);
|
|
setView(viewFromSnapshot(stored));
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!isCurrent()) return;
|
|
const status = chartClientFailureStatus(error);
|
|
console.warn("chart_page_layer_failed", { status, layer });
|
|
fail(status);
|
|
})
|
|
.finally(() => {
|
|
inflightLayers.current.delete(layer);
|
|
if (!mounted.current) return;
|
|
setPendingLayers((current) => {
|
|
const next = new Set(current);
|
|
next.delete(layer);
|
|
return next;
|
|
});
|
|
});
|
|
}, [subjectId, subjectReady]);
|
|
|
|
return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer };
|
|
}
|