import type { CarePlan, Goal, Observation, Task } from 'fhir/r4'; import { formatShortDate } from './dateUtils'; import { byReadingTime, goalProgress, isInGoalEpisode, isInGoalUnit, isValidReading, readingTime, untilReached, type GoalProgress } from './goalProgress'; import { getTaskMeasurement, isMissedOccurrence, taskDueDate } from './measurementTask'; import { getTaskCarePlanReference } from './planItem'; import type { GoalPractice } from './goalAuthorship'; // A goal, in the words the drawing uses for it. // // THE SENTENCES LIVE HERE AND NOT IN THE PAGE, for the same reason `describeActivity` does: the goals // page, the care plan and a collaboration team holding the plan all draw the same goal, and three // surfaces phrasing it separately is how one of them ends up saying a number is progress when it is not. // // EVERYTHING IS DERIVED FROM THE READINGS THAT EXIST. Nothing here reads a stored progress value, // because there is none - which is why the line under the bar says what MOVES it rather than when it was // last set. A bar that changes for reasons the patient cannot see is worse than no bar. const PATIENT_LOGGED = 'you log this yourself'; const FROM_THE_LAB = 'moves when a result arrives from the lab'; const FROM_THE_PRACTICE = 'moves when your care team records one'; /** Thousands separated, at most one decimal - "8,000", "0.5", "248". */ const formatNumber = (value: number): string => value.toLocaleString('en-US', { maximumFractionDigits: 1 }); const withUnit = (value: number, unit: string | undefined): string => (unit ? `${formatNumber(value)} ${unit}` : formatNumber(value)); /** The unit the goal is stated in. Every number on the row is in it, so a row is never mixed. */ export const getGoalUnit = (goal: Goal | undefined): string | undefined => goal?.target?.[0]?.detailQuantity?.unit; const sameMeasureAsGoal = (goal: Goal, observation: Observation): boolean => { const goalCodes = (goal.target?.[0]?.measure?.coding ?? []) .map((coding) => `${coding.system ?? ''}|${coding.code ?? ''}`); return (observation.code?.coding ?? []) .some((coding) => goalCodes.includes(`${coding.system ?? ''}|${coding.code ?? ''}`)); }; /** * This goal's readings, oldest first. * * THE SAME MEASURE AND UNIT RULES AS `goalProgress`, deliberately. The list under a goal is meant to be * what moved the bar above it, so a reading the bar ignored appearing in the list would read as a bar * that is broken. */ export const goalReadings = (goal: Goal | undefined, observations: Observation[]): Observation[] => { if (!goal) return []; const unit = getGoalUnit(goal); return untilReached(goal, observations .filter((observation) => sameMeasureAsGoal(goal, observation)) .filter(isValidReading) .filter((observation) => isInGoalEpisode(goal, observation)) .filter((observation) => typeof observation.valueQuantity?.value === 'number') .filter((observation) => isInGoalUnit(observation, unit)) .sort(byReadingTime)); }; const isPatientPerformed = (observation: Observation | undefined): boolean => (observation?.performer ?? []).some((performer) => performer.reference?.startsWith('Patient/')); const isLabResult = (observation: Observation | undefined): boolean => (observation?.category ?? []).some((category) => (category.coding ?? []) .some((coding) => coding.code === 'laboratory')); /** * What makes this number move, said to the patient. * * READ OFF THE READINGS THEMSELVES rather than configured, because the honest answer is whoever has * actually been taking them. `undefined` when there are none yet - the clause is dropped rather than * guessed, since telling a patient they log a reading themselves when the lab does is worse than saying * nothing about it at all. */ export const describeWhatMovesIt = ( goal: Goal | undefined, observations: Observation[], ): string | undefined => { const readings = goalReadings(goal, observations); const latest = readings[readings.length - 1]; if (!latest) return undefined; if (isPatientPerformed(latest)) return PATIENT_LOGGED; if (isLabResult(latest)) return FROM_THE_LAB; return FROM_THE_PRACTICE; }; export interface GoalRow { title: string; /** The latest reading, in the goal's unit. Absent until one has been taken. */ currentLabel?: string; /** The line under the bar. */ summary: string; /** Where it started, where it is going and where it is now, when the goal has numbers. */ numbers?: string; progress?: GoalProgress; reached: boolean; } /** * The row as the drawing states it. * * THREE SHAPES, and which one applies is a fact rather than a matter of taste: * * reached Reached on Jul 14 - from 280 lb - retired * moved 32 lb down, 48 to go - started at 280 lb * not yet Lower is better - you log this yourself * * The third shape exists because a goal with no reading has nothing to say about distance travelled, and * inventing "0 lb down" would draw a patient who has not started as one who has made no progress. */ export const describeGoalRow = ( goal: Goal | undefined, observations: Observation[], ): GoalRow | undefined => { if (!goal) return undefined; // A goal with no description is still a record somebody created, and hiding it is the worse of the // two failures - the patient would have no way to find out it exists, let alone correct it. It is // named as untitled rather than dropped. const title = goal.description?.text?.trim() || 'Untitled goal'; const unit = getGoalUnit(goal); const progress = goalProgress(goal, observations); const readings = goalReadings(goal, observations); const latest = readings[readings.length - 1]; const current = progress?.currentValue; const row: GoalRow = { title, currentLabel: current === undefined ? undefined : withUnit(current, unit), summary: '', numbers: progress ? `${withUnit(progress.startValue, unit)} → ${withUnit(progress.targetValue, unit)}${current === undefined ? '' : ` · now ${withUnit(current, unit)}`}` : undefined, progress, reached: progress?.reached ?? false, }; // Nothing computable - no direction, no target, or a zero range. The row still draws, because a goal // somebody set is worth showing even when its numbers cannot make a bar; it simply says nothing it // cannot support. if (!progress) { row.summary = describeWhatMovesIt(goal, observations) ?? ''; return row; } const direction = progress.direction === 'down' ? 'Lower is better' : 'Higher is better'; if (progress.reached) { const on = formatShortDate(latest ? readingTime(latest) : undefined); const from = withUnit(progress.startValue, unit); row.summary = on ? `Reached on ${on} · from ${from} · retired` : `Reached · from ${from} · retired`; return row; } if (current !== undefined && current !== progress.startValue) { const travelled = Math.abs(current - progress.startValue); const remaining = Math.abs(progress.targetValue - current); const way = progress.direction === 'down' ? 'down' : 'up'; row.summary = `${withUnit(travelled, unit)} ${way}, ${formatNumber(remaining)} to go` + ` · started at ${withUnit(progress.startValue, unit)}`; return row; } const moves = describeWhatMovesIt(goal, observations); row.summary = moves ? `${direction} · ${moves}` : direction; return row; }; export interface GoalSource { label: string; /** The patient's own goal, which the drawing marks differently because nobody prescribed it. */ selfSet: boolean; /** The plan it belongs to, when one does - what a "View care plan" control would open. */ carePlanId?: string; /** Which of the drawing's provenances this is: the patient's, this practice's, or another practice's. */ kind?: 'self' | 'here' | 'elsewhere'; } /** * Where the goal came from, which the drawing calls "the first question either way". * * RETURNS NOTHING WHEN IT CANNOT TELL, rather than falling back to "you set this one". A goal whose * provenance was lost is not the patient's own goal, and saying so would put words in their mouth about * a record their practice wrote. */ export const describeGoalSource = ( goal: Goal | undefined, plans: CarePlan[], patientId: string | undefined, // Staff read the patient's goals about the patient, so the sentences name them rather than "you". reader: { staff: boolean; patientName?: string; practice?: GoalPractice; practitionerId?: string } = { staff: false }, ): GoalSource | undefined => { if (!goal) return undefined; const their = reader.staff ? 'their' : 'your'; // The clinician who wrote it reads "You set this", as the drawing does, not their own name. const readerSetIt = reader.staff && Boolean(reader.practitionerId) && goal.expressedBy?.reference === `Practitioner/${reader.practitionerId}`; const planReference = (goal.addresses ?? []) .find((reference) => reference.reference?.startsWith('CarePlan/')); const planId = planReference?.reference?.split('/')[1]; const plan = plans.find((candidate) => candidate.id === planId); if (plan) { const title = plan.title?.trim(); const author = plan.author?.display?.trim(); if (readerSetIt) { const on = title ? `on the ${title} plan` : `on one of ${their} care plans`; return { label: `You set this · ${on}`, selfSet: false, carePlanId: plan.id, kind: 'here' }; } const label = title ? `On ${reader.staff ? 'the' : 'your'} ${title} plan` : `On one of ${their} care plans`; return { label: author ? `${label} · ${author}` : label, selfSet: false, carePlanId: plan.id, kind: 'here' }; } const expressedBy = goal.expressedBy?.reference; if (patientId && expressedBy === `Patient/${patientId}`) { return { label: reader.staff ? `${reader.patientName?.trim() || 'The patient'} set this themselves` : 'You set this one', selfSet: true, kind: 'self', }; } // A clinician tells a goal written elsewhere apart from one written here. if (reader.staff && reader.practice?.fromAnotherPractice) { return { label: `Set at ${reader.practice.practiceName ?? 'another practice'}`, selfSet: false, kind: 'elsewhere' }; } if (readerSetIt) return { label: 'You set this', selfSet: false, kind: 'here' }; const author = goal.expressedBy?.display?.trim(); if (author) { return { label: `Set by ${author}`, selfSet: false, kind: 'here' }; } // A plan reference we could not resolve is still a plan, and saying so beats saying nothing - but it // must not claim a title it does not have. return planId ? { label: `On one of ${their} care plans`, selfSet: false, carePlanId: planId, kind: 'here' } : undefined; }; /** * One reading, as a line in "What moves it". * * WHO TOOK IT IS PART OF THE FACT. "A weight the patient logged and one taken at the practice are not * the same kind of fact" - the same provenance distinction medications already draw between prescribed * and patient-reported. */ export interface GoalReadingLine { date: string; value: string; who: string; source: ReadingSource; } /** Who took a reading. Only `patient` is a self-report; the other three were measured. */ export type ReadingSource = 'patient' | 'device' | 'lab' | 'practice'; /** A device outranks the performer: a scale that sends its own weight is not the patient typing one. */ export const readingSource = (observation: Observation): ReadingSource => { if (observation.device?.reference || observation.device?.display) return 'device'; if (isPatientPerformed(observation)) return 'patient'; if (isLabResult(observation)) return 'lab'; return 'practice'; }; const READING_SOURCE_WORDS: Record = { patient: 'You logged this', device: 'Sent by a device', lab: 'From the lab', practice: 'Taken at the practice', }; export const describeGoalReading = ( goal: Goal | undefined, observation: Observation, ): GoalReadingLine => ({ date: formatShortDate(readingTime(observation)), value: withUnit(observation.valueQuantity?.value ?? 0, getGoalUnit(goal)), who: READING_SOURCE_WORDS[readingSource(observation)], source: readingSource(observation), }); /** One entry in "What moves it" - either a reading, or a day that passed unanswered. */ export interface GoalHistoryEntry { kind: 'reading' | 'missed'; /** Sortable, for ordering. The rendered form is `date`. */ on: string; date: string; value?: string; who?: string; source?: ReadingSource; } /** The occurrences of this goal's measurement that were never answered. */ const missedOccurrences = (goal: Goal, tasks: Task[]): Task[] => { const measure = goal.target?.[0]?.measure; const plan = (goal.addresses ?? []).find((r) => r.reference?.startsWith('CarePlan/'))?.reference; return tasks .filter(isMissedOccurrence) .filter((task) => { const measurement = getTaskMeasurement(task); if (!measurement) return false; // Same plan AND same measure, mirroring the rule the scheduler applies in the other // direction. A plan can hold a weight goal and a blood sugar goal at once, and a missed // weigh-in is not a missed blood sugar. const taskPlan = getTaskCarePlanReference(task)?.reference; const measureCodes = (measure?.coding ?? []).map((c) => `${c.system ?? ''}|${c.code ?? ''}`); return (!plan || taskPlan === plan) && (measurement.code.coding ?? []).some((c) => measureCodes.includes(`${c.system ?? ''}|${c.code ?? ''}`)); }) .filter((task) => taskDueDate(task) !== undefined); }; /** * Everything that moved this goal, and everything that should have, newest first. * * A MISSED DAY IS IN THE LIST RATHER THAN ABSENT FROM IT. "A gap somebody has to notice is not a * record" - a run of blanks reads as a patient with no history instead of one with a pattern, and the * pattern is the thing worth seeing. * * WHOEVER RENDERS IT MUST NOT REACH FOR THE OVERDUE TREATMENT. Amber means OWED, and a missed day is no * longer owed; it is history, not a reprimand. */ export const goalHistory = ( goal: Goal | undefined, observations: Observation[], tasks: Task[] = [], ): GoalHistoryEntry[] => { if (!goal) return []; const readings: GoalHistoryEntry[] = goalReadings(goal, observations).map((observation) => { const line = describeGoalReading(goal, observation); return { kind: 'reading', on: readingTime(observation), date: line.date, value: line.value, who: line.who, source: line.source, }; }); const missed: GoalHistoryEntry[] = missedOccurrences(goal, tasks).map((task) => ({ kind: 'missed', on: taskDueDate(task) as string, date: formatShortDate(taskDueDate(task)), })); // Newest first. Sorted on the raw dates rather than the rendered ones, which carry no year. return [...readings, ...missed].sort((a, b) => b.on.localeCompare(a.on)); };