import type { CarePlan, CareTeam, Goal, Observation } from "fhir/r4"; import { formatDate } from "../../../../utils/dateUtils"; import { createdAtOrganization } from "../../../../utils/createdAtOrganization"; import { carePlanName } from "../../../../utils/carePlanName"; /** * The patient's plans, the goals under each, and who is on their care team. * * A PLAN IS NOT A DOCUMENT. The approved deck: "A plan a patient can only read is a document - so * every line on it goes somewhere." The shapes here therefore carry a destination on every activity, * and a plan with no activities is still a plan rather than a heading with nothing under it. */ export type PlanActivity = { id: string; title: string; /** The quiet line under the activity - what was last done, or where it opens. */ detail?: string; /** True once the activity is finished, which is what fills the tick. */ done: boolean; /** Set when the activity is waiting on the patient rather than merely informational. */ due?: string; }; export type PlanGoal = { id: string; title: string; /** Where the number is now, as the patient's own measure reads it. */ value?: string; /** 0-100. Undefined when the goal carries no measurable target. */ percent?: number; reached: boolean; detail?: string; }; export type PlanCard = { id: string; title: string; /** Who wrote it. */ by?: string; /** * The practice they wrote it at, shown beside them. * * Spencer, 2026-08-29: a practitioner is never named without their organization - a patient can * hold clinicians at several practices and the name alone does not say which one this is. */ organization?: string; /** The practice's id, whose clock the plan's due dates are read on. */ organizationId?: string; /** The care teams the plan names (`CarePlan.careTeam`), by id. */ careTeamIds: string[]; /** "Started March 2021 - last reviewed 7 August". */ period?: string; activities: PlanActivity[]; goals: PlanGoal[]; finished: boolean; }; export type TeamMember = { id: string; /** The member's `Type/id`, when the team gave one; a nameless member is named from it. */ ref?: string; /** The name the team wrote down; absent means it has to be read from `ref`. */ name?: string; role?: string; organization?: string; }; /** A plan that has run its course. `completed` and `revoked` both belong under Finished. */ const FINISHED = new Set(["completed", "revoked", "entered-in-error"]); export const initialsOf = (name: string) => name .replace(/^(Dr\.?|Mr\.?|Mrs\.?|Ms\.?)\s+/i, "") .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? "") .join(""); const activityOf = (activity: NonNullable[number], index: number): PlanActivity => { const detail = activity.detail; const status = detail?.status; return { id: `${index}`, title: detail?.description?.trim() || detail?.code?.text?.trim() || "Something to do", detail: detail?.reasonCode?.[0]?.text?.trim(), done: status === "completed", // A scheduled activity that has not happened is the one line the patient can act on. due: status !== "completed" && detail?.scheduledPeriod?.end ? `Due by ${formatDate(detail.scheduledPeriod.end)}` : undefined, }; }; const goalOf = (goal: Goal, observations: readonly Observation[] | undefined): PlanGoal => { const reached = goal.achievementStatus?.coding?.[0]?.code === "achieved"; const measured = goalProgress(goal, observations); return { id: goal.id ?? goal.description?.text ?? "goal", title: goal.description?.text?.trim() || "A goal", // The number shown is the latest READING, not the target: the patient is asking where they are. value: measured.value, percent: reached ? 100 : measured.percent, reached, detail: goal.note?.[0]?.text?.trim(), }; }; /** The plans a goal names in its own `addresses`. */ const plansAddressedBy = (goal: Goal): string[] => (goal.addresses ?? []) .map((reference) => reference.reference) .filter((reference): reference is string => !!reference?.startsWith("CarePlan/")) .map((reference) => reference.split("/")[1] ?? "") .filter(Boolean); /** * The goals that belong to one plan, matched in BOTH directions. * * FHIR RECORDS THIS LINK TWICE AND WE ONLY EVER WRITE ONE HALF. `CarePlan.goal` points down and * `Goal.addresses` points back up, and the authoring path sets `addresses` on the goal without * amending the plan - so matching on `plan.goal` alone found nothing, every time. Observed in the * browser 2026-09-02: a plan authored end to end drew its title and no goals at all, with the goal * sitting on the server correctly addressed to it. * * Reading both is not a workaround for that. Either reference is a true statement of the link, so a * reader that honours one and ignores the other is wrong about the record however it was written - * including records that arrive from outside, where we do not choose which half gets set. */ const goalsFor = ( plan: CarePlan, goals: readonly Goal[], observations: readonly Observation[] | undefined, ): PlanGoal[] => { const wanted = new Set( (plan.goal ?? []).map((ref) => ref.reference?.split("/").pop()).filter(Boolean) as string[], ); return goals .filter((goal) => (goal.id && wanted.has(goal.id)) || (plan.id ? plansAddressedBy(goal).includes(plan.id) : false), ) .map((goal) => goalOf(goal, observations)); }; export const buildPlanCards = ( plans: readonly CarePlan[] | undefined, goals: readonly Goal[] | undefined, observations?: readonly Observation[], ): { active: PlanCard[]; finished: PlanCard[] } => { const cards = (plans ?? []) .filter((plan) => plan.status !== "draft") .map((plan): PlanCard => { const started = plan.period?.start ? `Started ${formatDate(plan.period.start)}` : undefined; const reviewed = plan.meta?.lastUpdated ? `last reviewed ${formatDate(plan.meta.lastUpdated)}` : undefined; return { id: plan.id ?? plan.title ?? "plan", // The patient's words for a plan with no name, the same ones a task that came from it uses. title: carePlanName(plan, "A plan from your care team"), by: plan.author?.display?.trim(), organization: createdAtOrganization(plan).name, organizationId: createdAtOrganization(plan).id, careTeamIds: (plan.careTeam ?? []) .map((reference) => reference.reference?.split("/").pop() ?? "") .filter(Boolean), period: [started, reviewed].filter(Boolean).join(" - ") || undefined, activities: (plan.activity ?? []).map(activityOf), goals: goalsFor(plan, goals ?? [], observations), finished: FINISHED.has(plan.status ?? ""), }; }); return { active: cards.filter((card) => !card.finished), finished: cards.filter((card) => card.finished), }; }; /** * "3 active - 2 practices" - the count line beside the heading. * * Counted on the ORGANIZATION, not the author: two plans from two clinicians at the same practice * are one practice, and counting authors would tell the patient they attend more places than they do. */ export const plansSummary = (active: PlanCard[]): string => { const practices = new Set(active.map((card) => card.organization).filter(Boolean)); const practiceLabel = practices.size === 1 ? "1 practice" : `${practices.size} practices`; return `${active.length} active · ${practiceLabel}`; }; /** Clinicians only: the card is the patient's clinical care team, as the deck draws it. */ const CLINICIAN_TYPES = new Set(["Practitioner", "PractitionerRole"]); /** A date-only bound covers its whole day. */ const boundOf = (value: string, endOfDay: boolean) => Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T${endOfDay ? "23:59:59.999" : "00:00:00"}` : value); /** FHIR R4 CareTeam statuses whose members are not participating in care; a missing status is read as in force. */ const NOT_PARTICIPATING = new Set(["proposed", "suspended", "inactive", "entered-in-error"]); const isCurrent = (period: { start?: string; end?: string } | undefined, now: number) => !(period?.end && boundOf(period.end, true) < now) && !(period?.start && boundOf(period.start, false) > now); /** * The clinicians currently on the patient's care teams, once each. * * DEDUPED ON THE PERSON, not on the membership: the same clinician on two teams is one human on the * list, and showing them twice would make a five-person team read as eight. A membership that has * ended, a team not currently in force, and an organization, the patient or a relative on the team are * not people looking after them clinically, so none is listed. */ export const buildTeam = (teams: readonly CareTeam[] | undefined, now: number = Date.now()): TeamMember[] => { const seen = new Map(); for (const team of teams ?? []) { if (team.status && NOT_PARTICIPATING.has(team.status)) continue; for (const participant of team.participant ?? []) { if (!isCurrent(participant.period, now)) continue; const ref = participant.member?.reference?.trim() || undefined; const type = ref?.split("/")[0] ?? participant.member?.type; if (!type || !CLINICIAN_TYPES.has(type)) continue; const name = participant.member?.display?.trim() || undefined; const key = ref ?? name; if (!key || seen.has(key)) continue; seen.set(key, { id: key, ref, name, role: participant.role?.[0]?.text?.trim() || participant.role?.[0]?.coding?.[0]?.display?.trim(), organization: team.name?.trim(), }); } } return [...seen.values()]; }; /** * How far along a goal is, measured rather than asserted. * * Spencer, 2026-08-29: progress comes from observations recorded by the practice AND from the * patient's own readings - not one or the other. Both are Observations; who took the reading is the * `performer`, not a different resource, so matching on the goal's `target.measure` code picks up * both without the page having to know which is which. * * A DIRECTION IS NOT ASSUMED. "HbA1c under 7%" and "Lose 10 lb" move opposite ways, and FHIR does not * say which. So a bar is only drawn where the goal states its own achievement, or where the latest * reading is on the near side of the target; otherwise the reading is shown as a value with no bar, * which says what is known without inventing what is not. */ export const goalProgress = ( goal: Goal, observations: readonly Observation[] | undefined, ): { value?: string; percent?: number } => { const target = goal.target?.[0]; const codes = new Set( (target?.measure?.coding ?? []).map((coding) => coding.code).filter(Boolean) as string[], ); const latest = (observations ?? []) .filter((observation) => (observation.code?.coding ?? []).some((coding) => coding.code && codes.has(coding.code)), ) .sort((a, b) => (b.effectiveDateTime ?? "").localeCompare(a.effectiveDateTime ?? ""))[0]; const reading = latest?.valueQuantity; if (!reading?.value) return {}; const goalValue = target?.detailQuantity?.value; // A percent or degree sign sits against its number; a unit word takes a space. Without this, // an HbA1c goal reads "6.8 %". const unit = reading.unit?.trim(); const spaced = unit && !/^[%°]/.test(unit); const value = `${reading.value}${unit ? `${spaced ? " " : ""}${unit}` : ""}`; if (goalValue === undefined || goalValue === 0) return { value }; const ratio = reading.value / goalValue; return { value, percent: Math.max(0, Math.min(100, Math.round(ratio * 100))), }; };