import { useMemo } from "react"; import type { Appointment, Task } from "fhir/r4"; import { useFhirAppointments, useFhirTasks } from "../../../hooks"; import { hasPatientFacingTaskScreen, isOutstandingTask, isPatientsOwnTask } from "../../../services"; import { usePatientUnpaidInvoices } from "./usePatientUnpaidInvoices.tsx"; import { isOrglessPatientsHost } from "../../../utils/hostedMount"; import { wasCancelledByThePatientsOwnAnswer, wasDeclinedByPractice } from "../../../utils/appointmentCancellation"; import { deriveUrgentNotice, type UrgentSources } from "./urgentNotice.ts"; import type { UrgentNotice } from "./patientHomeBanner.ts"; /** * Reads the three things that raise an urgent notice from the records the home already holds. *, Spencer 2026-08-13. * * The derivation is pure and lives in `urgentNotice.ts`; this only supplies it. Appointments and tasks * come from the same FHIR contexts the home's own cards read (`autoLoad: false`), and the invoices * from the one load the summary band also uses. */ /** Priorities that mean "urgent" for the banner. `high` stays an ordinary ranked row. */ const URGENT_PRIORITIES = new Set(["urgent", "asap", "stat"]); /** * A cancelled appointment that is worth interrupting for. * * THE TWO BOUNDS ARE THE CLEARING RULE, and without them this arm never stops firing - a cancelled * appointment stays cancelled for ever, so "status === cancelled" alone would put a banner on the home * of anyone who has ever had a visit called off: * - the visit must still be in the FUTURE - a cancelled appointment that has already passed is * history, not something a patient can act on, and this is what makes the notice expire by itself; * - and there must be no upcoming booked or requested appointment - **rebooking IS the resolution**, so a patient * who has already acted must not keep being told to act. */ // A request still waiting on the practice is the patient's rebooking too. const REBOOKED_STATUSES = new Set(["booked", "pending", "proposed"]); export const changedAppointments = (appointments: Appointment[], now: Date) => { const upcomingBooked = appointments.some( (appointment) => REBOOKED_STATUSES.has(appointment.status) && appointment.start !== undefined && new Date(appointment.start).getTime() >= now.getTime(), ); if (upcomingBooked) return []; return appointments .filter( (appointment) => appointment.status === "cancelled" && // Their own Cancel, or the visits a refused agreement took with it: they already know. !wasCancelledByThePatientsOwnAnswer(appointment) && appointment.start !== undefined && new Date(appointment.start).getTime() >= now.getTime(), ) .map((appointment) => ({ id: appointment.id as string, when: appointment.start, declined: wasDeclinedByPractice(appointment), practiceName: appointment.participant?.find((participant) => participant.actor?.reference?.startsWith("Organization/"), )?.actor?.display, })); }; export const usePatientUrgentNotice = (now: Date = new Date()): UrgentNotice | undefined => { const { appointmentsList } = useFhirAppointments({ autoLoad: false }); const { tasksList } = useFhirTasks({ autoLoad: false }); const unpaid = usePatientUnpaidInvoices(); return useMemo(() => { const sources: UrgentSources = { changedAppointments: changedAppointments(appointmentsList ?? [], now), // An unloaded list raises NOTHING, and that is the safe direction: the banner is an alarm, so // silence while the answer is unknown is correct and a premature "A payment is past due" would not // be. It resolves itself the moment the invoices arrive. troubledInvoices: (unpaid?.invoices ?? []).map((invoice) => ({ id: invoice.id, amountCents: invoice.amountCents, dueDate: invoice.dueDate, failed: invoice.failed, })), // The patients host has no online payment yet; its bills page only shows the bill. billsPayableHere: !isOrglessPatientsHost(), // "Any task assigned to them marked urgent that isn't resolved yet" - `isOutstandingTask` is the // same predicate the Tasks card and the summary band use, so "unresolved" means one thing. urgentTasks: (tasksList ?? []) // `isPatientsOwnTask` guards this too, and it matters most here: an urgent internal item would // be raised to the patient as an ALARM about something they cannot act on. .filter((task: Task) => isPatientsOwnTask(task) && isOutstandingTask(task) && URGENT_PRIORITIES.has(task.priority)) .map((task: Task) => ({ id: task.id as string, title: task.description ?? task.code?.text ?? "Your practice needs you", // Where the queue sends the same task: a form or custom task opens the patient's own screen, // in place. The chart route is the staff-shaped Task record, and only a legacy task falls to it. route: hasPatientFacingTaskScreen(task) ? `/dashboard/home/task/${task.id}` : `/dashboard/patient/tasks/${task.id}`, opensInPlace: hasPatientFacingTaskScreen(task), })), }; return deriveUrgentNotice(sources, now); }, [appointmentsList, tasksList, unpaid, now]); };