import { formatDate, formatDateTime } from '../../utils/dateUtils'; import type { FieldTimeline, FieldTimelineEntry } from '../../utils/fieldHistory/buildFieldTimeline'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Button } from '../ui/button'; import type { FieldAttribution } from './FieldHistoryProvider'; import './FieldHistoryModal.css'; export const ATTRIBUTION_INCOMPLETE = 'Some change details could not be loaded. The values shown are still available.'; export interface FieldHistoryModalProps { /** The field's display label - used as the dialog title. */ label: string; timeline: FieldTimeline; open: boolean; onClose: () => void; /** `list`: the patient profile's plain list of values, each with when and who. */ layout?: 'timeline' | 'list'; /** What the record's attribution read covered; an incomplete one is said, with a retry. */ attribution?: FieldAttribution; } /** Said when not every change detail was read: the values stand, and the read can be tried again. */ function AttributionNotice({ attribution }: { attribution?: FieldAttribution }) { if (attribution?.state !== 'incomplete') return null; return (

{ATTRIBUTION_INCOMPLETE}

); } // FHIR `date` (YYYY, YYYY-MM, or YYYY-MM-DD) vs `dateTime`/`instant` (has a `T` time component). // Only a plain YYYY-MM-DD is unambiguous enough to hand to `formatDate` (matches // `dateUtils.parseFhirDate`'s own local-midnight special case); anything with a time component // goes to `formatDateTime`. Any other string (a name, a free-text note, a status code,...) is // rendered as-is. const FHIR_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const FHIR_DATETIME_PATTERN = /^\d{4}-\d{2}(-\d{2})?T\d{2}:\d{2}/; /** * Best-effort, never-throwing rendering of a single timeline entry's `value` (which is `unknown` * -- it can be any FHIR data-type shape `getAtPath` (Task 1) resolved a path down to): * - `undefined`/`null` (the field was absent on that version) -> "(not set)". * - A FHIR date/dateTime string -> formatted via `dateUtils`. * - Any other string, number, or boolean -> its literal/`String()` form. * - An array -> each item, joined with "; ". * - A composite (Quantity, Coding, CodeableConcept, component, Range, Period) -> readable text via * `formatCompositeValue`; never a raw object or a bare unit. */ function formatEntryValue(value: unknown): string { if (value === undefined || value === null) { return '(not set)'; } if (typeof value === 'string') { if (FHIR_DATETIME_PATTERN.test(value)) { return formatDateTime(value); } if (FHIR_DATE_ONLY_PATTERN.test(value)) { return formatDate(value); } return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } if (Array.isArray(value)) { const parts = value.map(formatEntryValue).filter((part) => part !== '(not set)' && part !== ''); return parts.length > 0 ? parts.join('; ') : '(not set)'; } if (typeof value === 'object') { return formatCompositeValue(value as Record); } return String(value); } const hasText = (value: unknown): value is string => typeof value === 'string' && value.length > 0; // Composite FHIR types, most specific shape first: a Quantity's UCUM code is only its unit, and an // Observation component's code names what its value measures. function formatCompositeValue(value: Record): string { if (hasText(value.display)) { return value.display; } if (typeof value.value === 'number' || hasText(value.value)) { const unit = hasText(value.unit) ? value.unit : hasText(value.code) ? value.code : ''; const comparator = hasText(value.comparator) ? value.comparator : ''; return `${comparator}${value.value}${unit ? ` ${unit}` : ''}`; } const valueKey = Object.keys(value).find((key) => key.startsWith('value') && key !== 'value'); if (valueKey) { const measured = formatEntryValue(value[valueKey]); return value.code && typeof value.code === 'object' ? `${formatEntryValue(value.code)}: ${measured}` : measured; } if (hasText(value.text)) { return value.text; } if (Array.isArray(value.coding) && value.coding.length > 0) { return formatEntryValue(value.coding[0]); } if (value.low !== undefined || value.high !== undefined) { const low = value.low === undefined ? '' : formatEntryValue(value.low); const high = value.high === undefined ? '' : formatEntryValue(value.high); if (low && high) return `${low} to ${high}`; return low ? `at least ${low}` : `up to ${high}`; } if (value.start !== undefined || value.end !== undefined) { const start = value.start === undefined ? '' : formatEntryValue(value.start); const end = value.end === undefined ? '' : formatEntryValue(value.end); if (start && end) return `${start} to ${end}`; return start ? `from ${start}` : `until ${end}`; } if (hasText(value.code)) { return value.code; } return '(a value this view cannot show)'; } /** One earlier value on the patient profile: the value, then when and who. */ function FieldHistoryListRow({ entry }: { entry: FieldTimelineEntry }) { const when = entry.whenIso ? formatDateTime(entry.whenIso) : 'An unknown time'; const who = entry.who ? entry.who : entry.whoConfidence === 'refused' ? 'we could not read who made this change' : entry.whoConfidence === 'incomplete' ? 'who made this change could not be loaded' : 'unknown'; return (
  • {formatEntryValue(entry.value)}

    {`${when} ยท ${who}`}

  • ); } function FieldHistoryEntryRow({ entry }: { entry: FieldTimelineEntry }) { const when = entry.whenIso ? formatDateTime(entry.whenIso) : 'an unknown time'; /* * "UNKNOWN" USED TO MEAN TWO THINGS AND ONE OF THEM WAS A LIE. * * Measured on the mount: a patient opening their own date of birth saw four versions, each * "changed by unknown", because `GET /R4/Provenance` answered 403 on their token. The same four * versions read from the staff record name the people. So the screen told the patient the system * does not know who changed their date of birth, when the truth is that they were not allowed to * ask - and those two readings call for completely different actions from them. * * The standing rule is that a refusal renders as a denial naming what is denied, never as empty * data. `unknown` is kept for what it always honestly meant: no Provenance describes this version. */ const attribution = entry.who ? `changed by ${entry.who} on ${when}` : entry.whoConfidence === 'refused' ? `changed on ${when} \u2014 we could not read who made this change` : entry.whoConfidence === 'incomplete' ? `changed on ${when}; who made this change could not be loaded` : `changed by unknown on ${when}`; return (
  • {formatEntryValue(entry.value)}

    {attribution}

  • ); } /** * Renders one field's change timeline (Task 2's `FieldTimeline`, optionally who-joined by Task 3) * in a Radix `ui/dialog`. Opened from `FieldRow`'s history affordance (Task 6). * * `timeline.entries` is already newest-first (guaranteed by `buildFieldTimeline`, which iterates * `_history` versions in that same order) -- rendered as given, no re-sort. */ export function FieldHistoryModal({ label, timeline, open, onClose, layout = 'timeline', attribution }: FieldHistoryModalProps) { const hasChanges = timeline.changed && timeline.entries.length > 0; if (layout === 'list') { return ( { if (!next) onClose(); }} > {label} Every value this field has held, newest first. {hasChanges ? (
      {timeline.entries.map((entry, index) => ( ))}
    ) : (

    No changes recorded for this field.

    )}

    A change can take a minute or two to appear here.

    ); } return ( { if (!next) onClose(); }} > {label} History versions may take 1-2 minutes to appear after changes are made. If you don't see the most recent version immediately, this is expected behavior. {hasChanges ? (
      {timeline.entries.map((entry, index) => ( ))}
    ) : (

    No changes recorded for this field.

    )}
    ); } export default FieldHistoryModal;