import type { CodeableConcept, Observation, Task, TaskOutput } from 'fhir/r4'; import { readingQuantity } from './trackableMeasures'; // A plan activity that asks for a NUMBER, and what answering one produces. // // A plan activity is one of two things. An INSTRUCTION is a thing to do - "take metformin twice daily" // is standing and is not something to tick off. A MEASUREMENT asks for a number: it names what to // measure and its unit, and answering it writes the value to the task AND an observation. // // THE OBSERVATION IS THE POINT RATHER THAN A SIDE EFFECT. A goal's progress is derived at read time // from the observations that exist and is never stored, so a measurement that closed its task without // writing one would tick off as done and move nothing - the patient logs a reading and watches the bar // stay still. Everything that shows the value (the goal, the plan, a collaboration team holding it) is // reading the same observations, which is why they all move together. export const AKASHIC_TASK_MEASUREMENT_CODE_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-measurement-code'; export const AKASHIC_TASK_MEASUREMENT_UNIT_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-measurement-unit'; /** * A day that passed without an answer, written by the SYSTEM when the series caught up. * * IT IS A RECORD, NOT AN ABSENCE. The server writes one occurrence per skipped day rather than * discarding them, so a run of missed days reads as a pattern instead of a gap somebody has to notice - * and the pattern is the thing a practitioner needs to see. * * NEVER SET BY A PERSON, which is what separates it from `patient-unable-to-complete` sitting beside it: * one is somebody saying they could not, the other is nobody having been asked. */ export const AKASHIC_SYSTEM_MISSED_CODE = 'system-missed'; const TASK_STATUS_REASON_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/task-status-reason'; export const isMissedOccurrence = (task: Task | undefined): boolean => task?.status === 'failed' && (task.statusReason?.coding ?? []).some((coding) => coding.system === TASK_STATUS_REASON_SYSTEM && coding.code === AKASHIC_SYSTEM_MISSED_CODE); /** The day an occurrence was due, which is the date a missed one is filed under. */ export const taskDueDate = (task: Task | undefined): string | undefined => task?.restriction?.period?.end; /** What an activity asks for: the thing to measure, and the unit the answer is in. */ export interface TaskMeasurement { code: CodeableConcept; unit: string; } /** * What this activity asks for, or `undefined` when it asks for nothing measurable. * * BOTH HALVES OR NEITHER. A code with no unit produces a reading nobody can compare, and a unit with * nothing to measure names nothing - so a half-configured activity is deliberately NOT a measurement * rather than a measurement with a gap. Offering a reading field for one would write an observation * whose meaning cannot be recovered. */ export const getTaskMeasurement = (task: Task | undefined): TaskMeasurement | undefined => { const code = task?.extension?.find( (extension) => extension.url === AKASHIC_TASK_MEASUREMENT_CODE_EXTENSION, )?.valueCodeableConcept; const unit = task?.extension?.find( (extension) => extension.url === AKASHIC_TASK_MEASUREMENT_UNIT_EXTENSION, )?.valueString?.trim(); if (!code || !unit) { return undefined; } return { code, unit }; }; /** Whether the patient should be asked for a number. */ export const isMeasurementTask = (task: Task | undefined): boolean => getTaskMeasurement(task) !== undefined; /** * What the patient answered, recorded on the task itself. * * Typed by what was asked for, so a reader knows which measurement this answers without going back to * the activity's extensions - the task travels to surfaces the plan does not. */ export const buildMeasurementOutput = (task: Task, value: number): TaskOutput => { const measurement = getTaskMeasurement(task); if (!measurement) { throw new Error('Only a measurement activity can record a reading'); } if (!Number.isFinite(value)) { throw new Error(`Not a usable measurement value: ${value}`); } return { type: measurement.code, valueQuantity: readingQuantity(value, measurement.unit), }; }; export interface MeasurementObservationContext { /** When the reading was taken, as an ISO instant. */ now: string; /** The plan this activity belongs to, when one owns it. */ carePlanReference?: string; } /** * The reading, as the record holds it. * * `basedOn` IS WHAT LETS A GOAL FIND IT. Progress is derived by reading the observations that exist, so * an observation with no route back to its plan lands in the record and moves no bar. It is omitted * rather than invented when no plan owns the activity - a goal can exist without a plan, and so can a * measurement, but a dangling reference to a plan that is not there is worse than an absent one. * * `performer` IS THE PATIENT, and that is a fact about the reading rather than bookkeeping. A weight the * patient logged and one taken at the practice are not the same kind of fact, and the goal's history has * to say which is which - the same distinction medications already draw between prescribed and * patient-reported. */ export const buildMeasurementObservation = ( task: Task, value: number, context: MeasurementObservationContext, ): Observation => { const output = buildMeasurementOutput(task, value); const subject = task.for; if (!subject?.reference) { throw new Error('A measurement reading needs the patient it belongs to'); } return { resourceType: 'Observation', id: measurementReadingId(task), status: 'final', code: output.type as CodeableConcept, subject, performer: [subject], effectiveDateTime: context.now, valueQuantity: output.valueQuantity, ...(context.carePlanReference && { basedOn: [{ reference: context.carePlanReference }] }), }; }; /** * The reading's id, derived from the occurrence that produced it. * * ONE OCCURRENCE, ONE READING. Answering is two writes and the reading goes first; if closing the task * then fails, the activity stays open and the patient answers again. A second observation would be * counted by any goal reading them - a single weigh-in showing as two - so the id has to be the same on * a retry and the write has to replace rather than add. * * Derived from the TASK rather than from the day, because a recurring series is a task per occurrence: * keying on the date would make tomorrow's weigh-in overwrite today's and leave the goal one reading. */ export const measurementReadingId = (task: Task): string => { if (!task.id) { throw new Error('A measurement reading needs the activity it answers'); } return `reading-${task.id}`; }; const ordered = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(ordered); if (value && typeof value === 'object') { return Object.keys(value as Record).sort().reduce>((out, key) => { const entry = (value as Record)[key]; if (entry !== undefined) out[key] = ordered(entry); return out; }, {}); } return value; }; /** A reading without what an answer itself sets each time (its value and time) or the server stamps. */ const ANSWER_SETS = ['meta', 'id', 'performer', 'valueQuantity', 'effectiveDateTime', 'issued']; const answerShape = (reading: Observation): unknown => ordered(Object.fromEntries(Object.entries(reading).filter(([key]) => !ANSWER_SETS.includes(key)))); /** * Whether a stored reading is still only an answer to this occurrence: nothing but its value and time differ from the * answer about to be written. One the care team has since corrected, retracted, annotated or interpreted is not. */ export const isUntouchedAnswerReading = (stored: Observation, answer: Observation): boolean => JSON.stringify(answerShape(stored)) === JSON.stringify(answerShape(answer)); /** One write an answer produces, in the order it must happen. */ export interface MeasurementWrite { kind: 'observation' | 'task'; resource: Observation | Task; } /** * Everything answering a measurement records, in order. * * THE READING IS FIRST AND THE ORDER IS THE DESIGN. Closing the task without writing the observation * would tick the activity off as done and leave the goal exactly where it was, with nothing anywhere * saying why - progress is derived from the observations that exist, so a lost reading is a bar that * silently does not move. The reverse failure is repairable and this one is not: an unclosed task is * answered again, and the reading it re-writes replaces the first. * * Returned rather than performed, so the ordering is a fact a test can hold rather than a sequence * buried in a callback. */ export const answeredMeasurementWrites = ( task: Task, value: number, context: MeasurementObservationContext, ): MeasurementWrite[] => { const reading = buildMeasurementObservation(task, value, context); const output = buildMeasurementOutput(task, value); return [ { kind: 'observation', resource: reading }, { kind: 'task', resource: { ...task, status: 'completed', output: [...(task.output ?? []), output], }, }, ]; };