import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Appointment, Communication, CommunicationRequest, Organization, Task, } from "fhir/r4"; import { useFhirAppointments, useFhirPractitioners, useFhirTasks } from "../../../hooks"; import type { PractitionerWithRoles } from "../../../services/PractitionerService.tsx"; import { resolveAppointmentPractitionerLabel } from "../../../utils/appointmentParticipants.ts"; import { useCommunicationManager } from "../../../hooks/useCommunicationManager"; import { getAkashicTaskKind, isOutstandingTask, isPatientsOwnTask, hasPatientFacingTaskScreen, useCommunicationServiceContext, useAppointmentServiceContext, usePatientServiceContext, useTaskServiceContext, formatCustomTaskDueDate, getCustomTaskDueDisplay, resolveTaskAppointment, type AkashicTaskKind, } from "../../../services"; import { formatRelativeTime } from "../../../utils/formatRelativeTime.ts"; import { visitListProvesNoneBooked } from "../../../utils/visitListProvesNoneBooked.ts"; import { getThreadOrganizationId, resolveCounterpartyReference, toSelfReference, } from "../../../utils/messaging/counterparty"; import { getThreadPoolKey, poolThreadLabel } from "../../../utils/messaging/poolThread"; import { categorizePatientAppointments } from "../../../utils/patientAppointmentCategorization.ts"; import { useMyOrganizations } from "../../../hooks/useMyOrganizations.tsx"; import { appointmentOrgRef, organizationNameOf } from "../../../utils/patientOrganizations.ts"; import { usePatientUnpaidInvoices } from "./usePatientUnpaidInvoices.tsx"; import { usePatientBillsCheck } from "./patientBillsCheck"; import { buildPatientQueue, LIST_ROUTE_FOR_KIND, type QueueItem, type QueueTierView } from "./patientQueue.ts"; import { formatMoney } from "./patientAccountSummary.ts"; import { openReferrals, usePatientHomeReferrals } from "./patientHomeReferrals.ts"; import { referralDay, type PatientReferral } from "../../../utils/patientReferrals.ts"; import { taskName } from "../../../utils/taskName.ts"; import { isStandingPlanInstruction } from "../../../utils/planActivity.ts"; import { packetOfTask } from "../../../utils/packetMembers.ts"; import type { PacketSummary } from "../../../services/QuestionnairePacketService.ts"; import { mayMissRecentWrite } from "../../../utils/recentWritesUnresolved.ts"; /** * Feeds the ranked queue from the four sources the summary band already reads., slice 2. * * THE SAME SOURCES, DELIBERATELY. D3's constraint is that the strip and these rows read one * selector, and that governs *what counts* as much as the arithmetic: forms use `isOutstandingTask` * (the predicate the Tasks card filters on), unread comes from the manager the sidebar badge reads, * and the invoices are the single shared load. If this hook decided any of those for itself the strip * would eventually say three while the list showed two - the defect four walk findings are. */ const participantDisplay = (appointment: Appointment, resourceType: string): string | undefined => appointment.participant?.find((participant) => participant.actor?.reference?.startsWith(`${resourceType}/`), )?.actor?.display; /** * THE PRACTICE ON A ROW. Spencer, 2026-09-08: "Also missing the org logo and name on the * cards." * * Measured on the running portal: of 40 Tasks, 33 carry `owner: { reference: * "Organization/" }` with NO display and 7 carry one. Reading the display alone leaves the * practice blank five rows out of six, which is why it never appeared. * * \u26D4 Resolved against the patient's OWN organizations, never against the portal they happen to be * standing in. The card's own note says why: "A patient at three practices gets messages, bills and * forms from all of them: a lab result FROM one practice ORDERED BY another's doctor." A practice * inferred from the current host would put the wrong name on the row, and somebody rings the wrong * number. Unknown stays undefined - a row that names nobody is honest. */ const orgNameOf = organizationNameOf; /** * THE PRACTICE BEHIND AN APPOINTMENT, and it is not on the appointment. * * Measured on the running portal 2026-09-08: of 65 appointments, exactly ONE carries an * Organization participant and that one carries no display. So `participant` cannot answer this, and * the appointment row was the only card on the page still showing no practice. * * What every appointment DOES carry is its clinician, and the loaded practitioners carry their roles - * a PractitionerRole names the organization the person works for. That is already in memory for the * name resolution beside it, so this costs nothing. * * Falls through to nothing rather than to the portal's own practice. A patient at three * practices reads all three in this column, and a guess here puts the wrong name under a visit. */ // Shared with the task list, so one visit wears one practice on both. // (appointmentOrgRef, utils/patientOrganizations.ts) /** * Exported ONLY so the `opens` it attaches can be tested against a real appointment. * * A MUTATION SURVIVED WITHOUT THIS. Deleting `opens` here sent every appointment row back to * navigating away - the exact regression was raised for - and the click tests stayed green, * because they mock this hook and supply `opens` in their own fixture. A test that provides the fact it * is checking cannot notice the producer dropping it. */ export const appointmentItems = ( appointments: Appointment[], now: Date, /** * The loaded practitioners, so the clinician's name comes off the LIVE record. * * `actor.display` IS A COPY TAKEN WHEN THE APPOINTMENT WAS BOOKED, and it goes stale. Measured * 2026-08-29: a clinician whose stored surname had the role welded onto it read as *"Alice * NguyenFamily Medicine Physician"* on this row. The record was then repaired, the appointment card * beside it corrected itself, and this row did not - because it was reading the copy. * Optional so the existing callers and tests keep working; empty falls back to the display, which * is the old behaviour and still better than nothing. */ practitioners: readonly PractitionerWithRoles[] = [], /** Optional for the same reason the practitioners are: existing callers and tests keep working. */ organizations: Organization[] = [], ): QueueItem[] => // THE SELECTOR IS THE APPOINTMENT CARD'S OWN, and that is the point of this line rather than an // implementation detail. It used to be a local `start >= now` filter, which agreed with the card // everywhere EXCEPT the minutes a visit is actually running: `categorizePatientAppointments` keeps an // appointment while `now < end`, this dropped it the second it began. So during a visit the // card showed it and the queue did not - and if it was the patient's only pending item the queue fell // to its EMPTY state, which on a phone stacks above the card and announces that nothing needs them // while their visit is in progress. Worst for telehealth, for own reason: you join // *during* the appointment, so that window is exactly when this surface is the door. // Reading one selector is also this file's stated rule - see the header note. A second definition of // "upcoming" is the same class of defect as a second definition of "outstanding". categorizePatientAppointments(appointments, now) .upcoming.filter((appointment) => appointment.start !== undefined) .map((appointment) => ({ id: appointment.id as string, kind: "appointment" as const, // Titled by the thing itself, never by its type. title: appointment.description?.trim() || appointment.serviceType?.[0]?.text?.trim() || "Appointment", due: appointment.start, // No "Due by:" - an appointment is not a deadline, it is the thing itself. It reads as a plain // when, in the same date shape every other surface in the product prints. dueLine: appointment.start ? formatCustomTaskDueDate(appointment.start) : undefined, organizationName: participantDisplay(appointment, "Organization")?.trim() || orgNameOf(appointmentOrgRef(appointment, practitioners), organizations), // The shared resolver, which reads the live record and falls back to the booked-in display. // Empty rather than "Unknown Practitioner": a row that names nobody is honest, and a placeholder // where a clinician's name goes reads as a name. practitionerName: resolveAppointmentPractitionerLabel(appointment, practitioners, "").trim() || undefined, // R5 - the row OPENS the appointment here. `to` stays as the fallback for a caller that cannot // open a dialog (and as what the peer line uses, which genuinely wants the full list). // This route used to be the whole behaviour, and it was the defect: it landed the patient on // the LIST, where they had to find the same appointment and click it again. to: "/dashboard/my-appointments", opens: { kind: "appointment" as const, appointment }, })); /** * Exported for the same reason `appointmentItems` is, and for the sharper version of the same risk. * * A test that builds its own row and asserts the row's `to` proves nothing: the destination is decided * HERE, from the task, and a fixture supplying `to` would agree with any bug this function has. The whole * defect was one ternary in this function, so the test has to call this function. */ /** * WHAT THE PATIENT IS TOLD THEY ARE OPENING. * * THE DEFECT THIS CLOSES, observed 2026-08-21: every outstanding task announced itself as * "FORM - Start form", because one `QueueKind` covers all of them. Pressing it on a custom task * opened "Complete the instruction above, then mark the task complete" and a checkbox. There was no * form. The ROUTING half of exactly this was fixed on 2026-08-19 (see `to` below) - the label was * left behind, so the destination became right while the promise stayed wrong. * * `legacy` does not say "Complete task", and that is not a typo. It has no patient-facing screen, * so `to` still sends it to the staff-shaped Task editor; offering to complete something there is a * promise the screen cannot keep. It is at least no longer called a form. */ const TASK_LABELS: Record = { questionnaire: { kind: "Form", action: "Start form" }, custom: { kind: "Task", action: "Complete task" }, legacy: { kind: "Task", action: "Open task" }, }; export const taskItems = ( tasks: Task[], appointments: Appointment[] = [], organizations: Organization[] = [], noneBookedIsKnown = true, ): QueueItem[] => // `isPatientsOwnTask` FIRST. `Task?patient=` returns every Task this patient is the SUBJECT of, // and a referral the practice must send is one of those - it was appearing here as something the // patient must do, due by their next appointment, which they cannot do and cannot dismiss. tasks.filter((task) => isPatientsOwnTask(task) && isOutstandingTask(task) && !isStandingPlanInstruction(task)).map((task) => ({ id: task.id as string, kind: "form" as const, kindLabel: TASK_LABELS[getAkashicTaskKind(task)].kind, actionLabel: TASK_LABELS[getAkashicTaskKind(task)].action, title: taskName(task) || task.description?.trim() || task.code?.text?.trim() || "Something to complete", // `since` is when it started WAITING, not when it was written - that is what promotion reads. since: task.authoredOn, due: task.restriction?.period?.end ?? task.executionPeriod?.end, // THE SHARED PRODUCER, not a date formatted here. It returns the seven ruled shapes - including // "Due by: this visit" for a task due at a visit that has already started, which is exactly the // moment a patient is most likely to be reading this. // // AND IT IS GIVEN THE RESOLVED APPOINTMENT, which the first version of this was not. Without it the // producer cannot tell a booked next visit from no visit at all, so it falls back to "Due by: your // next appointment" - the shape that MEANS none is booked. Walked 2026-08-27 on a patient who had // one: this row said "your next appointment" while the Tasks page, which does pass it, said // "Due by: next appointment on Fri, Aug 28, 2026 at 1:00 PM". Two surfaces, one task, two answers - // the exact thing a shared producer exists to prevent, reintroduced by not feeding it. dueLine: getCustomTaskDueDisplay(task, resolveTaskAppointment(task, appointments), Date.now(), noneBookedIsKnown), organizationName: orgNameOf(task.owner, organizations), practitionerName: task.requester?.display, // "Start form" HAS TO REACH THE FORM. This asked `isCustomTask` alone, so a QUESTIONNAIRE task - // the entire point of the label - fell to `/dashboard/patient/tasks/{id}`, which is the staff-shaped // FHIR Task editor: Task Status, Intent, Priority, "Part of composite task". No questions on it and // nothing to press to finish. Walked as a real patient on 2026-08-19; one assigned questionnaire // had sat `requested` since June 21, because no patient could ever have completed one. // // `hasPatientFacingTaskScreen` rather than a second `||` here: the task list made the identical // mistake independently, which is what a shared predicate prevents. The question is not "which kind // is it" but "can the patient's own screen actually do this one". // // The fallback is still the chart route, and it is still wrong for a patient - it just no longer // catches anything a patient is normally handed. Only `legacy` tasks reach it now. to: hasPatientFacingTaskScreen(task) ? `/dashboard/home/task/${task.id}` : `/dashboard/patient/tasks/${task.id}`, /* * OPENS IN PLACE ONLY WHERE THERE IS A PATIENT-FACING SCREEN TO OPEN - the same predicate that * decides `to`, asked once and used twice, because the two answers must not be able to disagree. * * The fallback route is the staff-shaped FHIR Task editor: Task Status, Intent, Priority, "Part of * composite task". Hoisting THAT into a dialog on the patient's overview would put a chart form in * front of somebody who cannot complete it and now cannot even tell they have been sent * somewhere. Only `legacy` tasks reach it, and they keep navigating, where the surrounding page at * least explains where they are. */ opens: hasPatientFacingTaskScreen(task) ? { kind: "task" as const, taskId: task.id as string } : undefined, })); /** * An open packet is one item, counted once, with the submitted count over every form it was assigned. It opens the * first form still open, in assigned order, on the same task screen as any form; with none it goes to the Tasks page. */ export const packetItems = ( packets: PacketSummary[], tasks: Task[], appointments: Appointment[] = [], organizations: Organization[] = [], noneBookedIsKnown = true, ): QueueItem[] => packets.filter((packet) => packet.lifecycle === "open").map((packet) => { const ordered = [...packet.members].sort((a, b) => a.position - b.position); const forms = ordered.map((member) => ({ member, task: member.taskId ? tasks.find((task) => task.id === member.taskId) : undefined, })); const next = forms.find(({ member, task }) => member.state === "open" && task && hasPatientFacingTaskScreen(task))?.task; const started = forms.some(({ member, task }) => member.state === "submitted" || task?.status === "in-progress"); return { id: packet.id, kind: "form" as const, kindLabel: "Packet", actionLabel: next ? (started ? "Continue packet" : "Start packet") : "View packet", title: packet.title || "Forms from your practice", since: packet.assignedOn ?? packet.publishedAt, due: next?.restriction?.period?.end ?? next?.executionPeriod?.end ?? packet.due?.date, dueLine: next ? getCustomTaskDueDisplay(next, resolveTaskAppointment(next, appointments), Date.now(), noneBookedIsKnown) : packet.due?.date ? `Due by: ${formatCustomTaskDueDate(packet.due.date)}` : undefined, progressLine: `${packet.progress.submitted} of ${packet.progress.total} submitted`, organizationName: packet.organizationId ? orgNameOf({ reference: `Organization/${packet.organizationId}` }, organizations) : undefined, to: LIST_ROUTE_FOR_KIND.form, opens: next ? { kind: "task" as const, taskId: next.id as string } : undefined, }; }); /** A referral the patient still has to book is one of their tasks, listed and counted with them. */ export const referralItems = (referrals: PatientReferral[]): QueueItem[] => openReferrals(referrals).map((referral) => { const day = referralDay(referral.authoredOn); return { id: referral.id, kind: "form" as const, kindLabel: "Task", actionLabel: "Open referral", title: referral.title, since: referral.authoredOn, dueLine: [referral.referredBy && `Referred by ${referral.referredBy}`, day].filter(Boolean).join(", ") || undefined, // The title already names the practice; the practice line would say it twice. practitionerName: referral.practitioner, to: LIST_ROUTE_FOR_KIND.form, opens: { kind: "referral" as const, referralId: referral.id }, }; }); /** * "Sent 2d ago", or nothing at all when the timestamp is missing or unparseable. * * Returning `undefined` rather than a bare "Sent" matters on this row: the due line is the only thing * left stating urgency now the tier bands are gone, so a half-sentence there reads as a value that * failed to load rather than as an absence. */ const messageSentLine = (timestamp: string | undefined, now: Date): string | undefined => { if (!timestamp) return undefined; const at = new Date(timestamp).getTime(); if (Number.isNaN(at)) return undefined; const relative = formatRelativeTime(at, now.getTime()); return relative ? `Sent ${relative}` : undefined; }; /** * THE LAST THING SAID IN THE THREAD, and which way it went. * * Spencer, 2026-09-08: "it should show a truncated beginning of the message. Plus, if that * was a message 'to' me then it's wrong anyway because it says A message 'from' your practice." * * Both faults had one cause: the row was built from the thread RESOURCE, which carries no text and no * direction, so every message read "A message from your practice" whoever wrote it - including the * ones the patient wrote themselves. * * NO EXTRA FETCH. `getMessagesForRequest` reads the thread map the messaging service has * already loaded; this is a lookup, not a request. * * Sorted by `sent` rather than trusted in array order: the map is filled from several fetches and * "the last one in the list" is not the same claim as "the most recent". */ const lastMessageOf = (messages: Communication[]): Communication | undefined => [...messages] .filter((message) => !!message.sent) .sort((a, b) => new Date(a.sent!).getTime() - new Date(b.sent!).getTime()) .pop() ?? messages[messages.length - 1]; /** * The opening of a message, for a row that has one line to spend on it. * * 90 characters, cut on a word boundary so the last thing a patient reads is a word rather than half * of one. The ellipsis is a real character, not three dots - a screen reader announces it. */ const openingOf = (text: string | undefined): string | undefined => { const trimmed = text?.replace(/\s+/g, " ").trim(); if (!trimmed) return undefined; if (trimmed.length <= 90) return trimmed; const cut = trimmed.slice(0, 90); const lastSpace = cut.lastIndexOf(" "); return (lastSpace > 50 ? cut.slice(0, lastSpace) : cut).trimEnd() + "\u2026"; }; /** The other party in a thread, by the SHARED resolver rather than a second reading of it. */ const counterpartyIdOf = (request: CommunicationRequest, selfReference: string | undefined) => resolveCounterpartyReference(request, selfReference)?.reference?.split("/")[1]; /** Exported for the same reason `appointmentItems` is - see its note. */ export const messageItems = ( requests: CommunicationRequest[], unreadFor: (id: string) => number, selfReference: string | undefined, now: Date = new Date(), organizations: Organization[] = [], messagesFor: (request: CommunicationRequest) => Communication[] = () => [], ): QueueItem[] => requests .filter((request) => unreadFor(request.id as string) > 0) .map((request) => { const last = lastMessageOf(messagesFor(request)); const opening = openingOf(last?.payload?.[0]?.contentString); // WHOSE VOICE THIS IS. `sender` on the MESSAGE, not on the thread - a thread has two ends and // only a message has an author. const fromMe = !!selfReference && last?.sender?.reference === selfReference; const sentAt = last?.sent ?? request.meta?.lastUpdated ?? request.authoredOn; /* * THE PRACTICE, AND EVERY ROW GETS ONE. Spencer, 2026-09-08: "Every card should always * show the org logo and name because any of these could be from any org." * * `requester` carries the organization on some threads and nothing on others, which is why the * message row was the one card on the page with no mark beside it. A thread also records the * practice it belongs to in its own extension - `getThreadOrganizationId`, the marker the * messaging surfaces already read - so the row asks the requester first and the thread second * rather than giving up on the first miss. */ const senderName = orgNameOf(request.requester, organizations) ?? orgNameOf( { reference: `Organization/${getThreadOrganizationId(request) ?? ""}` }, organizations, ); /* * WHO IS ON THE OTHER END. Spencer: "if it's the front desk then say front desk, if it's a * specific practitioner it should show the practitioner name." * * A pool thread has no person on the other end by design - the counterparty resolver returns * nothing for one - so the row named nobody. `poolThreadLabel` is the same label the messaging * screens print, so the front desk is called the same thing in both places. */ const poolKey = getThreadPoolKey(request); const counterparty = resolveCounterpartyReference(request, selfReference); const otherParty = poolKey ? poolThreadLabel(poolKey) : counterparty?.reference?.startsWith("Practitioner/") ? counterparty.display : undefined; return { id: request.id as string, kind: "message" as const, /* * The message itself, and only a description of one when there is genuinely nothing to quote. * * The fallback said "A message from your practice" in every case, which was wrong twice over. * Spencer: it printed "from" over the patient's OWN words, and - "even 'your practice' is * wrong because how do I know 'what' practice?" A patient at three practices gets messages * from all three into one column, so a possessive that names nobody identifies nothing. * * So it names the practice when the row knows it, and says nothing it cannot support when it * does not. `senderName` is resolved just above from the same reference the row's own practice * line uses, so the two can never disagree. */ title: opening ?? (fromMe ? otherParty ? `A message you sent to ${otherParty}` : "A message you sent" : otherParty ? `A message from ${otherParty}` : "A message"), since: sentAt, // A message has no deadline, so its line states RECENCY instead - which is the fact a patient // actually weighs on an unanswered one. `formatRelativeTime` is the same helper the messaging // surfaces print, deliberately: a second way of saying "2d ago" on the home page is how one // conversation comes to look older on one screen than on another. /* * A MESSAGE YOU SENT AND NOBODY ANSWERED IS A DIFFERENT FACT from one waiting to be read, and * the row now says which. Spencer: "If you're promoting it like gmail does an email that * hasn't been responded to yet you should call that out." */ dueLine: fromMe ? [messageSentLine(sentAt, now), "no reply yet"].filter(Boolean).join(" \u00b7 ") : messageSentLine(sentAt, now), organizationName: senderName, /* * THE COUNTERPARTY, NOT THE SENDER. Spencer, reading his own home page 2026-09-08: "why does * that message say 'Patient One' which is me and not the person that sent it?" * * `sender` is whoever sent the request, and on a thread the PATIENT started that is the patient * - so the row introduced the reader to themselves. The data was right; the field was wrong. * * `resolveCounterpartyReference` is the shared rule for "the other end of this thread", and this * file was ALREADY calling it two lines below to build the link. The name and the destination now * come from one answer instead of two, which is also what stops them disagreeing. * * Guarded to a Practitioner because this slot sits next to the practice name: a pool thread's * counterparty IS the organization, and taking its display here would print the practice twice. */ /* The office or the person, whichever this thread has - resolved above so the title and this line can never name two different counterparties. */ practitionerName: otherParty, // THE CONVERSATION, not the inbox. The replaced card linked to // `/dashboard/messages?counterpartyId=...`, and dropping that query lands a patient on a list // and makes them find the thread again - a real loss on the surface that exists to reduce // steps. Same shared resolver the messages page uses, so one rule decides who the // counterparty is. to: counterpartyIdOf(request, selfReference) ? `/dashboard/messages?counterpartyId=${encodeURIComponent(counterpartyIdOf(request, selfReference) as string)}` : "/dashboard/messages", // R5 - the conversation OPENS HERE. Reading "can you come at 3 instead?" and answering "yes" is // quick-access work, which is what this page exists for; `to` stays as the fallback and as where // the peer line ("+ 2 more messages") correctly goes. opens: { kind: "message" as const, request }, }; }); /** `packets` is the admitted packet summary, when known; until then a packet form is listed as any other form. */ /** `readerIsHelper`: a helper's visit read can withhold sensitive visits, so finding none proves nothing. */ export const usePatientQueue = (now: Date = new Date(), packets?: PacketSummary[], readerIsHelper = false): QueueTierView[] => { const appointmentRead = useFhirAppointments({ autoLoad: false }); const { appointmentsList } = appointmentRead; const noneBookedIsKnown = visitListProvesNoneBooked(appointmentRead, readerIsHelper); // LOADED HERE, not borrowed from the appointment card beside it: a queue that relied on another // component to read the clinicians' roles lost its practice marks the day that component was not // mounted. The provider shares one read between them and keeps it, so this adds no request. // Pending or failed leaves the list empty, and an empty list marks nothing rather than guessing. const { practitionersList } = useFhirPractitioners(); const { tasksList } = useFhirTasks({ autoLoad: false }); const { patientCommunicationRequests, getUnreadCountForRequest, getMessagesForRequest } = useCommunicationManager(); const { messagingIdentity } = useCommunicationServiceContext(); const unpaid = usePatientUnpaidInvoices(); // The practices this patient actually belongs to - the only safe source for a row's practice name. const { organizations } = useMyOrganizations(); const { referrals } = usePatientHomeReferrals(); return useMemo(() => { // An unloaded invoice list contributes NO ROWS, which is the right behaviour here and worth // separating from defect. The band asserts a NUMBER, so a premature `0` is a false // statement; the queue renders ROWS, and a row that has not arrived yet says nothing at all. The // transient understatement of "N things need you" is the same family but a lesser one, and the queue // has no unknown state to render - noted rather than smuggled in under this card. const invoices: QueueItem[] = (unpaid?.invoices ?? []).map((invoice) => ({ id: invoice.id, kind: "payment" as const, title: invoice.amountCents === undefined ? "Bill due, amount not confirmed" : `${formatMoney(invoice.amountCents)} due`, amountCents: invoice.amountCents, due: invoice.dueDate, since: invoice.dueDate, // "- past due" is the deck's, and it is the half a patient acts on. A date alone makes them work // out whether it has passed; on a bill that is the whole question. dueLine: invoice.dueDate ? `Due by: ${formatCustomTaskDueDate(invoice.dueDate)}${new Date(invoice.dueDate).getTime() < now.getTime() ? " ยท past due" : ""}` : undefined, // The display when the invoice carries one, else the id resolved against the patient's own // practices - the same two-step every other row on this page uses. organizationName: invoice.issuerName?.trim() || orgNameOf(invoice.issuerRef, organizations), // Opens this bill's own detail on the bills page, not just the list it is in. to: `/dashboard/my-invoices?invoice=${encodeURIComponent(invoice.id)}`, })); // A summarised packet is listed once, as itself, where its first form would have been; its forms are not // listed again beside it. One with no form listed here still has its place, at the end. const grouped = new Set((packets ?? []).map((packet) => packet.id)); const packetById = new Map(packetItems(packets ?? [], tasksList ?? [], appointmentsList ?? [], organizations, noneBookedIsKnown) .map((item) => [item.id, item])); const forms: QueueItem[] = []; for (const task of tasksList ?? []) { const packetId = packetOfTask(task); if (!packetId || !grouped.has(packetId)) { forms.push(...taskItems([task], appointmentsList ?? [], organizations, noneBookedIsKnown)); } else if (packetById.has(packetId)) { forms.push(packetById.get(packetId) as QueueItem); packetById.delete(packetId); } } forms.push(...packetById.values()); return buildPatientQueue( [ ...appointmentItems(appointmentsList ?? [], now, practitionersList ?? [], organizations), ...forms, ...referralItems(referrals ?? []), ...messageItems( patientCommunicationRequests ?? [], getUnreadCountForRequest, toSelfReference(messagingIdentity), now, organizations, getMessagesForRequest, ), ...invoices, ], now, ); }, [appointmentsList, noneBookedIsKnown, practitionersList, tasksList, patientCommunicationRequests, getUnreadCountForRequest, messagingIdentity, unpaid, referrals, now, packets]); }; /** How many things the queue is actually asking of the patient - the "N things need you." lead line. */ export const queueTotal = (tiers: QueueTierView[]): number => tiers.reduce((sum, tier) => sum + tier.total, 0); /** * Whether the queue's own reads have answered. An empty queue may only say "nothing needs you" once the * appointments and tasks behind it were read; while they load, or after one fails, it says so instead. A task * list that answered but may be missing a recent change (`incomplete`) is not a settled one either. */ export const usePatientQueueStatus = (): { loading: boolean; failed: boolean; incomplete: boolean; /** A fresh check of this patient's tasks is out. */ checking: boolean; recheckTasks: () => void; } => { // Read from the services, which every loader shares; a non-loading hook instance never sees its reads. const patient = usePatientServiceContext().patientState; const patientId = patient?.id; const appointments = useAppointmentServiceContext(); const tasks = useTaskServiceContext(); const referrals = usePatientHomeReferrals(); // A bill read that failed or stopped short may not end in "nothing needs you" either. const bills = usePatientBillsCheck(); const failed = Boolean(patientId) && (appointments.appointmentsFailedFor === patientId || tasks.tasksFailedFor === patientId || referrals.failed || bills.failed); const answered = appointments.cachedForPatientId === patientId && tasks.cachedForPatientId === patientId && referrals.referrals !== undefined && !bills.loading; const incomplete = Boolean(patientId) && tasks.cachedForPatientId === patientId && mayMissRecentWrite(tasks.tasksBundle); // A fresh read of this patient's tasks, never the cached answer. Only the latest check for the patient on screen // ends the checking state; a failure is the task read's own, which the queue already says. const [checking, setChecking] = useState(false); const latestCheck = useRef(0); useEffect(() => { latestCheck.current += 1; setChecking(false); }, [patientId]); const { fetchTasksWithPolling } = tasks; const recheckTasks = useCallback(() => { if (!patient) return; const check = ++latestCheck.current; setChecking(true); void fetchTasksWithPolling(patient, true).catch(() => undefined).finally(() => { if (latestCheck.current === check) setChecking(false); }); }, [patient, fetchTasksWithPolling]); return { loading: Boolean(patientId) && !failed && !answered, failed, incomplete, checking, recheckTasks }; };