Files
Jyotisha/frontend/src/lib/current-subject.ts
T
Jesse_ChenandClaude Opus 5.5 ab656e0dea perf(home): cold boot reads account, people list, sessions and entry summary in one round (BUG-1127)
The sessions read guesses the remembered person and is dropped when the
people list settles on another; a first sign-in no longer re-reads the
people list it just fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 00:15:12 +08:00

265 lines
9.4 KiB
TypeScript

"use client";
import { useEffect, useSyncExternalStore } from "react";
import { fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
import { readProfile, upsertSelfChart } from "@/lib/home-profile";
import type { Account, ChartLibraryRecord, Profile } from "@/lib/home-types";
const SELF_SUBJECT_ID = "self";
export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account";
export function currentSubjectStorageKey(id: string): string {
return `jyotisha_current_subject:${id}`;
}
function storedValue(key: string): string {
try {
return typeof localStorage === "undefined" ? "" : localStorage.getItem(key) || "";
} catch {
return "";
}
}
function storeValue(key: string, value: string): void {
try {
if (typeof localStorage !== "undefined") localStorage.setItem(key, value);
} catch { /* The in-memory selection still works when storage is unavailable. */ }
}
let accountId = storedValue(LAST_SUBJECT_ACCOUNT_KEY);
let subjectId = (accountId && storedValue(currentSubjectStorageKey(accountId))) || SELF_SUBJECT_ID;
let catalogReady = false;
let catalog: ChartLibraryRecord[] | null = null;
const listeners = new Set<() => void>();
const catalogListeners = new Set<() => void>();
export function readCurrentSubjectId(): string {
return subjectId;
}
export function readCurrentSubjectAccountId(): string {
return accountId;
}
export function peekSubjectCatalog(): readonly ChartLibraryRecord[] | null {
return catalogReady ? catalog : null;
}
export function readCurrentSubjectScope(): string {
return JSON.stringify([accountId, subjectId]);
}
export function useCurrentSubjectScope(): string {
return useSyncExternalStore((listener) => {
const offSubject = subscribeCurrentSubject(listener);
const offCatalog = subscribeSubjectCatalog(listener);
return () => { offSubject(); offCatalog(); };
}, readCurrentSubjectScope, () => "");
}
export function currentSubjectCatalogReady(): boolean {
return catalogReady;
}
export function subscribeCurrentSubject(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function subscribeSubjectCatalog(listener: () => void): () => void {
catalogListeners.add(listener);
return () => { catalogListeners.delete(listener); };
}
function emit(): void {
for (const listener of listeners) listener();
}
function emitCatalog(): void {
for (const listener of catalogListeners) listener();
}
export function setCurrentSubject(next: string): void {
const id = next.trim() || SELF_SUBJECT_ID;
if (id === subjectId) return;
subjectId = id;
if (accountId) storeValue(currentSubjectStorageKey(accountId), id);
emit();
}
/** A null list preserves the stored choice, but cannot open the request gate. */
export function bindCurrentSubjectAccount(nextAccountId: string, knownOtherIds: readonly string[] | null): void {
const previousReady = catalogReady;
const previousAccount = accountId;
accountId = nextAccountId;
const stored = nextAccountId ? storedValue(currentSubjectStorageKey(nextAccountId)) || SELF_SUBJECT_ID : SELF_SUBJECT_ID;
const valid = stored === SELF_SUBJECT_ID || knownOtherIds === null || knownOtherIds.includes(stored);
const next = valid ? stored : SELF_SUBJECT_ID;
if (nextAccountId) {
storeValue(LAST_SUBJECT_ACCOUNT_KEY, nextAccountId);
if (!valid) storeValue(currentSubjectStorageKey(nextAccountId), SELF_SUBJECT_ID);
try { localStorage.removeItem(`jyotisha_active_chart:${nextAccountId}`); } catch { /* Optional storage. */ }
}
catalogReady = Boolean(nextAccountId) && knownOtherIds !== null;
if (next !== subjectId) {
subjectId = next;
emit();
}
if (previousReady !== catalogReady || previousAccount !== accountId) emitCatalog();
}
export function useCurrentSubject(): string {
return useSyncExternalStore(subscribeCurrentSubject, readCurrentSubjectId, () => SELF_SUBJECT_ID);
}
export function useCurrentSubjectReady(): boolean {
const ready = useSyncExternalStore(subscribeSubjectCatalog, currentSubjectCatalogReady, () => false);
useEffect(() => {
if (currentSubjectCatalogReady()) return;
void loadSubjectCatalog().catch(() => undefined);
}, []);
return ready;
}
export function useCurrentSubjectProfile(self: Profile): Profile | null {
const id = useCurrentSubject();
const rows = useSyncExternalStore(subscribeSubjectCatalog, () => catalog, () => null);
return id === SELF_SUBJECT_ID ? self : rows?.find((row) => row.id === id)?.profile ?? null;
}
let catalogRequest: Promise<ChartLibraryRecord[]> | null = null;
let catalogAccountId = "";
let catalogGeneration = 0;
let othersUnavailable = false;
let accountFlight: Promise<{ status: number; account: Account | null }> | null = null;
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function subjectCatalogOthersUnavailable(): boolean {
return othersUnavailable;
}
export function invalidateAccountFlight(): void {
accountFlight = null;
}
/** One in-flight account read shared by the session list and the person catalog. */
export function readBootAccount(): Promise<{ status: number; account: Account | null }> {
if (accountFlight) return accountFlight;
const published = (async () => {
const response = await fetch("/api/account", { cache: "no-store" });
if (response.status === 401) return { status: 401, account: null };
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error("account_unavailable");
return { status: response.status, account: payload as Account };
})().catch((error: unknown) => {
if (accountFlight === published) accountFlight = null;
throw error;
});
accountFlight = published;
return published;
}
function clearCatalogRecovery(): void {
if (recoveryTimer === null) return;
clearTimeout(recoveryTimer);
recoveryTimer = null;
}
export function resetSubjectCatalogForTests(): void {
catalogGeneration += 1;
catalogRequest = null;
catalogAccountId = "";
accountId = "";
subjectId = SELF_SUBJECT_ID;
catalogReady = false;
catalog = null;
othersUnavailable = false;
accountFlight = null;
clearCatalogRecovery();
}
export function invalidateSubjectCatalog(): void {
catalogGeneration += 1;
catalogRequest = null;
catalogAccountId = "";
catalogReady = false;
catalog = null;
othersUnavailable = false;
clearCatalogRecovery();
emitCatalog();
}
function rememberCatalog(account: Account, library: ChartLibraryRecord[], degraded: boolean): ChartLibraryRecord[] {
catalogAccountId = account.user.id;
catalog = library;
othersUnavailable = degraded;
bindCurrentSubjectAccount(catalogAccountId, degraded ? [] : library.filter((row) => row.role === "other").map((row) => row.id));
emitCatalog();
return library;
}
function scheduleCatalogRecovery(account: Account): void {
if (recoveryTimer !== null || !othersUnavailable) return;
const generation = catalogGeneration;
const timer = setTimeout(() => {
recoveryTimer = null;
if (generation !== catalogGeneration || !othersUnavailable) return;
void fetchCloudChartLibrary().then((others) => {
if (generation !== catalogGeneration) return;
const self = readProfile(account.profile);
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
clearCatalogRecovery();
catalogRequest = Promise.resolve(rememberCatalog(account, library, false));
}).catch(() => {
if (generation === catalogGeneration && othersUnavailable) scheduleCatalogRecovery(account);
});
}, 3000);
if (typeof timer === "object" && timer && "unref" in timer) timer.unref();
recoveryTimer = timer;
}
type SettledRead<T> = { ok: true; value: T } | { ok: false; error: unknown };
/** Starts a read now without an unhandled rejection; the caller rethrows on unwrap. */
function settledRead<T>(promise: Promise<T>): Promise<SettledRead<T>> {
return promise.then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }));
}
function unwrapRead<T>(read: SettledRead<T>): T {
if (!read.ok) throw read.error;
return read.value;
}
export function loadSubjectCatalog(force = false): Promise<ChartLibraryRecord[]> {
if (!force && catalogRequest) return catalogRequest;
const generation = ++catalogGeneration;
clearCatalogRecovery();
// The people list does not depend on the account payload, so it is read in the
// same round as the account instead of after it (BUG-1127).
const libraryRead = settledRead(fetchCloudChartLibrary());
catalogRequest = readBootAccount().then(async (result) => {
if (generation !== catalogGeneration) return loadSubjectCatalog();
if (result.status === 401 || !result.account) throw new Error("account_unavailable");
const account = result.account;
try {
const others = unwrapRead(await libraryRead);
if (generation !== catalogGeneration) return loadSubjectCatalog();
const self = readProfile(account.profile);
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
return rememberCatalog(account, library, false);
} catch (error) {
if (generation !== catalogGeneration) throw error;
const self = readProfile(account.profile);
const library = upsertSelfChart([], self);
const remembered = rememberCatalog(account, library, true);
scheduleCatalogRecovery(account);
return remembered;
}
}).catch((error: unknown) => {
if (generation === catalogGeneration) catalogRequest = null;
throw error;
});
return catalogRequest;
}