Skip to main content
Glama
format.ts16.9 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Address, CodeableConcept, Coding, HumanName, Money, Observation, ObservationComponent, Period, Quantity, Range, Reference, Timing, TimingRepeat, } from '@medplum/fhirtypes'; import type { TypedValue } from './types'; import { capitalize, stringify } from './utils'; export interface AddressFormatOptions { all?: boolean; use?: boolean; lineSeparator?: string; } export interface HumanNameFormatOptions { all?: boolean; prefix?: boolean; suffix?: boolean; use?: boolean; } /** * Converts a typed value to a string. * @param typedValue - The typed value to convert to a string. * @returns The string representation of the typed value. */ export function typedValueToString(typedValue: TypedValue | undefined): string { if (!typedValue) { return ''; } switch (typedValue.type) { case 'Address': return formatAddress(typedValue.value); case 'CodeableConcept': return formatCodeableConcept(typedValue.value); case 'Coding': return formatCoding(typedValue.value); case 'ContactPoint': return typedValue.value.value; case 'HumanName': return formatHumanName(typedValue.value); case 'Quantity': return formatQuantity(typedValue.value); case 'Reference': return formatReferenceString(typedValue.value); default: return typedValue.value?.toString() ?? ''; } } /** * Formats a FHIR Reference as a string. * @param value - The reference to format. * @returns The formatted reference string. */ export function formatReferenceString(value: Reference | undefined): string { if (!value) { return ''; } return value.display ?? value.reference ?? stringify(value); } /** * Formats a FHIR Address as a string. * @param address - The address to format. * @param options - Optional address format options. * @returns The formatted address string. */ export function formatAddress(address: Address | undefined, options?: AddressFormatOptions): string { if (!address) { return ''; } const builder = []; if (address.line) { builder.push(...address.line); } if (address.city || address.state || address.postalCode) { const cityStateZip = []; if (address.city) { cityStateZip.push(address.city); } if (address.state) { cityStateZip.push(address.state); } if (address.postalCode) { cityStateZip.push(address.postalCode); } builder.push(cityStateZip.join(', ')); } if (address.use && (options?.all || options?.use)) { builder.push('[' + address.use + ']'); } return builder.join(options?.lineSeparator ?? ', ').trim(); } /** * Formats a FHIR HumanName as a string. * @param name - The name to format. * @param options - Optional name format options. * @returns The formatted name string. */ export function formatHumanName(name: HumanName | undefined, options?: HumanNameFormatOptions): string { if (!name) { return ''; } const builder = []; if (name.prefix && options?.prefix !== false) { builder.push(...name.prefix); } if (name.given) { builder.push(...name.given); } if (name.family) { builder.push(name.family); } if (name.suffix && options?.suffix !== false) { builder.push(...name.suffix); } if (name.use && (options?.all || options?.use)) { builder.push('[' + name.use + ']'); } if (builder.length === 0) { const textStr = ensureString(name.text); if (textStr) { return textStr; } } return builder.join(' ').trim(); } /** * Formats the given name portion of a FHIR HumanName element. * @param name - The name to format. * @returns The formatted given name string. */ export function formatGivenName(name: HumanName): string { const builder: string[] = []; if (name.given) { builder.push(...name.given); } return builder.join(' ').trim(); } /** * Formats the family name portion of a FHIR HumanName element. * @param name - The name to format. * @returns The formatted family name string. */ export function formatFamilyName(name: HumanName): string { return ensureString(name.family) ?? ''; } /** * Returns true if the given date object is a valid date. * Dates can be invalid if created by parsing an invalid string. * @param date - A date object. * @returns Returns true if the date is a valid date. */ export function isValidDate(date: Date): boolean { return date instanceof Date && !Number.isNaN(date.getTime()); } /** * Formats a FHIR date string as a human readable string. * Handles missing values and invalid dates. * @param date - The date to format. * @param locales - Optional locales. * @param options - Optional date format options. * @returns The formatted date string. */ export function formatDate( date: string | undefined, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ): string { if (!date) { return ''; } const d = new Date(date); if (!isValidDate(d)) { return ''; } d.setUTCHours(0, 0, 0, 0); return d.toLocaleDateString(locales, { timeZone: 'UTC', ...options }); } /** * Formats a FHIR time string as a human readable string. * Handles missing values and invalid dates. * @param time - The date to format. * @param locales - Optional locales. * @param options - Optional time format options. * @returns The formatted time string. */ export function formatTime( time: string | undefined, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ): string { if (!time) { return ''; } const d = new Date('2000-01-01T' + time + 'Z'); if (!isValidDate(d)) { return ''; } return d.toLocaleTimeString(locales, options); } /** * Formats a FHIR time string as a human readable string. * The viewer's timezone does not affect the display. * @param time - The time to format, a string like `HH:mm` or `HH:mm:ss` * @param locales - Optional locales. * @param options - Optional time format options. * @returns The formatted time string. */ export function formatWallTime( time: string | undefined, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ): string { if (!time) { return ''; } const [hours = 0, minutes = 0, seconds = 0] = time.split(':').map(Number); // Create a UTC date with the given time, and format the result with respect // to the UTC timezone so that the time is displayed correctly regardless of // the viewer's timezone. const date = new Date(Date.UTC(1970, 0, 1, hours, minutes, seconds)); if (!isValidDate(date)) { return ''; } return new Intl.DateTimeFormat(locales, { hour: 'numeric', minute: '2-digit', second: seconds === 0 ? undefined : 'numeric', ...options, timeZone: 'UTC', }).format(date); } /** * Formats a FHIR dateTime string as a human readable string. * Handles missing values and invalid dates. * @param dateTime - The dateTime to format. * @param locales - Optional locales. * @param options - Optional dateTime format options. * @returns The formatted dateTime string. */ export function formatDateTime( dateTime: string | undefined, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ): string { if (!dateTime) { return ''; } const d = new Date(dateTime); if (!isValidDate(d)) { return ''; } return d.toLocaleString(locales, options); } /** * Formats a FHIR Period as a human readable string. * @param period - The period to format. * @param locales - Optional locales. * @param options - Optional period format options. * @returns The formatted period string. */ export function formatPeriod( period: Period | undefined, locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions ): string { if (!period || (!period.start && !period.end)) { return ''; } return formatDateTime(period.start, locales, options) + ' - ' + formatDateTime(period.end, locales, options); } const unitAdverbForm: Record<string, string> = { s: 'every second', min: 'every minute', h: 'hourly', d: 'daily', wk: 'weekly', mo: 'monthly', a: 'annually', }; const singularUnits: Record<string, string> = { s: 'second', min: 'minute', h: 'hour', d: 'day', wk: 'week', mo: 'month', a: 'year', }; const pluralUnits: Record<string, string> = { s: 'seconds', min: 'minutes', h: 'hours', d: 'days', wk: 'weeks', mo: 'months', a: 'years', }; /** * Formats a FHIR Timing as a human readable string. * @param timing - The timing to format. * @returns The formatted timing string. */ export function formatTiming(timing: Timing | undefined): string { if (!timing) { return ''; } const builder: string[] = []; formatTimingRepeat(builder, timing.repeat); if (timing.event) { builder.push(timing.event.map((d) => formatDateTime(d)).join(', ')); } return capitalize(builder.join(' ').trim()); } /** * Formats a FHIR Timing repeat element as a human readable string. * @param builder - The output string builder. * @param repeat - The timing repeat element. */ function formatTimingRepeat(builder: string[], repeat: TimingRepeat | undefined): void { if (!repeat?.periodUnit) { // Period unit is the only required field return; } const frequency = repeat.frequency ?? 1; const period = repeat.period ?? 1; const periodUnit = repeat.periodUnit; if (frequency === 1 && period === 1) { builder.push(unitAdverbForm[periodUnit]); } else { if (frequency === 1) { builder.push('once'); } else { builder.push(frequency + ' times'); } if (period === 1) { builder.push('per ' + singularUnits[periodUnit]); } else { builder.push('per ' + period + ' ' + pluralUnits[periodUnit]); } } if (repeat.dayOfWeek) { builder.push('on ' + repeat.dayOfWeek.map(capitalize).join(', ')); } if (repeat.timeOfDay) { builder.push('at ' + repeat.timeOfDay.map((t) => formatWallTime(t)).join(', ')); } } /** * Returns a human-readable string for a FHIR Range datatype, taking into account one-sided ranges * @param range - A FHIR Range element * @param precision - Number of decimal places to display in the rendered quantity values * @param exclusive - If true, one-sided ranges will be rendered with the `>` or `<` bounds rather than `>=` or `<=` * @returns A human-readable string representation of the Range */ export function formatRange(range: Range | undefined, precision?: number, exclusive = false): string { if (exclusive && precision === undefined) { throw new Error('Precision must be specified for exclusive ranges'); } // Extract high and low range endpoints, explicitly ignoring any comparator // since Range uses SimpleQuantity variants (see http://www.hl7.org/fhir/datatypes.html#Range) const low = range?.low && { ...range.low, comparator: undefined }; const high = range?.high && { ...range.high, comparator: undefined }; if (low?.value === undefined && high?.value === undefined) { return ''; } if (low?.value !== undefined && high?.value === undefined) { // Lower bound only if (exclusive && precision !== undefined) { low.value = preciseDecrement(low.value, precision); return `> ${formatQuantity(low, precision)}`; } return `>= ${formatQuantity(low, precision)}`; } else if (low?.value === undefined && high?.value !== undefined) { // Upper bound only if (exclusive && precision !== undefined) { high.value = preciseIncrement(high.value, precision); return `< ${formatQuantity(high, precision)}`; } return `<= ${formatQuantity(high, precision)}`; } else { // Double-sided range if (low?.unit === high?.unit) { delete low?.unit; // Format like "X - Y units" instead of "X units - Y units" } return `${formatQuantity(low, precision)} - ${formatQuantity(high, precision)}`; } } /** * Returns a human-readable string for a FHIR Quantity datatype, taking into account units and comparators * @param quantity - A FHIR Quantity element * @param precision - Number of decimal places to display in the rendered quantity values * @returns A human-readable string representation of the Quantity */ export function formatQuantity(quantity: Quantity | undefined, precision?: number): string { if (!quantity) { return ''; } const result = []; if (quantity.comparator) { result.push(quantity.comparator); result.push(' '); } if (quantity.value !== undefined) { if (precision !== undefined) { result.push(quantity.value.toFixed(precision)); } else { result.push(quantity.value); } } if (quantity.unit) { if (quantity.unit !== '%' && result.at(-1) !== ' ') { result.push(' '); } result.push(quantity.unit); } return result.join('').trim(); } export function formatMoney(money: Money | undefined): string { if (money?.value === undefined) { return ''; } return money.value.toLocaleString(undefined, { style: 'currency', currency: money.currency ?? 'USD', currencyDisplay: 'narrowSymbol', }); } /** * Formats a CodeableConcept element as a string. * @param codeableConcept - A FHIR CodeableConcept element * @returns The codeable concept as a string. */ export function formatCodeableConcept(codeableConcept: CodeableConcept | undefined): string { if (!codeableConcept) { return ''; } const textStr = ensureString(codeableConcept.text); if (textStr) { return textStr; } if (codeableConcept.coding) { return codeableConcept.coding.map((c) => formatCoding(c)).join(', '); } return ''; } /** * Formats a Coding element as a string. * @param coding - A FHIR Coding element * @param includeCode - If true, includes both the code and display if available * @returns The coding as a string. */ export function formatCoding(coding: Coding | undefined, includeCode?: boolean): string { const display = ensureString(coding?.display); if (display) { const code = includeCode ? ensureString(coding?.code) : undefined; return `${display}${code ? ' (' + code + ')' : ''}`; } return ensureString(coding?.code) ?? ''; } /** * Formats a FHIR Observation resource value as a string. * @param obs - A FHIR Observation resource. * @returns A human-readable string representation of the Observation. */ export function formatObservationValue(obs: Observation | ObservationComponent | undefined): string { if (!obs) { return ''; } const result = []; if (obs.valueQuantity) { result.push(formatQuantity(obs.valueQuantity)); } else if (obs.valueCodeableConcept) { result.push(formatCodeableConcept(obs.valueCodeableConcept)); } else { const valueString = ensureString(obs.valueString); if (valueString) { result.push(valueString); } } if ('component' in obs) { result.push((obs.component as ObservationComponent[]).map((c) => formatObservationValue(c)).join(' / ')); } return result.join(' / ').trim(); } /** * Ensures the input is a string. * While the TypeScript type definitions for FHIR resources are strict, the actual input data can be malformed. * We use this method to protect against runtime errors. * @param input - The input to ensure is a string. * @returns The input as a string, or undefined if not a string. */ function ensureString(input: unknown): string | undefined { return typeof input === 'string' ? input : undefined; } /** * Returns the input number increased by the `n` units of the specified precision * @param a - The input number. * @param precision - The precision in number of digits. * @param n - (default 1) The number of units to add. * @returns The result of the increment. */ function preciseIncrement(a: number, precision: number, n = 1): number { return (toPreciseInteger(a, precision) + n) * Math.pow(10, -precision); } /** * Returns the input number decreased by the `n` units of the specified precision * @param a - The input number. * @param precision - The precision in number of digits. * @param n - (default 1) The number of units to subtract. * @returns The result of the decrement. */ function preciseDecrement(a: number, precision: number, n = 1): number { return (toPreciseInteger(a, precision) - n) * Math.pow(10, -precision); } /** * Returns an integer representation of the number with the given precision. * For example, if precision is 2, then 1.2345 will be returned as 123. * @param a - The number. * @param precision - Optional precision in number of digits. * @returns The integer with the given precision. */ function toPreciseInteger(a: number, precision?: number): number { if (precision === undefined) { return a; } return Math.round(a * Math.pow(10, precision)); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server