import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Organization } from "fhir/r4"; import { useAuthServiceContext } from "../services/AuthService.tsx"; import { useOrganizationServiceContext } from "../services/OrganizationService.tsx"; import { useOptionalWebSocketServiceContext } from "../services/WebSocketService"; import { sortEntryOrgFirst } from "../utils/patientOrganizations"; import { isPayerOrganization } from "../utils/organizationType"; import { PRACTICES_CHANGED_EVENT, type PracticesChanged, useRereadOnReturn } from "./useRereadOnReturn"; import { useViewerScope } from "./useViewerScope"; const ORGANIZATION_UPDATED = "com.akashichealthrecords.fhir.organization.updated"; interface UseMyOrganizationsReturn { organizations: Organization[]; loading: boolean; error: string | null; /** Re-reads the whole list, for when the list itself failed. */ retry: () => void; /** Re-reads one practice the server could not read, leaving the other rows alone. */ retryOrganization: (organizationId: string) => Promise; /** Practices being re-read right now. */ retryingIds: string[]; /** Whether the list shown is the latest answer, being checked, could not be refreshed, or still lacks a practice just answered. */ freshness: ListFreshness; /** Reads the list again now, without clearing it. */ checkAgain: () => void; } export type ListFreshness = "current" | "checking" | "stale" | "unsettled"; type Stamp = { epoch: number; at: number }; /** * - the practices this patient is a patient of. * * THIS USED TO BE N REQUESTS AND IT COULD NOT WORK. It read the ids out of `patientState`, then fetched * each organization while passing the id of the WordPress install the patient is STANDING ON. The * Organization route's guard on that value is unconditional, so any practice on a different WordPress site * answered **400** - and a genuinely different practice has its own by definition. * **My Providers could only ever hydrate practices sharing the current install.** * And it failed SILENTLY: the per-org catch degraded a failure to a stub card, so the patient saw a * nameless tile rather than an error. Nobody had noticed because staging has one organization. * * That value is gone from this file entirely, and its ABSENCE is the fix - the server derives the list * from the caller's own record, so there is nothing here to get wrong. A test greps for it. * * TWO RULES DELIBERATELY KEPT ON THE CLIENT, because neither is the server's to decide: * - **payers are filtered out** - an insurer is never a care organization (Spencer, 2026-07-19), and * legacy insurer `Organization`s still exist; * - **the entry organization sorts first** - "the site I logged in through" is a fact about this browser * session, not about the record. * * THE ENTRY ORGANIZATION SORTS THE LIST, IT DOES NOT REFETCH IT. Ordering used to live inside `load`, * which put `currentOrganization` in the fetch's dependencies - so the list was requested AGAIN every * time that object took a new identity, and it takes one on every sign-in as auth resolves it from * undefined. Measured in the browser 2026-09-09: two `GET /my-organizations` per portal load, the second * arriving 347ms after the first. The second can never differ from the first, because the route reads no * organization id at all (`OrganizationService.fetchMyOrganizations`, and `MyOrganizations/get.ts` * behind it) - it derives everything from the caller's own record. So the whole round trip bought a * re-sort of a list already in hand. Fetching keyed on the fetcher and sorting derived from the id gives * the same order without the request, and re-sorts the instant the organization resolves rather than * waiting on the network to come back with the same rows. */ // While practices that stopped waiting on the patient are not yet in their list, it is read again on this // interval, this many times. A practice that was declined never arrives; the bound ends the wait either way. export const SETTLE_INTERVAL_MS = 5_000; export const SETTLE_READS = 6; // `enabled: false` for a staff viewer, whom the route refuses; the list stays empty. // `rereadOnReturn` quietly re-reads the list when the patient comes back to the page or answers a practice elsewhere. export const useMyOrganizations = ({ enabled = true, rereadOnReturn = false }: { enabled?: boolean; rereadOnReturn?: boolean } = {}): UseMyOrganizationsReturn => { const { currentOrganization } = useAuthServiceContext(); const { fetchMyOrganizations } = useOrganizationServiceContext(); const scope = useViewerScope(); const [careOrganizations, setCareOrganizations] = useState([]); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const [retryingIds, setRetryingIds] = useState([]); const [freshness, setFreshness] = useState("current"); // One clock for every read that writes the list. A whole-list answer lands only if no later whole list has; // a row answer only if no whole list or read of that row started after it has landed. `scopeEpoch` drops // every answer begun for another viewer. const scopeEpoch = useRef(0); const clock = useRef(0); const listLanded = useRef(0); const rowLanded = useRef(new Map()); const begin = (): Stamp => ({ epoch: scopeEpoch.current, at: ++clock.current }); const live = (stamp: Stamp) => stamp.epoch === scopeEpoch.current; const landList = (stamp: Stamp, results: Organization[]) => { if (!live(stamp) || stamp.at < listLanded.current) return false; listLanded.current = stamp.at; const fresh = results.filter((o) => !isPayerOrganization(o)); const newer = (id?: string) => (rowLanded.current.get(id ?? "") ?? 0) > stamp.at; setCareOrganizations((shown) => { const kept = fresh.flatMap((o) => (newer(o.id) ? shown.filter((s) => s.id === o.id) : [o])); const freshIds = new Set(fresh.map((o) => o.id)); return [...kept, ...shown.filter((s) => !freshIds.has(s.id) && newer(s.id))]; }); return true; }; const landRow = (stamp: Stamp, organizationId: string, fresh: Organization | undefined) => { if (!live(stamp) || stamp.at < listLanded.current || stamp.at < (rowLanded.current.get(organizationId) ?? 0)) return; rowLanded.current.set(organizationId, stamp.at); // Absent from the answer means it is no longer one of theirs, so the row goes. setCareOrganizations((list) => fresh ? list.map((o) => (o.id === organizationId ? fresh : o)) : list.filter((o) => o.id !== organizationId)); }; const fullLoad = useRef(0); const quietRead = useRef(false); const quietQueued = useRef(false); const refreshRef = useRef<() => Promise>(async () => undefined); // An automatic read is not started while the page is hidden: the reason to read is kept, and coming back reads once. const heldWhileHidden = useRef(false); const admit = () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { heldWhileHidden.current = true; return; } void refreshRef.current(); }; const load = useCallback(async () => { const stamp = begin(); fullLoad.current = stamp.at; setLoading(true); setError(null); try { const results = await fetchMyOrganizations(); if (landList(stamp, results)) setFreshness("current"); } catch { // A FAILED LOAD IS AN ERROR, NEVER AN EMPTY LIST. "You are not a patient of any practice" is a // different sentence from "we could not find out", and the second one must not be rendered as the // first. The server already degrades a // single unreadable organization to a stub, so reaching this catch means the whole list failed. if (live(stamp) && fullLoad.current === stamp.at) { setError("Failed to load your organizations"); setCareOrganizations([]); } } finally { if (live(stamp) && fullLoad.current === stamp.at) { fullLoad.current = 0; setLoading(false); if (quietQueued.current) { quietQueued.current = false; admit(); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchMyOrganizations]); // A quiet re-read: no loading state, and a failure keeps the list shown but marks it as not current. One runs at // a time; a reason to read that arrives meanwhile reads once more after it, or after a full load in flight. const refresh = useCallback(async (): Promise => { // A read started or queued here carries any reason held while the page was hidden. heldWhileHidden.current = false; if (fullLoad.current || quietRead.current) { quietQueued.current = true; return; } quietRead.current = true; const stamp = begin(); setFreshness((now) => (now === "current" ? now : "checking")); try { const results = await fetchMyOrganizations(); if (landList(stamp, results)) { setError(null); setFreshness("current"); } } catch { if (live(stamp) && stamp.at > listLanded.current) setFreshness("stale"); } finally { if (live(stamp)) { quietRead.current = false; if (quietQueued.current) { quietQueued.current = false; admit(); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchMyOrganizations]); refreshRef.current = refresh; // A new viewer starts from nothing: answers begun for the last one are dropped and its rows cleared. useEffect(() => { scopeEpoch.current += 1; listLanded.current = 0; rowLanded.current = new Map(); fullLoad.current = 0; quietRead.current = false; quietQueued.current = false; heldWhileHidden.current = false; setCareOrganizations([]); setFreshness("current"); setRetryingIds([]); if (enabled) void load(); return () => { scopeEpoch.current += 1; }; }, [load, enabled, scope]); const listedRef = useRef(careOrganizations); listedRef.current = careOrganizations; // Practices that stopped waiting are expected to join the list, though the server can trail the answer by a // few seconds and a declined practice never joins. Read again until they are all there or the bound is reached; // the list is only ever what the server answered. const [settling, setSettling] = useState<{ ids: string[]; reads: number } | null>(null); const watching = enabled && rereadOnReturn; useRereadOnReturn(refresh, watching); useEffect(() => { setSettling(null); if (!watching) return; const onChanged = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail && detail.scope !== scope) return; const answered = detail?.ids ?? []; // A practice answered earlier and still missing from the list is waited for alongside the new ones; // a change that names none leaves the wait, and its count of reads, as it was. setSettling((now) => { const missing = (now?.ids ?? []).filter((id) => !listedRef.current.some((o) => o.id === id)); if (!answered.length) return missing.length ? { ids: missing, reads: now?.reads ?? 0 } : null; return { ids: [...new Set([...missing, ...answered])], reads: 0 }; }); admit(); }; window.addEventListener(PRACTICES_CHANGED_EVENT, onChanged); return () => window.removeEventListener(PRACTICES_CHANGED_EVENT, onChanged); }, [watching, refresh, scope]); // A hidden page is not read on the timer and spends none of its reads; coming back reads once and the wait resumes. // The reason held while hidden is read on return unless the return re-read already took it; that re-read skips a // return close behind the last one, so the held reason is checked after it has had its turn. const [hidden, setHidden] = useState(() => typeof document !== "undefined" && document.visibilityState === "hidden"); useEffect(() => { let active = true; const onVisibility = () => { const nowHidden = document.visibilityState === "hidden"; setHidden(nowHidden); if (nowHidden) return; void Promise.resolve().then(() => { if (active && heldWhileHidden.current && document.visibilityState !== "hidden") void refreshRef.current(); }); }; document.addEventListener("visibilitychange", onVisibility); return () => { active = false; document.removeEventListener("visibilitychange", onVisibility); }; }, []); const settled = settling ? settling.ids.every((id) => careOrganizations.some((o) => o.id === id)) : true; useEffect(() => { if (!settling || settled || hidden || settling.reads >= SETTLE_READS) return; const timer = window.setTimeout(() => { setSettling((now) => (now ? { ...now, reads: now.reads + 1 } : now)); void refresh(); }, SETTLE_INTERVAL_MS); return () => window.clearTimeout(timer); }, [settling, settled, hidden, refresh]); const unsettled = !!settling && !settled && settling.reads >= SETTLE_READS; const retryOrganization = useCallback(async (organizationId: string) => { const stamp = begin(); setRetryingIds((ids) => [...ids, organizationId]); try { landRow(stamp, organizationId, (await fetchMyOrganizations(organizationId)).find((o) => o.id === organizationId)); } catch { // The row keeps saying it could not be read, and can be tried again. } finally { if (live(stamp)) setRetryingIds((ids) => ids.filter((id) => id !== organizationId)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchMyOrganizations]); // A practice renamed while this page is open is reread on its update event; other organizations are ignored. const socket = useOptionalWebSocketServiceContext(); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; useEffect(() => { if (!enabled || !subscribe || !unsubscribe) return; const onUpdated = async (event: { data?: Organization }) => { const id = event?.data?.id; if (!id || !listedRef.current.some((o) => o.id === id)) return; const stamp = begin(); try { const fresh = (await fetchMyOrganizations(id)).find((o) => o.id === id); // A rename read never removes the row; only a whole-list read or a retry says it is no longer theirs. if (fresh) landRow(stamp, id, fresh); } catch { // The row keeps its last good read. } }; subscribe(ORGANIZATION_UPDATED, onUpdated); return () => unsubscribe(ORGANIZATION_UPDATED, onUpdated); // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, subscribe, unsubscribe, fetchMyOrganizations]); // Derived, not stored: the entry organization decides ORDER only, and it arrives after the rows do. // Keyed on the id rather than the object so a context that hands back an equal-but-new // `currentOrganization` re-sorts nothing and refetches nothing. const organizations = useMemo( () => sortEntryOrgFirst(careOrganizations, currentOrganization?.id), [careOrganizations, currentOrganization?.id], ); const checkAgain = useCallback(() => { setSettling((now) => (now ? { ...now, reads: 0 } : now)); void refresh(); }, [refresh]); // `retry` is `load` under a name that says what a caller wants it for. A card that could not be read // offers a Try again, and without this it would have nothing to call - the alternative is every caller // building its own fetch, which is a second source of truth for the same list. return { organizations, loading, error, retry: load, retryOrganization, retryingIds, freshness: unsettled && freshness === "current" ? "unsettled" : freshness, checkAgain, }; };