import { useMemo } from "react"; import type { Appointment, Task } from "fhir/r4"; import { useFhirAppointments, useFhirTasks } from "../../../hooks"; import { useCommunicationManager } from "../../../hooks/useCommunicationManager"; import { isOutstandingTask, isPatientsOwnTask, usePatientServiceContext, useTaskServiceContext, } from "../../../services"; import type { Countable } from "../../../utils/counting/countingSelector"; import { categorizePatientAppointments } from "../../../utils/patientAppointmentCategorization.ts"; import { buildPatientAccountSummary, type SummaryItem } from "./patientAccountSummary.ts"; import { usePatientUnpaidInvoices } from "./usePatientUnpaidInvoices.tsx"; import { exactOwedCents } from "./exactOwedCents"; import { openReferrals, usePatientHomeReferrals } from "./patientHomeReferrals.ts"; import { isStandingPlanInstruction } from "../../../utils/planActivity.ts"; import { mayMissRecentWrite } from "../../../utils/recentWritesUnresolved.ts"; /** * Feeds the account summary from the sources the home ALREADY holds., slice 2. * * THE POINT OF THIS FILE IS THAT IT ADDS NO NEW ANSWER TO AN EXISTING QUESTION. D3's constraint is * one selector for the strip, the rows and the peer counts, and that governs *what counts* as much as * the arithmetic: * - appointments and tasks come from the SAME FHIR contexts the home's own cards read, with * `autoLoad: false` - the cards already load them, so this reads their state rather than firing a * second request that could resolve to a different answer; * - tasks use **`isOutstandingTask`**, the very predicate the Tasks card filters on. A second * definition of "outstanding" is how a header count and its list start disagreeing; * - unread comes from the same manager the sidebar badge reads, per thread - which is also what * makes "oldest 3d" a real measurement rather than an invented one. * * EXCEPT INVOICES, and the exception is deliberate. Reading the shared `invoiceList` alone would * report **zero because nobody asked** - the list is only populated once someone opens the invoices * page, and a patient who has not would be told they owe nothing. That is exactly the defect class * is open on, so this fetches through `fetchInvoicesForPatient`, the same call that page * makes. */ /** * THIS WAS A THIRD DEFINITION OF "SCHEDULED", and the file's own rule above forbids exactly that. * * It filtered on `start >= now`, which files a visit under *not scheduled* the moment it begins - so a * patient sitting in a video visit was told **"0 scheduled appointments"** by the band while the * appointment card beside it showed the visit. `categorizePatientAppointments` has kept an appointment * until its `end` since; the band and the queue were both still comparing `start`. * * The status set it carried is not lost - it is the same set `categorizePatientAppointments` arrives * at by exclusion, and B28's point (a cancelled appointment is not "scheduled") is that function's first * rule. What is gone is the second copy that could drift from it. */ export const upcomingAppointments = (appointments: Appointment[], now: Date): Appointment[] => categorizePatientAppointments(appointments, now).upcoming; const byStart = (a: Appointment, b: Appointment) => new Date(a.start ?? 0).getTime() - new Date(b.start ?? 0).getTime(); export const usePatientAccountSummary = (now: Date = new Date()): SummaryItem[] => { /* * `autoLoad: false` - the home's own Appointment cards load these; this reads their state. * * AND THE BUNDLE IS READ ALONGSIDE THE LIST, BECAUSE THE LIST CANNOT SAY "I DO NOT KNOW YET". * `appointmentsList` starts as `[]` and stays `[]` until a fetch lands, so an empty list means * either "this patient has none" or "nobody has asked yet" - and those are opposite facts. The * BUNDLE starts as `null` and is only ever set from a response, so it is the honest signal. * * WHAT THIS COST WHEN IT WAS MISSING, observed 2026-08-31: a patient signed in and their home said * "0 scheduled appointments" with the Next Appointment card empty, while the booking dialog in the * same session refused a new booking because they already had one on a named date with a named * clinician. Two parts of one portal disagreeing, and the wrong one stated as a fact. * * It is NOT the search-index lag the referral and appointment cards are about. Nothing was * lagging - the data was already retrievable, and one surface simply rendered a count before it * had one. Do not reach for the carry-the-written-record fix here. */ const { appointmentsList, appointmentsBundle } = useFhirAppointments({ autoLoad: false }); // TASKS LOADS ITSELF, AND IT HAS TO. This read `autoLoad: false` on the same reasoning as the line // above - "the home's own Tasks card loads these" - and that card is GONE: `HomePage.tsx` carries // `PatientTasks` commented out at both its import and its mount since the patient-portal rebuild. // // So nothing on the home fetched tasks, this read an empty cache, and the tile said "0 tasks to // complete" while the tasks page listed three. The tile was not answering a NARROWER question than // the page - it was not answering one at all. // // AND THE ZERO IS THE WORST POSSIBLE WRONG ANSWER HERE, because there is no Tasks item in the patient // portal nav: this tile IS the route to that page, and nobody clicks a zero. A patient with three // outstanding tasks was shown a dead end. // // The arithmetic below is untouched - `isPatientsOwnTask && isOutstandingTask`, the same predicates // the Tasks page filters on. What was missing was the fetch, not the rule. // // AND THE FALSE-ZERO GUARD COULD NEVER HAVE CAUGHT THIS ONE, WHICH IS WORTH KNOWING. // `summaryNeverAssertsAFalseZero` proves the summary distinguishes "not loaded" (`undefined`) from // "nothing" (`[]`) - but `tasksList` initialises to `[]` in TaskService, so this caller could not hand // it `undefined` and the third state was unreachable from here. The guard was right; its input was // already a lie by the time it arrived. const { tasksList } = useFhirTasks(); // NOT the hook's `loading` flag, and the near miss is the point: `loading` starts FALSE and is only // set inside an effect, so there is a painted frame where nothing is loading and nothing is loaded - // exactly the confident 0 this is here to prevent. // `tasksBundle` + `cachedForPatientId` is the SAME test the hook itself uses to decide whether to // fetch, so "we have an answer for THIS patient" cannot drift from "we asked for it". It also covers // the case a flag cannot: a bundle left behind by the previous chart is not this patient's zero. const { tasksBundle, cachedForPatientId } = useTaskServiceContext(); const { patientState } = usePatientServiceContext(); const tasksAnswered = Boolean(tasksBundle) && cachedForPatientId === patientState?.id; const tasksMayBeShort = mayMissRecentWrite(tasksBundle); // `getCommunicationThread` IS HERE AS AN ARRIVAL TEST, NOT TO READ A THREAD. The unread count does // not live on the request - `getUnreadCountForRequest` looks the id up in a SEPARATE threads map and // returns 0 for a miss, so between the request list landing and the threads landing every thread // reads as read. That window is not theoretical: measured in a browser 2026-08-31, the tile stated // "0 unread" for 1.4s and then flipped to "1", with no reload and nothing else on the page changing. // // The two arrive separately BY CONSTRUCTION - the requests are set as soon as they are fetched, then // one message search per request runs and the map is set ONCE, after all of them settle. So the map // is the later of the two, always, and asking whether it holds an entry for every request is exactly // "have the counts arrived". // // IT CANNOT HANG WAITING: initialization writes a thread for every request including the ones whose // message fetch THREW, keyed by the request id it was passed. And a patient with no threads at all // passes vacuously, so a genuine zero is still reachable, which is control 3 on this card. const { patientCommunicationRequests, getUnreadCountForRequest, getCommunicationThread, communicationDataLoaded } = useCommunicationManager(); // AND `communicationDataLoaded` IS THE HALF THAT THE THREAD TEST CANNOT COVER, measured in a browser // 2026-08-31 against the first version of this guard. // // The request list initialises to `[]` in the service, so before anything is fetched it is TRUTHY and // `.every()` over it is VACUOUSLY TRUE - the guard passed and the tile stated a confident 0 for three // seconds, then correctly withheld once the requests landed, then showed 1. Half a fix reads as a whole // one on any warm screen, which is how it survived its own tests. // // It is the same shape as the `appointmentsList &&` this file already carries a comment about: an empty // array that cannot say whether anybody asked. Two questions, and BOTH have to be answered - has the // load finished, and are the counts in hand for the requests it returned. const messagesAnswered = communicationDataLoaded && (patientCommunicationRequests ?? []).every( (request) => getCommunicationThread(request.id as string) !== undefined, ); // ONE LOAD, shared with the urgent banner. Two hooks each fetching would be two answers to one // question, on the surface where being wrong costs money -- see usePatientUnpaidInvoices, which // also carries why it FETCHES rather than reading the shared list. const unpaid = usePatientUnpaidInvoices(); const { referrals } = usePatientHomeReferrals(); return useMemo(() => { // `undefined` IS PASSED THROUGH, NEVER COALESCED TO `[]`, and is why: a source that has // not arrived would otherwise be counted as zero and rendered as a confident answer. Measured in a // browser - "0 invoices unpaid" for ~2s on in-app return, while $245 was owed across two. // // ALL FOUR, not just invoices. Spencer's ruling that every tile renders including zeros is right, // and it MULTIPLIES a false zero by four: each of these lists is empty before its source answers. // So `?? []` here is not a tidy-up - it is the defect. // AND THIS GUARD WAS THE ONE THAT COULD NEVER FIRE. It read `appointmentsList &&`, which looks // like the same protection the other three tiles have and is not: that list is initialised to `[]` // and is therefore ALWAYS truthy, so the tile reported a confident zero from the first paint. The // bundle is `null` until a response sets it, so it is what actually distinguishes "none" from // "not asked yet" - which is the distinction this whole block exists to make. const upcoming = appointmentsBundle ? upcomingAppointments(appointmentsList, now).sort(byStart) : undefined; // THE SAME THIRD STATE THE OTHER THREE TILES GET, and this one had to be built rather than read. // The optional chain above it looked like the guard the invoices tile has and was not: it only // asks whether the REQUEST LIST arrived, and the number on screen comes from the threads map. const messages: Countable[] | undefined = messagesAnswered ? (patientCommunicationRequests ?? []) .filter((request) => getUnreadCountForRequest(request.id as string) > 0) .map((request) => ({ id: request.id as string, // When the thread last moved - which is the age that matters, not when it was created. since: request.meta?.lastUpdated ?? request.authoredOn, })) : undefined; // NOT `?? []` - the OPPOSITE, and it is the same defect wearing the other face. TaskService // initialises `tasksList` to `[]`, so unlike the three sources beside it this one cannot report // itself as unarrived: it says "nothing" from the first render. Withholding it until the cache is // provably this patient's is what gives this source the third state the others get for free. // A list that may be missing a recent change has no exact count either, so its count is withheld. const tasks: Countable[] | undefined = (tasksAnswered && !tasksMayBeShort ? tasksList : undefined) // `isPatientsOwnTask` FIRST, and every patient task surface applies it: `Task?patient=` // returns everything a task is ABOUT this patient, which includes the referrals the practice // must send. Counting one here would put a number on the band that no screen can explain. ?.filter((task: Task) => isPatientsOwnTask(task) && isOutstandingTask(task) && !isStandingPlanInstruction(task)) .map((task: Task) => ({ id: task.id as string, since: task.authoredOn })); // Open referrals are tasks too, the same ones the queue lists; unread, the count is withheld. const tasksAndReferrals: Countable[] | undefined = tasks && referrals ? [...tasks, ...openReferrals(referrals).map((referral) => ({ id: referral.id, since: referral.authoredOn }))] : undefined; return buildPatientAccountSummary( { appointments: upcoming?.map((appointment) => ({ id: appointment.id as string })), nextAppointmentStart: upcoming?.[0]?.start, messages, tasks: tasksAndReferrals, // A list with a practice or page missing has no exact count; an unknown amount withholds the total. invoices: unpaid?.complete ? unpaid.invoices.map((invoice) => ({ id: invoice.id, amountCents: exactOwedCents(unpaid) === undefined ? undefined : invoice.amountCents, })) : undefined, }, now, ); }, [appointmentsList, appointmentsBundle, tasksList, tasksAnswered, tasksMayBeShort, patientCommunicationRequests, messagesAnswered, getUnreadCountForRequest, unpaid, referrals, now]); };