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
265 lines
9.4 KiB
TypeScript
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;
|
|
}
|