import type { UrgentNotice } from "./patientHomeBanner.ts"; import { DECLINED_BY_PRACTICE_LABEL } from "../../../utils/appointmentCancellation"; /** * What raises the urgent notice., settled by Spencer 2026-08-13: * * > *"So it should just be cancelled/moved appointment, failed or past due payment and any task * > assigned to them marked 'urgent' that isn't resolved yet. Simple as that."* * * THE SHAPE THAT MAKES THIS CHEAP: all three are **views over records the product already holds** - * an Appointment, an invoice, a Task. There is no urgent-notice resource, no second staff inbox, no * new writer and no bespoke clearing rule. **Each trigger clears itself because the record behind it * changes**: the appointment is rebooked, the invoice is paid, the task is completed. * Which is also why there is no dismiss button - dismissing would change nothing except our ability * to tell the patient. * * The task arm is the one that used to need a bespoke permission. It no longer does: marking a task * urgent is task-creation permission, which already exists and is already in the configurable model. */ export type UrgentSources = { /** Appointments the practice cancelled or moved. */ changedAppointments: Array<{ id: string; when?: string; practiceName?: string; declined?: boolean }>; /** Unpaid invoices that are past their due date, or whose payment attempt failed. */ troubledInvoices: Array<{ id: string; amountCents: number | undefined; dueDate?: string; failed?: boolean }>; /** Unresolved tasks the practice marked urgent. */ urgentTasks: Array<{ id: string; title: string; route: string; opensInPlace?: boolean }>; /** False where the bills page cannot take a payment, so the notice offers to view the bill rather than pay it. */ billsPayableHere?: boolean; }; /** * PAST DUE IS A DELIBERATE WIDENING of the earlier "a failed payment that BLOCKS CARE" wording, and * it has a consequence worth keeping visible: because the notice clears only when the thing behind it * resolves - never by dismissal - **a patient carrying an old balance sees the banner for as long as * they carry it.** That is what was asked for, and this constant is the knob: raise it to give a grace * period before the balance is promoted out of the queue and into the slot. */ export const DAYS_PAST_DUE_BEFORE_URGENT = 0; /** The patient's own calendar day as YYYY-MM-DD. */ const localDay = (instant: Date): string => `${instant.getFullYear()}-${String(instant.getMonth() + 1).padStart(2, "0")}-${String( instant.getDate(), ).padStart(2, "0")}`; /** * COMPARED AS CALENDAR DAYS, NEVER AS INSTANTS - and that is a correction, not a style choice. * A due date is `YYYY-MM-DD`, which `new Date()` parses at UTC MIDNIGHT. Subtracting instants therefore * made an invoice due TOMORROW read as past due from 7pm tonight in US Central, and one due TODAY read * as past due all day. Measured, not reasoned: at 8pm Central on Aug 13, a due date of Aug 14 returned * true. * * A day-string comparison is also what the other two places in the product already do - * BillingPage/invoiceDisplay.ts and the server's billing-export paymentStatus.ts both derive past-due as * `dueDate < today`. This was the odd one out, and being the odd one out here meant shouting at a * patient about a bill that is not late yet. * * Due TODAY is not past due; `graceDays` is the grace period on top of that. * * THE GRACE PERIOD IS A PARAMETER, not a read of the constant, purely so it can be tested. With the * constant at 0 the sign of that subtraction is unobservable - a knob nobody can exercise is a knob that * is wrong the first time it is turned, and this one decides how long a patient is left alone about a * balance. `deriveUrgentNotice` still passes the constant; nothing else should pass anything else. * * The shape guard is not decoration. Past-due is a STRING comparison, so any value that happens to * sort below today's date - a stray leading space, a truncated field - would render "A payment is past * due" to a patient with perfect confidence. Unreadable is not late. */ export const isPastDue = ( dueDate: string | undefined, now: Date, graceDays: number = DAYS_PAST_DUE_BEFORE_URGENT, ): boolean => { if (!dueDate || !/^\d{4}-\d{2}-\d{2}/.test(dueDate)) return false; const graceEnds = new Date(now.getTime()); graceEnds.setDate(graceEnds.getDate() - graceDays); return dueDate.slice(0, 10) < localDay(graceEnds); }; const money = (cents: number) => `$${(cents / 100).toFixed(2)}`; /** * The one occupant, if any. * * PRECEDENCE IS THE ORDER SPENCER LISTED THEM - appointment, payment, task - rather than a severity * ranking invented here. Only one notice may be on screen, so something has to decide, and an * enumerated order is at least inspectable; a "most severe" heuristic would be this file's opinion * about a patient's life. */ export const deriveUrgentNotice = ( sources: UrgentSources, now: Date = new Date(), ): UrgentNotice | undefined => { const [appointment] = sources.changedAppointments; // A declined request was never a booked time, so it is not an appointment that changed. if (appointment?.declined) { return { id: `appointment-${appointment.id}`, trigger: "appointment-changed", title: "Your appointment request was not confirmed", detail: `${DECLINED_BY_PRACTICE_LABEL}.`, primaryAction: { label: "Book another time", opens: "book-appointment" }, }; } if (appointment) { return { id: `appointment-${appointment.id}`, trigger: "appointment-changed", title: "Your appointment was changed", detail: appointment.practiceName ? `${appointment.practiceName} cancelled or moved your appointment.` : "Your practice cancelled or moved your appointment.", // The instruction is what the patient can DO about it, and rebooking is the only thing the // product can offer them here. // // OPENS THE BOOKING DIALOG rather than naming a route, because the route it used to name is // retired. It is an action and not a `to` for a reason worth keeping: this notice fires ONLY // for an appointment the practice CANCELLED, and only while the patient holds no upcoming // booked one, so there is nothing here to duplicate. That is what makes offering a booking // safe here and not on a live appointment. primaryAction: { label: "Reschedule", opens: "book-appointment" }, }; } const troubled = sources.troubledInvoices.find( (invoice) => invoice.failed || isPastDue(invoice.dueDate, now), ); if (troubled) { return { id: `invoice-${troubled.id}`, trigger: "payment-blocks-care", title: troubled.failed ? "A payment did not go through" : "A payment is past due", detail: troubled.amountCents === undefined ? "The amount outstanding could not be confirmed just now." : `${money(troubled.amountCents)} outstanding.`, primaryAction: { label: sources.billsPayableHere === false ? "View bills" : "Pay now", to: "/dashboard/my-invoices" }, }; } const [task] = sources.urgentTasks; if (task) { return { id: `task-${task.id}`, trigger: "practice-asks-for-a-call", // The task's own title, never a template: the practice wrote what they need, and a per-type // default would eventually say the wrong thing confidently. title: task.title, detail: "Your practice marked this urgent.", primaryAction: task.opensInPlace ? { label: "Open", to: task.route, opens: "task", taskId: task.id } : { label: "Open", to: task.route }, }; } return undefined; };