import { useCallback, useEffect, useRef, useState } from 'react'; import { useOptionalFrequentRecordItemsService } from '../../../services/FrequentRecordItemsService'; import { useOptionalAuthServiceContext } from '../../../services/AuthService'; import { mostAdded, thingFromFiledValues, type RecordThingTally } from '../../../utils/recordThingTally'; import type { RecordThing } from '../../../utils/recordThingSearch'; import type { ProposalKind } from '../../../utils/recordProposals'; /** * What this clinician adds most, and keeping it current as they file. * * READ ONCE PER MOUNT AND PRACTICE, NOT PER KEYSTROKE. It is a shortcut list, not a search - re-reading it while * somebody types would spend a round trip per character to reorder five rows. The server keeps it per clinician and * practice, and the note editor is not remounted on a practice switch, so a list read for one practice is never * shown in another: it is read again, and a late answer for the practice left behind is dropped. * * KEPT CURRENT IN MEMORY AFTER A FILE, rather than re-fetching. The clinician has just added the thing; * seeing it climb is the whole point, and a refetch would race the write that caused it - the server is * eventually consistent and would very likely answer with the count from before. */ export interface FrequentRecordItems { /** The rows for "What you add most", already ordered and capped. Empty until there is a history. */ mostAdded: readonly RecordThing[]; /** Record that an item of this kind was just filed, from what was filed. */ recordFiled: (kind: ProposalKind, values: Record) => void; } const NOW = () => new Date().toISOString(); export const useFrequentRecordItems = (): FrequentRecordItems => { const service = useOptionalFrequentRecordItemsService(); const practiceId = useOptionalAuthServiceContext()?.currentOrganization?.id ?? ''; // The tallies with the practice they were read and kept for; another practice's are not shown. const [kept, setKept] = useState<{ practiceId: string; tallies: RecordThingTally[] }>({ practiceId, tallies: [] }); const tallies = kept.practiceId === practiceId ? kept.tallies : []; const practice = useRef(practiceId); practice.current = practiceId; // Held by reference so the load effect cannot depend on the service object's identity. The context // is rebuilt on its provider's every render, and depending on it re-runs the effect on every // render, which sets state, which renders. const held = useRef(service); held.current = service; useEffect(() => { let live = true; void (async () => { /* CAUGHT HERE TOO, even though the service swallows its own failures. Depending on that contract makes this hook's safety somebody else's to maintain, and the cost of being wrong is an unhandled rejection raised from inside the note editor - which is the exact thing the tolerant accessor above it exists to prevent. */ const found = await held.current?.listFrequent().catch(() => undefined); // A late answer must not overwrite a list this hook has already updated from a file - the // clinician would watch the thing they just added drop back off the group. if (live && found) setKept({ practiceId, tallies: found }); })(); return () => { live = false; }; }, [practiceId]); const recordFiled = useCallback((kind: ProposalKind, values: Record) => { const thing = thingFromFiledValues(kind, values); if (!thing) return; /* THE LOCAL TALLY MOVES FIRST AND THE SERVER IS TOLD SECOND, deliberately. The write is fire-and-forget and swallows its own failure, so waiting on it would delay the one visible effect for no gain - and a failure would leave the group stale rather than wrong, which is the cheaper of the two. */ const here = practice.current; setKept((previous) => { const current = previous.practiceId === here ? previous.tallies : []; const at = NOW(); const found = current.find((entry) => entry.kind === thing.kind && entry.id === thing.id); return { practiceId: here, tallies: found ? current.map((entry) => (entry === found ? { ...entry, count: entry.count + 1, lastAddedAt: at } : entry)) : [...current, { ...thing, count: 1, lastAddedAt: at }], }; }); // Caught for the same reason as the read: fire-and-forget without a catch is an unhandled // rejection, and this one fires from the moment a clinician files anything at all. void held.current?.recordFiled(thing).catch(() => undefined); }, []); return { mostAdded: mostAdded(tallies), recordFiled }; }; export default useFrequentRecordItems;