/** * - the stagnant-draft nagger. * * **Spencer, 2026-08-11:** *"We can add a toast nagger if drafts remain stagnant for more than 24 hours or * something, similar to what we do for the receptionist for appointments they haven't updated the status * on after the appointment time."* * * Deliberately mirrors `ReceptionistHomePage.tsx`'s late-appointment toast rather than inventing a second * nagging mechanism. The four properties worth copying, because each one is load-bearing: * * 1. **A stable per-item toast id.** `getLateAppointmentToastId(id)` is what lets the 30-second clock * re-assert the same toast instead of stacking a new one on every tick. Without it a nagger becomes * a spammer within a minute and gets muted by the user, which is worse than not having it. * 2. **Graded thresholds as named constants**, not magic numbers at the call site. * 3. **A retention predicate.** The receptionist version keeps nagging while the appointment sits in a * non-actioned status and drops off the moment someone actions it. Here, accepting the note is the * action - an accepted draft must vanish from the nagger instantly, or people learn to ignore it. * 4. **The toast is clickable and goes to the thing that needs doing.** A nag you cannot act on from * where it appears is just noise. * * WHY THE ESCALATION IS SHAPED DIFFERENTLY FROM THE APPOINTMENT ONE * * A late appointment is wrong *immediately* - the schedule is lying about the room right now - so that one * escalates over minutes (15 30 60). A draft at 24 hours is not wrong yet, it is merely waiting, and * with a 30-day retention window nagging hourly from hour 24 would fire hundreds of times for one note. * * So the entry point is Spencer's 24 hours, and the escalation is anchored to the thing that is actually * irreversible: **expiry**. At expiry the draft is destroyed AND the audio it came from was already * deleted when the job completed, so the note is unrecoverable - see scribeDraftRetention.ts. That is the * only moment where inaction costs something permanent, and it is where the pressure belongs. */ import { draftExpiresAtMs, daysUntilDraftExpiry, type ScribeDraftLike, } from './draftRetentionPolicy'; /** Spencer's threshold: a draft is not worth mentioning until it has sat for a day. */ export const DRAFT_NAG_AFTER_HOURS = 24; /** Days remaining at which the nag escalates - the point where losing the note becomes plausible. */ export const DRAFT_NAG_ELEVATED_DAYS_REMAINING = 7; /** Days remaining at which it becomes the receptionist toast's "Immediate action required" register. */ export const DRAFT_NAG_CRITICAL_DAYS_REMAINING = 2; /** Mirrors ReceptionistHomePage's `LateUrgency` vocabulary so the two naggers read as one system. */ export type DraftNagUrgency = 'recent' | 'elevated' | 'stale'; /** Mirrors `LateAppointmentToastTone`. */ export type DraftNagTone = 'warning' | 'critical' | 'overdue'; const MS_PER_HOUR = 60 * 60 * 1000; const MS_PER_DAY = 24 * MS_PER_HOUR; /** * Stable per-draft toast id - the property that makes this a nagger rather than a spammer. * * Keyed on the ENCOUNTER, not the draft row. A regenerated draft for the same encounter is the same * outstanding job from the practitioner's point of view, and giving it a fresh id would pop a second toast * for a note they have already been told about. */ export const getScribeDraftToastId = (encounterId: string): string => `scribe-draft-pending-${encounterId}`; export interface ScribeDraftNagItem extends ScribeDraftLike { encounterId: string; } export interface ScribeDraftNag { draft: T; toastId: string; urgency: DraftNagUrgency; tone: DraftNagTone; daysRemaining: number; message: string; } // A LOCAL COPY, DELETED. It derived expiry from `createdAtMs` alone and so ignored the server's own // `expiresAtMs` when the caller had one - the second of the two sources this card reconciled. const expiresAtMs = (draft: ScribeDraftLike): number => draftExpiresAtMs(draft); /** * Whether this draft should currently be nagged about. * * Accepted drafts drop off immediately - the receptionist nagger's actioned-status rule, applied to the * only action that matters here. Expired drafts also drop off: the toast asks someone to review a note, * and a note that no longer exists cannot be reviewed. Surfacing the loss is a separate concern and a * different message. */ export function shouldNagAboutDraft(draft: ScribeDraftLike, nowMs: number): boolean { if (draft.acceptedAtMs != null) { return false; } const age = nowMs - draft.createdAtMs; if (age < DRAFT_NAG_AFTER_HOURS * MS_PER_HOUR) { return false; } return nowMs < expiresAtMs(draft); } export function draftNagUrgency(draft: ScribeDraftLike, nowMs: number): DraftNagUrgency { const daysRemaining = (expiresAtMs(draft) - nowMs) / MS_PER_DAY; if (daysRemaining <= DRAFT_NAG_CRITICAL_DAYS_REMAINING) { return 'stale'; } if (daysRemaining <= DRAFT_NAG_ELEVATED_DAYS_REMAINING) { return 'elevated'; } return 'recent'; } const TONE_BY_URGENCY: Record = { recent: 'warning', elevated: 'critical', stale: 'overdue', }; /** * The nags to show right now, most urgent first. * * Returning the whole list (rather than a count) is deliberate: each one carries its own stable toast id * so the caller can `toast.custom(..., { id })` per draft and let sonner reconcile, exactly as the * receptionist page does on its 30-second clock. */ export function scribeDraftNags( drafts: readonly T[], nowMs: number, ): ScribeDraftNag[] { return drafts .filter((draft) => shouldNagAboutDraft(draft, nowMs)) .map((draft) => { const urgency = draftNagUrgency(draft, nowMs); // WAS `Math.ceil`, which disagreed with the owed-work list about the same draft by a // whole day. This module stays alive after its toast was retired precisely so the list and // the nagger "cannot disagree about danger" - and they were disagreeing about the DATE. const daysRemaining = Math.max(0, daysUntilDraftExpiry(draft, nowMs)); return { draft, toastId: getScribeDraftToastId(draft.encounterId), urgency, tone: TONE_BY_URGENCY[urgency], daysRemaining, message: nagMessage(urgency, daysRemaining), }; }) .sort((a, b) => a.daysRemaining - b.daysRemaining); } /** * The critical message says what is LOST, not merely that something expires. "Expires in 2 days" reads * like a subscription renewal; a practitioner needs to know what cannot be regenerated, because the audio * is already gone. * * CORRECTED FOR E3, and the old wording is a good example of a true sentence going false * underneath you. It said the note *"cannot be recovered"*. That was accurate while the generated draft * was the only copy - but under **E2** its grounded text is merged into the encounter note the first time * the practitioner opens it, and under **E3** that note is itself saved server-side. So for the common * case the sentence now OVERSTATES the loss, and a warning that overstates gets discounted, including on * the day it is right. * * What is genuinely and permanently lost at expiry is **whatever was never carried into the note** - * which is exactly the "Not supported by the recording" list, the sentences that each need a deliberate * Add. So the message now names that, which is both true in every case and the only actionable thing to * say. Do not put "cannot be recovered" back without checking whether E2/E3 still hold. */ function nagMessage(urgency: DraftNagUrgency, daysRemaining: number): string { const days = `${daysRemaining} day${daysRemaining === 1 ? '' : 's'}`; switch (urgency) { case 'recent': return 'A visit note is drafted and waiting for your review.'; case 'elevated': return `A drafted visit note is still unreviewed. It will be deleted in ${days}.`; case 'stale': return `A drafted visit note will be permanently deleted in ${days}. Anything in it you have not added to the visit note goes with it, and the recording it came from has already been erased.`; } }