fix: keep western labels near their planets and chats on the current person
Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
This commit is contained in:
@@ -3,7 +3,7 @@ import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject
|
||||
import { sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
|
||||
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed";
|
||||
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed" | "degraded";
|
||||
|
||||
/** New ordinary chats follow the current person, not a fixed self id. */
|
||||
export function NEW_CHAT_SUBJECT_ID(): string {
|
||||
@@ -91,7 +91,7 @@ export function chatProfilePickerRows(input: {
|
||||
library: readonly ChartLibraryRecord[];
|
||||
selectedId: string;
|
||||
}): ChatProfilePickerRow[] {
|
||||
if (input.status !== "ready") return [];
|
||||
if (input.status !== "ready" && input.status !== "degraded") return [];
|
||||
const self = input.library.find((record) => record.role === "self" || record.id === "self");
|
||||
const others = input.library.filter((record) => record.role === "other" && record.id !== "self");
|
||||
const rows: ChatProfilePickerRow[] = [];
|
||||
@@ -106,6 +106,7 @@ export function chatProfilePickerRows(input: {
|
||||
selected: input.selectedId === "self",
|
||||
});
|
||||
}
|
||||
if (input.status === "degraded") return rows;
|
||||
for (const record of others) rows.push(toRow(record, input.selectedId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
import { fetchAccount, fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
|
||||
import { fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
|
||||
import { readProfile, upsertSelfChart } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, Profile } from "@/lib/home-types";
|
||||
import type { Account, ChartLibraryRecord, Profile } from "@/lib/home-types";
|
||||
|
||||
const SELF_SUBJECT_ID = "self";
|
||||
export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account";
|
||||
@@ -131,6 +131,40 @@ export function useCurrentSubjectProfile(self: Profile): 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;
|
||||
@@ -140,6 +174,9 @@ export function resetSubjectCatalogForTests(): void {
|
||||
subjectId = SELF_SUBJECT_ID;
|
||||
catalogReady = false;
|
||||
catalog = null;
|
||||
othersUnavailable = false;
|
||||
accountFlight = null;
|
||||
clearCatalogRecovery();
|
||||
}
|
||||
|
||||
export function invalidateSubjectCatalog(): void {
|
||||
@@ -148,21 +185,62 @@ export function invalidateSubjectCatalog(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
export function loadSubjectCatalog(force = false): Promise<ChartLibraryRecord[]> {
|
||||
if (!force && catalogRequest) return catalogRequest;
|
||||
const generation = ++catalogGeneration;
|
||||
catalogRequest = Promise.all([fetchAccount(), fetchCloudChartLibrary()]).then(([account, others]) => {
|
||||
clearCatalogRecovery();
|
||||
catalogRequest = readBootAccount().then(async (result) => {
|
||||
if (generation !== catalogGeneration) return loadSubjectCatalog();
|
||||
const self = readProfile(account.profile);
|
||||
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
|
||||
catalogAccountId = account.user.id;
|
||||
catalog = library;
|
||||
bindCurrentSubjectAccount(catalogAccountId, library.filter((row) => row.role === "other").map((row) => row.id));
|
||||
emitCatalog();
|
||||
return library;
|
||||
if (result.status === 401 || !result.account) throw new Error("account_unavailable");
|
||||
const account = result.account;
|
||||
try {
|
||||
const others = await fetchCloudChartLibrary();
|
||||
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;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
|
||||
import type { SidebarSession } from "@/components/sidebar-session-row";
|
||||
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, loadSubjectCatalog, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { readSessions } from "@/lib/home-cloud-sync";
|
||||
import type { Account, ChatSession } from "@/lib/home-types";
|
||||
@@ -97,20 +97,21 @@ function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession
|
||||
&& sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session));
|
||||
}
|
||||
|
||||
async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
|
||||
const accountResponse = await fetch("/api/account", { signal, cache: "no-store" });
|
||||
if (accountResponse.status === 401) {
|
||||
async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise<SessionListBoot> {
|
||||
const accountResult = await readBootAccount();
|
||||
signal.throwIfAborted();
|
||||
if (accountResult.status === 401) {
|
||||
invalidateSubjectCatalog();
|
||||
bindCurrentSubjectAccount("", null);
|
||||
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
|
||||
}
|
||||
if (!accountResponse.ok) throw new Error("account_unavailable");
|
||||
const accountPayload = await accountResponse.json() as Account;
|
||||
if (!accountResult.account) throw new Error("account_unavailable");
|
||||
const accountPayload = accountResult.account;
|
||||
if (readCurrentSubjectAccountId() !== accountPayload.user.id) {
|
||||
invalidateSubjectCatalog();
|
||||
bindCurrentSubjectAccount(accountPayload.user.id, null);
|
||||
}
|
||||
await loadSubjectCatalog();
|
||||
await loadSubjectCatalog(forceCatalog);
|
||||
signal.throwIfAborted();
|
||||
const subjectId = readCurrentSubjectId();
|
||||
const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" });
|
||||
@@ -152,21 +153,24 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let displayedAccount = "";
|
||||
const load = (nextReadyPack: ReturnType<typeof createReadyGate>) => {
|
||||
const load = (nextReadyPack: ReturnType<typeof createReadyGate>, forceCatalog = false) => {
|
||||
controllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setSettled(false);
|
||||
setSignedOut(false);
|
||||
bootRef.current = null;
|
||||
void loadSessionList(controller.signal)
|
||||
void loadSessionList(controller.signal, forceCatalog)
|
||||
.then((boot) => {
|
||||
if (disposed || controller.signal.aborted || controllerRef.current !== controller) return;
|
||||
const requestedSubject = boot.subjectId ?? readCurrentSubjectId();
|
||||
if (requestedSubject !== readCurrentSubjectId()) return;
|
||||
bootRef.current = boot;
|
||||
const sameAccount = Boolean(boot.account) && displayedAccount === boot.account?.user.id;
|
||||
setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions) : boot.sessions);
|
||||
setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions, {
|
||||
subjectId: requestedSubject,
|
||||
complete: boot.cursor == null,
|
||||
}) : boot.sessions);
|
||||
displayedAccount = boot.account?.user.id ?? "";
|
||||
setSessionsCursor(boot.cursor);
|
||||
setAccount(boot.account);
|
||||
@@ -186,7 +190,8 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
|
||||
const next = createReadyGate();
|
||||
currentReady.current = next;
|
||||
previous.resolve();
|
||||
load(next);
|
||||
invalidateAccountFlight();
|
||||
load(next, true);
|
||||
};
|
||||
load(readyPack);
|
||||
const unsubscribe = subscribeCurrentSubject(() => {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { sessionMatchesSubject } from "./chat-profile-picker-model";
|
||||
import type { ChatSession } from "./home-types";
|
||||
import { isUnsavedEmptyConsultation } from "./session-list-filter";
|
||||
|
||||
/** A list row is metadata, never authority to erase an opened transcript. */
|
||||
/** A list row is metadata, never authority to erase an opened transcript.
|
||||
* A complete refresh of one person drops that person's rows the server no
|
||||
* longer returns, and leaves every other person untouched. */
|
||||
export function mergeSubjectSessionRows(
|
||||
current: readonly ChatSession[],
|
||||
incoming: readonly ChatSession[],
|
||||
options?: { readonly subjectId?: string; readonly complete?: boolean },
|
||||
): ChatSession[] {
|
||||
const rows = new Map(current.map((session) => [session.id, session]));
|
||||
const incomingIds = new Set(incoming.map((session) => session.id));
|
||||
const subjectId = options?.subjectId;
|
||||
const base = options?.complete && subjectId
|
||||
? current.filter((session) => incomingIds.has(session.id)
|
||||
|| !sessionMatchesSubject(session, subjectId)
|
||||
|| isUnsavedEmptyConsultation(session))
|
||||
: current;
|
||||
const rows = new Map(base.map((session) => [session.id, session]));
|
||||
for (const row of incoming) {
|
||||
const existing = rows.get(row.id);
|
||||
rows.set(row.id, existing?.messagesHydrated
|
||||
|
||||
Reference in New Issue
Block a user