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 (
);
}
return (
);
}
export default FieldHistoryModal;