// How loudly an unfinished note asks to be finished. // // Spencer, 2026-09-02, ruling the ladder himself: "we already display how many notes drafts need // submitted but I think we should start showing a toast if a draft has sat for over 8 hours. Make it nag // more after 12 and after 24 hours it should not be dismissable and only goes away once the draft is // submitted." // // under 8 hours nothing. The count in the header is enough. // over 8 a toast // over 12 it nags harder // over 24 not dismissable. It goes away when the draft is submitted, and only then. // // NO DEADLINE, AND THAT IS DELIBERATE. "I'm hesitant to put a deadline on notes drafts and would prefer a // nudge that gets louder over time." The deadline belongs to a practice's own medical staff bylaws, not // to the product. // // THE CLOCK IS THE DRAFT'S AGE, NEVER THE SESSION'S. It is computed from `updatedAtMs`, which is what // the server stored - a practitioner who closes the laptop and comes back tomorrow must meet a day-old // draft rather than a fresh one, so nothing about when this tab loaded may enter into it. export type NagLevel = 'none' | 'gentle' | 'insistent' | 'undismissable'; /** The mark the reminder's toast carries, so the message composer can make room for it rather than sit under it. */ export const UNFINISHED_NOTE_TOAST_CLASS = 'ahr-unfinished-note-toast'; const HOUR_MS = 60 * 60 * 1000; export const NAG_THRESHOLDS_MS = { gentle: 8 * HOUR_MS, insistent: 12 * HOUR_MS, undismissable: 24 * HOUR_MS, } as const; /** A draft, as the list route reports it. */ export interface UnfinishedNote { encounterId: string; patientId: string; updatedAtMs: number; } /** * How loud the reminder should be for one draft. * * THE BOUNDARIES ARE "OVER", not "at or over", because that is what the ruling says - a draft that has * sat for over 8 hours. At exactly eight it has not yet sat for over eight. */ export const nagLevelForAge = (ageMs: number): NagLevel => { if (ageMs > NAG_THRESHOLDS_MS.undismissable) return 'undismissable'; if (ageMs > NAG_THRESHOLDS_MS.insistent) return 'insistent'; if (ageMs > NAG_THRESHOLDS_MS.gentle) return 'gentle'; return 'none'; }; export interface NagDecision { level: NagLevel; /** How many drafts are unfinished, whatever their age - the number the reminder names. */ count: number; /** The one that decided the level. Absent when nothing is being chased. */ oldest?: UnfinishedNote; ageMs: number; } /** * What to say, given every unfinished note and the moment being judged. * * THE OLDEST DRAFT DECIDES THE LEVEL and the count names them all. A practitioner with one three-day * draft and six from this morning is being chased about the three-day one; reporting the level of the * newest, or averaging, would let the worst case hide behind ordinary ones. * * `now` IS PASSED IN rather than read here, so the caller decides which clock this is measured against - * and so a test can age a draft without waiting a day. */ export const decideNag = (drafts: UnfinishedNote[], now: number): NagDecision => { if (drafts.length === 0) { return { level: 'none', count: 0, ageMs: 0 }; } const oldest = drafts.reduce((worst, draft) => (draft.updatedAtMs < worst.updatedAtMs ? draft : worst)); const ageMs = now - oldest.updatedAtMs; return { level: nagLevelForAge(ageMs), count: drafts.length, oldest, ageMs }; }; /** * Whether the reminder may be shown at all right now. * * 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. * * THIS OUTRANKS THE LADDER, including the un-dismissable step. An un-dismissable nag a practitioner * cannot get past stops being a reminder and becomes an outage in the middle of a visit - which is the * one way this feature could do real harm. Suppressed, not deferred to a lower level: a quieter * interruption during a consultation is still an interruption. */ export const mayNagNow = (inEncounter: boolean): boolean => !inEncounter; /** What the reminder says. Plain, and it names the number rather than making the reader count. */ export const nagMessage = (decision: NagDecision): string => { if (decision.count === 0) return ''; const notes = decision.count === 1 ? 'note' : 'notes'; const days = Math.floor(decision.ageMs / (24 * HOUR_MS)); const hours = Math.floor(decision.ageMs / HOUR_MS); // Said in the unit a person would use. "31 hours" is arithmetic; "over a day" is the fact. const oldestSaid = days >= 1 ? `over ${days === 1 ? 'a day' : `${days} days`}` : `${hours} hours`; return decision.count === 1 ? `You have an unfinished note from ${oldestSaid} ago.` : `You have ${decision.count} unfinished ${notes}. The oldest is from ${oldestSaid} ago.`; };