Files
Jyotisha/frontend/src/hooks/use-chart-page.ts
T

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 };
}