import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useOptionalScribeService, type ScribeDraftSummary } from '../../../services/ScribeService'; import { useOverviewMessageFeed } from '../../../hooks/useOverviewMessageFeed'; import { RESULTS_UNREADABLE, useResultsAwaitingReview, type ResultAwaitingReview, } from '../../../hooks/useResultsAwaitingReview'; import type { OverviewMessageRow } from '../../../utils/overviewMessageFeed'; import type { SendOperation } from '../../../utils/messaging/sendOperation'; import { deriveOwedWork, isDraftOwed, type OwedWork } from './owedWork'; export interface OwedWorkState { /** The card's rows, and whether an all-clear may be stated. */ work: OwedWork; /** * The items behind the counts, for the panel a row opens. * * THE SAME SETS THE COUNTS CAME FROM. A panel that lists one thing while the row above counts * another is the defect this codebase keeps paying for - so both are derived here, from one filter * each, rather than re-derived by the panel. */ drafts: ScribeDraftSummary[]; messages: OverviewMessageRow[]; /** The results behind the count, in the same set the count came from. */ results: ResultAwaitingReview[]; sendReply: (row: OverviewMessageRow, text: string, send?: SendOperation) => Promise; /** * - take a conversation out of this list without sending anything. * * The reason the count needs an exit at all: *awaiting reply* is derived from who spoke last, so a * patient's "Thank you, that helps" owed a reply forever unless the clinician typed one - **which * fires a notification at the patient purely to clear the clinician's own dashboard.** A count nobody * can get to zero stops being read, and that is how this card's badge would have become wallpaper. * * It REJECTS on failure rather than resolving quietly. A dismissal that did not save would leave a * clinician believing they had cleared a thread that is still owed. */ dismissThread: (row: OverviewMessageRow) => Promise; } /** * The clinician's owed work, loaded once and kept fresh -. * * IT POLLS THE SAME ENDPOINT AS THE TOAST FOR NOW, AND THAT IS TEMPORARY BY DESIGN. `useScribeDraftNags` * fetches `/scribe/drafts` on the same 10-minute cadence, so until slice 6 retires the toast this page * makes two identical requests every ten minutes. Sharing a fetcher now would mean editing that hook, * which belongs to the session holding the encounter workspace - and the card's own build order says the * toast retires AFTER the card that replaces it exists, never before. Two cheap polls for one card's * lifetime is the smaller cost. * * `undefined` until the first answer arrives, never `[]`. An empty array here would print *"Nothing * waiting on you"* over a list that has not loaded - an all-clear we have not measured. */ const REFETCH_MS = 10 * 60 * 1000; /** Day-scale deadlines: recomputing every minute moves the number when it actually changes. */ const RECOMPUTE_MS = 60 * 1000; export const useOwedWork = (isPractitioner: boolean): OwedWorkState => { const scribe = useOptionalScribeService(); // The route answers a practitioner and refuses anybody else BY NAMING THE REFUSAL, so asking as a // non-practitioner would be a 403 on every poll on a landing screen. Passing what the caller is // lets the hook answer the honest empty list instead, which is a different fact from a failed read // and is the only one of the two allowed to contribute to an all-clear. const { results: resultsAwaitingReview, partial: resultsPartial } = useResultsAwaitingReview(isPractitioner); const resultsUnreadable = resultsAwaitingReview === RESULTS_UNREADABLE; const resultRows = Array.isArray(resultsAwaitingReview) ? resultsAwaitingReview : undefined; const { awaitingReplyRows, awaitingReplyCount, loading: messagesLoading, sendReply, dismissThread } = useOverviewMessageFeed(); const [drafts, setDrafts] = useState(undefined); const [clock, setClock] = useState(() => Date.now()); const scribeRef = useRef(scribe); scribeRef.current = scribe; const mountedRef = useRef(true); useEffect(() => () => { mountedRef.current = false; }, []); const load = useCallback(() => { // No scribe service mounted: an empty list is the HONEST answer, not a guess. A recording draft // can only exist where recordings happen, so with no service there are none - unlike a failed // fetch below, where we asked and did not find out. The two look identical from the outside and // must not be treated the same: this one may say "nothing waiting", that one may not. if (!scribeRef.current) { setDrafts([]); return; } void scribeRef.current.listDrafts() .then((next) => { if (mountedRef.current) setDrafts(next); }) // ⛔ Silent, and it stays UNLOADED on failure. A background poll that could not reach the // server has not established that nothing is owed, and the all-clear is the one sentence // that must never be printed on a guess. // ⛔ AND IT DOES NOT RESET `drafts` — not to `undefined`, not to `[]`. A refresh that blanked // the card mid-visit would replace a true count with a loading state on every failed poll. .catch(() => {}); }, []); useEffect(() => { load(); const refetch = window.setInterval(load, REFETCH_MS); const recompute = window.setInterval(() => setClock(Date.now()), RECOMPUTE_MS); return () => { window.clearInterval(refetch); window.clearInterval(recompute); }; }, [load]); /** * A DRAFT JUST LANDED ON THIS SCREEN - re-ask, rather than wait out the ten minutes. * * The defect, measured: *Finish & transcribe* at 20:05:31, the draft panel populated on its own at * ~20:06:03, and this card - **18 inches away on the same screen** - still read `1` at 20:10:09. A * route change ( not a document reload, so nothing was re-fetched from scratch and the server answer * never changed) made it `2` instantly. **The data was there the whole time; only the trigger was * missing.** * * WHY THAT MATTERS MORE THAN A FOUR-MINUTE LAG SOUNDS: **this is the number that was allowed to * retire the scribe-draft nag toast** ( slice ⑥), on the argument that *"a toast is what you * build when the surface is missing."* A surface that can be ten minutes stale about a note recorded * on its own screen is weaker than the thing it replaced - in exactly the window where a clinician * decides whether they are done for the day. * * A SEPARATE EFFECT FROM THE INTERVALS ABOVE, deliberately. Adding the signal to that effect's * dependencies would TEAR DOWN AND RESTART both timers on every draft, so the ten-minute poll and the * one-minute deadline recompute would silently re-phase around recordings. * AND `REFETCH_MS` IS UNTOUCHED: this page already issues two identical `/scribe/drafts` requests * per interval, so halving it doubles a duplicated request and still leaves a window. * `0` is the initial value and is skipped - the interval effect above has already done that load, * and firing here too would make every mount fetch the list twice. */ const draftsChangedCount = scribe?.draftsChangedCount ?? 0; useEffect(() => { if (draftsChangedCount === 0) return; load(); }, [draftsChangedCount, load]); const work = useMemo( () => deriveOwedWork( { scribeDrafts: drafts, // `undefined` WHILE LOADING, not 0. The feed reports zero for "no threads" and for // "still asking", and only the first of those may contribute to an all-clear. messagesAwaitingReply: messagesLoading ? undefined : awaitingReplyCount, resultsAwaitingReview: resultRows?.length, resultsUnreadable, resultsPartial, }, clock, ), [drafts, clock, awaitingReplyCount, messagesLoading, resultRows, resultsUnreadable, resultsPartial], ); // The SAME predicate the count came from, not a second reading of "owed". Two filters that agree // today are two filters that disagree the first time one of them grows a condition. const owedDrafts = useMemo( () => (drafts ?? []).filter((draft) => isDraftOwed(draft, clock)), [drafts, clock], ); return useMemo( () => ({ work, drafts: owedDrafts, messages: awaitingReplyRows, results: resultRows ?? [], sendReply, dismissThread, }), [work, owedDrafts, awaitingReplyRows, resultRows, sendReply, dismissThread], ); };