import { useCallback, useRef, useState } from 'react'; import { useOptionalRecordProposalService } from '../../../services/RecordProposalService'; import { decideProposal, startProposals, type ProposalState, type RecordProposal, } from '../../../utils/recordProposals'; import { isFiledAs, proposalKey } from '../../../utils/filedToEncounter'; /** * What the note just saved offers to put on the record, and what the clinician decided about each. * * IT FIRES ON SAVE rather than behind a button, and Spencer named the reason: *"on-demand-only means * the practitioners who most need it are the ones who never press the button."* * * NOTHING HERE FILES ANYTHING. This owns the OFFER and the DECISION; the write happens on the ordinary * resource route for the kind, from the overlay the decision opens. That separation is what makes * "nothing is filed without being pressed" a property of the code rather than a claim about it. * * A decided item is never offered again, and added items stay listed as added. What the record already * shows filed to this visit counts as decided, so a reload does not bring it back. */ export type ProposalRoundOrigin = 'check' | 'save'; const decisionKey = (proposal: Pick) => proposalKey(proposal.kind, proposal.title); /** The keys of items the record already shows filed to this visit, for the kinds asked about. */ export type AlreadyFiled = (kinds: RecordProposal['kind'][]) => Promise>; export interface NoteRecordProposals { proposals: ProposalState[]; /** Read the note and offer what it describes; `save` when it was just committed. */ offerFrom: (text: string, origin?: ProposalRoundOrigin) => Promise; /** What started the current round. */ origin: ProposalRoundOrigin; /** The last round could not read the note, so its empty list means unknown. */ failed: boolean; add: (id: string) => void; decline: (id: string) => void; /** Put the list away - the clinician is done with this round. */ clear: () => void; /** True while the note is being read. The list is not shown yet, and nothing is claimed about it. */ reading: boolean; /** * Has a round been run at all? * * WITHOUT THIS, AN EMPTY ROUND AND NO ROUND ARE THE SAME STATE, and the caller can only gate on * "are there proposals" - which hides the whole panel exactly when the extraction found nothing. * That is the clinician who most needs the manual search, and it is the case the design calls the * one most worth getting right. */ attempted: boolean; } export const useNoteRecordProposals = (alreadyFiled?: AlreadyFiled): NoteRecordProposals => { // OPTIONAL, for the reason the scribe accessor beside it is: this hook lives inside the note editor, // and a suggestion feature must never be able to take down note writing. Absent the provider there // are simply no proposals. const service = useOptionalRecordProposalService(); const [proposals, setProposals] = useState([]); const [reading, setReading] = useState(false); const [attempted, setAttempted] = useState(false); const [origin, setOrigin] = useState('check'); const [failed, setFailed] = useState(false); const decided = useRef(new Set()); const round = useRef(0); const offerFrom = useCallback(async (text: string, from: ProposalRoundOrigin = 'check') => { setReading(true); setAttempted(true); setOrigin(from); setFailed(false); round.current += 1; const thisRound = round.current; try { let result: { proposals: { kind: RecordProposal['kind']; title: string; prefilled: Record }[]; failed?: boolean }; try { result = service ? await service.proposeFromNote(text, from) : { proposals: [] }; } catch { result = { proposals: [], failed: true }; } setFailed(Boolean(result.failed)); let filed = new Set(); if (alreadyFiled && result.proposals.length) { try { filed = await alreadyFiled(result.proposals.map((item) => item.kind)); } catch { filed = new Set(); } } const found = result.proposals.filter((item) => !decided.current.has(decisionKey(item)) && !isFiledAs(filed, decisionKey(item))); // THE REASON LINE IS THE DRAWING'S, NOT A QUOTE FROM THE NOTE. Echoing the sentence invites // the clinician to check the extraction's reading comprehension rather than the item, and // the note is on the same screen anyway. const offered: RecordProposal[] = found.map((item, index) => ({ id: `proposal-${thisRound}-${index}`, kind: item.kind, title: item.title, reason: 'discussed in the visit', prefilled: item.prefilled, })); setProposals((current) => [ ...current.filter((state) => state.decision === 'added'), ...startProposals(offered), ]); } finally { // Released whatever happened. The service never throws, but a reading flag left stuck on // would hide the manual search behind a spinner that never resolves. setReading(false); } }, [service, alreadyFiled]); const decide = useCallback((id: string, decision: 'added' | 'declined') => { setProposals((current) => { const decidedOne = current.find((state) => state.proposal.id === id)?.proposal; if (decidedOne) decided.current.add(decisionKey(decidedOne)); return decideProposal(current, id, decision); }); }, []); const add = useCallback((id: string) => decide(id, 'added'), [decide]); const decline = useCallback((id: string) => decide(id, 'declined'), [decide]); // Puts the round away entirely, so the panel goes with it rather than sitting there empty. const clear = useCallback(() => { setProposals([]); setAttempted(false); }, []); return { proposals, offerFrom, origin, failed, add, decline, clear, reading, attempted }; };