import { daysUntilDraftExpiry, draftExpiresAtMs } from '../../../services/scribe/draftRetentionPolicy'; import { DRAFT_NAG_ELEVATED_DAYS_REMAINING } from '../../../services/scribe/draftNag'; /** * "Waiting on you" -, the practitioner overview's owed-work column. * * WHY IT EXISTS, in the design's own words: the overview only knows about TODAY, so everything a * clinician owes from before today has no surface anywhere in the product. **The stagnant-draft toast is * the proof we already knew** - it exists because a waiting note is *"usually NOT on today's queue."* * A toast is what you build when the surface is missing. This is the surface, and slice 6 retires it. * * THE THRESHOLD IS DELIBERATELY DIFFERENT FROM THE TOAST'S, and that is the whole distinction between * the two. The nagger waits 24 hours before speaking because INTERRUPTING is expensive. A list is not an * interruption, so it has no reason to hide work for a day - a worklist that goes quiet about a note you * finished recording an hour ago is the toast's logic wearing a list's clothes. * * NOTHING HERE READS THE VIEW TOGGLE. Design §4.2: a nurse practitioner in "Rooming patients" who also * has an unfinished note would otherwise have that note HIDDEN by a view setting - precisely the failure * the card exists to prevent. Rows are absent because they are EMPTY, never because of a filter. */ /** The subset of a scribe draft summary this derivation needs. */ export interface OwedScribeDraft { encounterId: string; createdAtMs: number; /** The server's own expiry instant when the caller has it - it wins over the client's copy of the * retention policy. See `draftRetentionPolicy.draftExpiresAtMs`. */ expiresAtMs?: number; /** Set once a human has adopted the note into the record; such a draft is no longer owed. */ acceptedAtMs?: number | null; } export interface OwedWorkRow { id: 'notes' | 'messages' | 'results'; count: number; label: string; /** The deadline line, when one applies. `undefined` is not "no deadline" - it is "none is close". */ detail?: string; /** * The same deadline at a glance, for the 64px rail - `today`, `1d`, `3d`.. * * A SECOND RENDERING, NEVER A SECOND DERIVATION. Both come from `expiryDetail`'s own numbers in one * place, because the last time this deadline was computed twice the overview said *26 days* and the * draft panel said *27* for one draft. Do not recompute either from the drafts at a * render site - `WaitingOnYouCard` takes its rows as a prop and reaches for nothing on purpose. * * It exists because the rail is ~40px of content and the sentence is not the part that fits. The * sentence still reaches a screen reader and a hover, as the card's accessible name. */ detailShort?: string; } /** * What the card is built from. * * EVERY SOURCE IS OPTIONAL AND `undefined` MEANS NOT LOADED. The all-clear may only be printed once * every source has answered - one unloaded source is enough to make *"Nothing waiting on you"* a claim * nobody measured. */ export interface OwedWorkSources { scribeDrafts?: OwedScribeDraft[]; /** Threads whose last word is not ours -. Not the unread count. */ messagesAwaitingReply?: number; /** * Results this practitioner ordered that have come back and nobody has acknowledged. * * THE THIRD THING A CLINICIAN OWES, and the one with a patient on the other end of it. A result * nobody looked at is the classic harm case in this area, and until this row existed the only * surface that knew a result had come back was the practice's own bench queue - which a * practitioner has no reason to be able to read. */ resultsAwaitingReview?: number; /** * A source we asked for and could not read, as opposed to one that has not answered yet. * * THE DIFFERENCE IS THE WHOLE POINT AND IT WAS MISSING. "Not loaded" is a state that ENDS - the * card waits, the answer arrives, the all-clear becomes printable. A source that cannot be reached * never ends, so folding the two together means the all-clear is withheld for ever and the card, * which renders nothing until it knows, disappears completely. Measured in the browser: a * practitioner's whole "Waiting on you" card vanished because one of its three sources was * unreachable. * * So an unreadable source stops BLOCKING the card and starts being REPORTED by it. What it must * never do is contribute to the all-clear, because "nothing is waiting on you" would then be a * claim about a list nobody could read - the false zero this module exists to prevent. */ resultsUnreadable?: boolean; /** Read, but the server stopped before its last page: a floor, never a total or an all-clear. */ resultsPartial?: boolean; } export interface OwedWork { rows: OwedWorkRow[]; /** * What could not be checked, in the reader's words, so the card can say so. * * Never a reason or an error: a clinician can do nothing about why, and the one thing they need is * to know that this list is not the whole picture. */ unreadable: string[]; /** Lists that were read only in part, in the reader's words. */ partial: string[]; /** * True only when we looked and there is nothing. Distinct from *not loaded* - the caller must not * print an all-clear over a list that has not arrived, which is the false-zero the patient home's * account band was fixed for. */ nothingOwed: boolean; } /** * Days left before this draft is destroyed, rounded DOWN. * * Down, never up: "expires in 4 days" when 4.9 remain is a day early and harmless; the same rounding * up on 3.1 promises a day that does not exist, and at expiry the draft AND the audio it came from are * both already gone. There is nothing to regenerate from. * * **THE RULE MOVED TO `draftRetentionPolicy`, WHERE THE RETENTION CONSTANT LIVES - because it was * being obeyed HERE and disobeyed in two other files.** The draft panel inside the encounter and the * retired nagger each rounded UP, so one draft printed *"26 days"* on the overview and *"27 days left"* * inside the visit, in the same minute. A rule written next to only one of its three implementations * is a rule the other two never read. This is now a thin re-export so existing callers keep working. */ /** Where a "Result to review" opens: that patient's Tests & imaging, on the exact order. None without a patient. */ export const resultToReviewPath = (result: { patientId?: string; orderId?: string }): string | undefined => result.patientId ? `/dashboard/patients/${result.patientId}/care-provision/diagnostic-orders${result.orderId ? `?order=${encodeURIComponent(result.orderId)}` : ""}` : undefined; export const daysUntilExpiry = (draft: OwedScribeDraft, nowMs: number): number => daysUntilDraftExpiry(draft, nowMs); /** A draft is owed while it is unaccepted and still exists. */ export const isDraftOwed = (draft: OwedScribeDraft, nowMs: number): boolean => draft.acceptedAtMs == null && nowMs < draftExpiresAtMs(draft); const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`; /** * The deadline line, or `undefined` when nothing is close enough to be worth a deadline. * * ONLY THE RECORDING DRAFT HAS ONE (design §4.5) - the clinician's own typed note is not destroyed on a * timer. Saying so is what lets the toast go: the toast's entire job was carrying this sentence. * The window is `DRAFT_NAG_ELEVATED_DAYS_REMAINING`, reused rather than re-chosen. A second threshold * would let the list and the nagger disagree about when a note is in danger. */ export const expiryDetail = (drafts: OwedScribeDraft[], nowMs: number): string | undefined => { const soonest = soonestClosingDays(drafts, nowMs); if (soonest === undefined) return undefined; const closing = closingDrafts(drafts, nowMs); // "in 0 days" is not a sentence anyone can act on, and the last day is the one that matters. if (soonest <= 0) { return closing.length === 1 ? '1 expires today' : `${closing.length} expire today`; } return closing.length === 1 ? `1 expires in ${plural(soonest, 'day')}` : `${closing.length} expiring, soonest in ${plural(soonest, 'day')}`; }; const closingDrafts = (drafts: OwedScribeDraft[], nowMs: number): OwedScribeDraft[] => drafts.filter((draft) => daysUntilExpiry(draft, nowMs) <= DRAFT_NAG_ELEVATED_DAYS_REMAINING); /** Days until the soonest draft in danger, or `undefined` when none is close. The one number both * renderings below are built from - see `OwedWorkRow.detailShort`. */ const soonestClosingDays = (drafts: OwedScribeDraft[], nowMs: number): number | undefined => { const closing = closingDrafts(drafts, nowMs); if (closing.length === 0) return undefined; return Math.min(...closing.map((draft) => daysUntilExpiry(draft, nowMs))); }; /** * The same deadline at a glance - `today`, `1d`, `3d`.. * * THE RAIL IS ~40px OF CONTENT AND THE SENTENCE IS NOT THE PART THAT FITS. `expiryDetail`'s wording is * right where there is a line to put it, and unreadable where there is not - measured at container 743, * the compact card's *"Waiting on you"* rendered **49 × 40px against a 20px line-height**, i.e. two lines, * while the deadline beside it truncated to nothing. **The label goes and the deadline stays**, which is * what that component's own comment already said it would do. * * THE COUNT IS DELIBERATELY NOT IN HERE. `expiryDetail` leads with it (*"3 expiring, soonest in..."*) * because it is a sentence; the rail already renders the owed TOTAL as its own number one line above, and * a second, different count directly beneath it reads as two facts rather than one. */ export const expiryDetailShort = (drafts: OwedScribeDraft[], nowMs: number): string | undefined => { const soonest = soonestClosingDays(drafts, nowMs); if (soonest === undefined) return undefined; return soonest <= 0 ? 'today' : `${soonest}d`; }; /** * What is waiting on this clinician right now. * * A SOURCE OF `undefined` MEANS NOT LOADED, and it is not an empty one: `nothingOwed` stays false so * the caller renders neither rows nor an all-clear *. * **Each source contributes its rows as soon as it answers**, so a slow one never withholds a row that * is already known - but the ALL-CLEAR waits for all of them, because that sentence is the only output * here that claims something about a source rather than reporting it. */ export const deriveOwedWork = ( sources: OwedWorkSources, nowMs: number, ): OwedWork => { const rows: OwedWorkRow[] = []; const owedDrafts = sources.scribeDrafts?.filter((draft) => isDraftOwed(draft, nowMs)); if (owedDrafts && owedDrafts.length > 0) { rows.push({ id: 'notes', count: owedDrafts.length, label: owedDrafts.length === 1 ? 'Note to finish' : 'Notes to finish', detail: expiryDetail(owedDrafts, nowMs), detailShort: expiryDetailShort(owedDrafts, nowMs), }); } const messages = sources.messagesAwaitingReply; if (messages !== undefined && messages > 0) { rows.push({ id: 'messages', count: messages, // "Awaiting your reply", never "unread" - design §7 deletes unread as the signal. A message // you read and did not answer is still owed, and an unread count goes quiet exactly while the // work piles up. label: messages === 1 ? 'Message awaiting your reply' : 'Messages awaiting your reply', }); } const results = sources.resultsAwaitingReview; if (results !== undefined && results > 0) { rows.push({ id: 'results', count: results, // "To review", never "new" or "unread". The act this counts is a clinician ACKNOWLEDGING a // result, which is a clinical decision with a record behind it - the same distinction the // messages row draws, for the same reason. label: sources.resultsPartial ? 'Results to review (list incomplete)' : results === 1 ? 'Result to review' : 'Results to review', }); } const unreadable = sources.resultsUnreadable ? ['Results'] : []; const partial = sources.resultsPartial && !sources.resultsUnreadable ? ['Results'] : []; const everySourceAnswered = sources.scribeDrafts !== undefined && messages !== undefined && (results !== undefined || sources.resultsUnreadable === true); return { rows, unreadable, partial, // An unreadable source can never make the all-clear TRUE, only stop it hanging for ever. nothingOwed: rows.length === 0 && everySourceAnswered && unreadable.length === 0 && partial.length === 0, }; };