import type { AllergyIntolerance, Appointment, Coverage, FamilyMemberHistory, MedicationStatement, Patient, RelatedPerson, } from "fhir/r4"; import { getAllCommPreferences } from "../config/communicationPreferences"; import { isSelfPayCoverage } from "./coverageKind"; // A visit that already happened also answers "schedule your first appointment". const SCHEDULED_APPOINTMENT_STATUSES = new Set(["booked", "arrived", "checked-in", "fulfilled"]); export type PatientSystemTaskChecklistItem = { id: string; label: string; completed: boolean; }; export type PatientSystemTaskDefinition = { id: string; title: string; shortLabel: string; description: string; priority: "routine" | "high" | "urgent" | "asap"; route: string; completed: boolean; progressCurrent: number; progressTotal: number; progressPercent: number; checklistItems?: PatientSystemTaskChecklistItem[]; }; export type PatientSystemTaskSummary = { completed: boolean; completedCount: number; totalCount: number; incompleteLabels: string[]; tasks: PatientSystemTaskDefinition[]; }; type BuildPatientSystemTasksInput = { patient?: Patient; relatedPersons: RelatedPerson[]; medicationStatements: MedicationStatement[]; allergyIntolerances: AllergyIntolerance[]; coverages: Coverage[]; hasConfirmedNoCurrentMedications: boolean; hasConfirmedNoKnownAllergies: boolean; familyMemberHistories: FamilyMemberHistory[]; hasConfirmedNoKnownFamilyHistory: boolean; hasConfirmedNoRelatedPersons?: boolean; appointments: Appointment[]; }; export const buildPatientSystemTasks = ({ patient, relatedPersons, medicationStatements, allergyIntolerances, coverages, hasConfirmedNoCurrentMedications, hasConfirmedNoKnownAllergies, familyMemberHistories, hasConfirmedNoKnownFamilyHistory, hasConfirmedNoRelatedPersons = false, appointments, }: BuildPatientSystemTasksInput): PatientSystemTaskDefinition[] => { const profileChecklistItems: PatientSystemTaskChecklistItem[] = [ { id: "name", label: "Full name", completed: Boolean(patient?.name?.[0]) }, // THESE CHECKLIST ITEMS ARE COMPUTED AND NEVER RENDERED TO A PATIENT. `PatientHomeBanner` - the // only consumer of these tasks - renders `task.title` and nothing else; `checklistItems` is read by // `OnboardingTaskFocus`, which belongs to the ORG onboarding and is fed by `ResolvedOrgTask`. // Found the hard way: messaging explanation was first put on the label below, where it // would never have been seen. It lives on the profile task's TITLE instead - see `profileTitle`. // Do not add patient-facing wording here expecting it to appear. { id: "birthDate", label: "Birth date", completed: Boolean(patient?.birthDate) }, { id: "gender", label: "Gender", completed: Boolean(patient?.gender) }, { id: "telecom", label: "Contact information", completed: Boolean(patient?.telecom?.length), }, { id: "address", label: "Address", completed: Boolean(patient?.address?.length) }, ]; const profileCurrent = profileChecklistItems.filter((item) => item.completed).length; const profileTotal = profileChecklistItems.length; const profileCompleted = profileCurrent === profileTotal; // The title is all the band and the list show, so it names what is still missing. const PROFILE_GAP_WORDS: Record = { name: "name", birthDate: "date of birth", gender: "gender", telecom: "contact details", address: "address", }; const profileGaps = profileChecklistItems.filter((item) => !item.completed).map((item) => PROFILE_GAP_WORDS[item.id] ?? item.label); const profileGapList = profileGaps.length > 1 ? `${profileGaps.slice(0, -1).join(", ")} and ${profileGaps[profileGaps.length - 1]}` : profileGaps[0]; const hasRelatedPersons = relatedPersons.some( (relatedPerson) => relatedPerson.active !== false, ); const relatedPersonsCompleted = hasRelatedPersons || hasConfirmedNoRelatedPersons; const hasMedicationHistory = medicationStatements.some( (medicationStatement) => medicationStatement.status !== "entered-in-error", ); const medicationsCompleted = hasMedicationHistory || hasConfirmedNoCurrentMedications; const hasAllergyHistory = allergyIntolerances.some( (allergyIntolerance) => allergyIntolerance.verificationStatus?.coding?.[0]?.code !== "entered-in-error", ); const allergiesCompleted = hasAllergyHistory || hasConfirmedNoKnownAllergies; const hasFamilyHistory = familyMemberHistories.some( (familyMemberHistory) => familyMemberHistory.status !== "entered-in-error", ); const familyHistoryCompleted = hasFamilyHistory || hasConfirmedNoKnownFamilyHistory; const hasAppointment = appointments.some((appointment) => SCHEDULED_APPOINTMENT_STATUSES.has(appointment.status), ); // A plan, or an explicit "uninsured". Self-pay is a per-visit choice at the desk, so a stored // self-pay record answers nothing. const coverageAnswered = coverages.some( (coverage) => coverage.status === "active" && !isSelfPayCoverage(coverage) && !(coverage.period?.end && new Date(coverage.period.end) < new Date()), ); // Completed once the patient has recorded ANY communication-channel decision (opt-in or explicit off). const communicationPreferencesSet = patient !== undefined && Object.keys(getAllCommPreferences(patient)).length > 0; return [ { id: "system-first-appointment", title: "Schedule your first appointment", shortLabel: "First appointment", description: "Book a visit with your practice.", priority: "high", route: "/dashboard/my-appointments", completed: hasAppointment, progressCurrent: hasAppointment ? 1 : 0, progressTotal: 1, progressPercent: hasAppointment ? 100 : 0, }, { id: "system-profile", // ADOLESCENT GATE FAILS CLOSED ON AN UNKNOWN AGE, and this title is the only place a // patient can find that out. Both the client nav and `isAdolescentPatientCaller` on the server hide // and refuse messaging when there is no usable `birthDate` - deliberately, so a missing field cannot // become a way to message as a minor. The server's own comment names the cost, *"an ADULT with no // birthDate on file loses messaging"*, and says it *"should surface loudly"*. // It surfaces loudly IN CLOUDWATCH. To the patient it surfaced as nothing at all - Messages and // Collaboration teams simply were not in the menu, with no explanation and no way to work out the // remedy, while being the only person who could apply it. // Observed in a browser 2026-08-19 on a brand-new adult patient with an empty date of birth. // It does NOT weaken the gate and must not be "fixed" by weakening it. // On the TITLE because that is the only field `PatientHomeBanner` renders - the first version of // this put it on the birth-date checklist item, which is computed and never shown. title: !patient?.birthDate ? "Complete your profile — your date of birth is what enables messages" : profileCompleted ? "Complete your profile" : `Complete your profile — add your ${profileGapList}`, shortLabel: "Profile", description: "Add your personal details and contact information.", priority: "high", route: "/dashboard/profile", completed: profileCompleted, progressCurrent: profileCurrent, progressTotal: profileTotal, progressPercent: Math.round((profileCurrent / profileTotal) * 100), checklistItems: profileChecklistItems, }, { id: "system-related-persons", title: "Add family & caregivers", shortLabel: "Family & caregivers", description: "Add family members, caregivers, or emergency contacts.", priority: "routine", route: "/dashboard/related-persons", completed: relatedPersonsCompleted, progressCurrent: relatedPersonsCompleted ? 1 : 0, progressTotal: 1, progressPercent: relatedPersonsCompleted ? 100 : 0, }, { id: "system-medications", title: "Add your medications", shortLabel: "Medications", description: "Document current medications before your visit.", priority: "routine", route: "/dashboard/patient/medications/medication-statements", completed: medicationsCompleted, progressCurrent: medicationsCompleted ? 1 : 0, progressTotal: 1, progressPercent: medicationsCompleted ? 100 : 0, }, { id: "system-allergies", title: "Add your allergies", shortLabel: "Allergies", description: "Document known allergies or intolerances.", priority: "routine", route: "/dashboard/patient/clinical/allergy-intolerances", completed: allergiesCompleted, progressCurrent: allergiesCompleted ? 1 : 0, progressTotal: 1, progressPercent: allergiesCompleted ? 100 : 0, }, { id: "system-insurance", title: "Add your insurance", shortLabel: "Insurance", description: "Add your insurance plan, or tell us you have none.", priority: "routine", route: "/dashboard/patient/insurance", completed: coverageAnswered, progressCurrent: coverageAnswered ? 1 : 0, progressTotal: 1, progressPercent: coverageAnswered ? 100 : 0, }, { id: "system-family-history", title: "Add your family history", shortLabel: "Family history", description: "Document conditions that run in your family.", priority: "routine", route: "/dashboard/patient/clinical/family-history", completed: familyHistoryCompleted, progressCurrent: familyHistoryCompleted ? 1 : 0, progressTotal: 1, progressPercent: familyHistoryCompleted ? 100 : 0, }, { id: "system-communication-preferences", title: "Set your communication preferences", shortLabel: "Notifications", description: "Choose whether Akashic can contact you by email or text message.", priority: "routine", route: "/dashboard/settings", completed: communicationPreferencesSet, progressCurrent: communicationPreferencesSet ? 1 : 0, progressTotal: 1, progressPercent: communicationPreferencesSet ? 100 : 0, }, ]; }; // Which system setup-step tasks to show for the current view. On the patient overview the Outstanding/Completed // tabs split them by completion, so a FINISHED setup step appears under Completed instead of silently vanishing // ( / AHR-224 - completing profile/meds/allergies used to just drop the nudge, never surfacing it as // done). On the full tasks page (no tabs) only the outstanding nudges show, as before. export function selectSystemTasksForTab( tasks: T[], isHomePage: boolean, taskTab: "outstanding" | "completed", ): T[] { if (!isHomePage) { return tasks.filter((task) => !task.completed); } return tasks.filter((task) => taskTab === "completed" ? task.completed : !task.completed, ); } export const summarizePatientSystemTasks = ( tasks: PatientSystemTaskDefinition[], ): PatientSystemTaskSummary => { const completedCount = tasks.filter((task) => task.completed).length; return { completed: tasks.length > 0 && completedCount === tasks.length, completedCount, totalCount: tasks.length, incompleteLabels: tasks .filter((task) => !task.completed) .map((task) => task.shortLabel), tasks, }; };