import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useOptionalEncounterNoteDraftService } from '../../../services/EncounterNoteDraftService'; import { decideNag, mayNagNow, nagMessage, UNFINISHED_NOTE_TOAST_CLASS, type NagDecision, } from '../../../utils/unfinishedNoteNag'; // The reminder that gets louder the longer a note sits unfinished. // // ON THE CLINICIAN'S HOME, NOT INSIDE THE ENCOUNTER, and for the reason already written into the page it // mounts on: the notes that most need chasing belong to encounters nobody has reopened, so a reminder // that only appeared once you were looking at the encounter would never fire for them. // // AND NOTHING DURING AN ENCOUNTER. Spencer: "I don't want to distract a practitioner while they're in an // encounter." The clinician is with a patient, and a note from Tuesday is not more important than the // person in front of them. /** How often the list is re-read. Day-scale thresholds do not need a faster clock than this. */ const REFETCH_MS = 10 * 60 * 1000; const TOAST_ID = 'unfinished-notes'; export interface UnfinishedNoteNag { decision: NagDecision; /** True while the reminder is on screen, so a caller can render its own surface instead. */ showing: boolean; } /** * @param inEncounter whether a visit is open right now. Suppresses everything while it is true. * @param onOpen what to do when the practitioner acts on the reminder. */ export const useUnfinishedNoteNag = ( inEncounter: boolean, onOpen?: (encounterId: string, patientId: string) => void, ): UnfinishedNoteNag => { const service = useOptionalEncounterNoteDraftService(); const [decision, setDecision] = useState({ level: 'none', count: 0, ageMs: 0 }); const [showing, setShowing] = useState(false); const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; useEffect(() => { if (!service) return; let live = true; const read = async () => { const drafts = await service.listUnfinishedNotes(); // Decided against the CURRENT clock, every read. The age is the draft's, computed from what // the server stored, so nothing about when this tab loaded enters into it. if (live) setDecision(decideNag(drafts, Date.now())); }; void read(); const timer = setInterval(() => { void read(); }, REFETCH_MS); return () => { live = false; clearInterval(timer); }; }, [service]); useEffect(() => { const silent = !mayNagNow(inEncounter) || decision.level === 'none'; if (silent) { // Dismissed rather than left standing. Walking into a consultation must clear it, or the // suppression only applies to reminders that had not fired yet. toast.dismiss(TOAST_ID); setShowing(false); return; } const undismissable = decision.level === 'undismissable'; const oldest = decision.oldest; /* * A TOAST, AND THAT IS THE CONTROL RATHER THAN A COMPROMISE. The un-dismissable step has to sit * ALONGSIDE clinical work and never in front of it - a modal a practitioner cannot get past * stops being a reminder and becomes an outage. A toast is the loudest thing that still leaves * the screen behind it usable. */ toast[undismissable ? 'error' : 'warning'](nagMessage(decision), { id: TOAST_ID, // Marked so the message composer can make room for it rather than sit under it. className: UNFINISHED_NOTE_TOAST_CLASS, duration: decision.level === 'gentle' ? 8_000 : Infinity, dismissible: !undismissable, // It goes away when the note is SUBMITTED, and only then - so the action is the way out // rather than the dismiss button, at every level. ...(oldest && onOpenRef.current ? { action: { label: 'Finish it', onClick: () => onOpenRef.current?.(oldest.encounterId, oldest.patientId), }, } : {}), }); setShowing(true); }, [decision, inEncounter]); return { decision, showing }; };