/** * Which of the three handles a receptionist just typed. * * THE DESK'S SEARCH ADVERTISES THREE AND SEARCHED TWO. The trigger says "Search name, phone, or date of * birth"; the dialog it opens says "Search for a patient by name or phone number"; and the search itself * had a name branch and a phone branch and nothing else. Measured at the front desk: a patient whose * stored birth date is 1985-04-12 was not found by `1985-04-12` and not found by `04/12/1985`. * * AND IT FAILED IN TWO DIFFERENT WAYS, which is why "date of birth does nothing" is too kind: * `1985-04-12` is digits and hyphens, so the phone test claimed it and searched for the phone * number 19850412 - a real query for a number nobody has. * `04/12/1985` has slashes, so it fell through to the NAME branch and searched for a patient * called "04/12/1985". * Both answered "No patients found", which reads identically to "that patient is not here". * * DATE IS TESTED FIRST, BEFORE PHONE, because the phone test is the looser of the two: it accepts any * run of digits and separators. A date is a shape - four-two-two, or two-two-four with slashes - and a * phone number is not, so asking the specific question first is what keeps `715-441-2920` a phone. * * WHY BOTH FORMATS. The record stores `YYYY-MM-DD` and a desk in the United States types `MM/DD/YYYY`; * the product is US customary by ruling, and a receptionist reading a date off a driving licence types * what is printed there. Refusing one of the two would be a search that works only for whoever wrote it. */ export type PatientSearchHandle = | { kind: 'birthdate'; value: string } | { kind: 'phone'; value: string } | { kind: 'name'; value: string }; /** `YYYY-MM-DD`, the form the record stores. */ const ISO = /^(\d{4})-(\d{1,2})-(\d{1,2})$/; /** `M/D/YYYY` and `MM/DD/YYYY`, the form a US front desk types. */ const US = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; const pad = (value: string): string => (value.length === 1 ? `0${value}` : value); /** * A real calendar date, or nothing. * * IT CHECKS THE DAY AGAINST THE MONTH rather than just the ranges, so `02/30/1985` is not a date and * falls through to be searched as typed. A search that silently corrects a typo into a different date * would answer confidently about the wrong person, which at a front desk is worse than finding nobody. */ const asIsoDate = (year: string, month: string, day: string): string | undefined => { const y = Number(year); const m = Number(month); const d = Number(day); if (m < 1 || m > 12 || d < 1 || d > 31) return undefined; const parsed = new Date(Date.UTC(y, m - 1, d)); if (parsed.getUTCFullYear() !== y || parsed.getUTCMonth() !== m - 1 || parsed.getUTCDate() !== d) { return undefined; } return `${year}-${pad(month)}-${pad(day)}`; }; /** The stored `YYYY-MM-DD` for a term that is a date, or undefined for anything else. */ export const parseSearchBirthDate = (term: string): string | undefined => { const trimmed = term.trim(); const iso = ISO.exec(trimmed); if (iso) return asIsoDate(iso[1], iso[2], iso[3]); const us = US.exec(trimmed); if (us) return asIsoDate(us[3], us[1], us[2]); return undefined; }; /** * Which handle to search on, in the order that makes each test answerable. * * The phone rule is unchanged from what it replaces: only digits and phone punctuation, and at least * three digits. It simply no longer sees the dates that used to reach it. */ export const patientSearchHandle = (searchTerm: string): PatientSearchHandle => { const trimmed = searchTerm.trim(); const birthDate = parseSearchBirthDate(trimmed); if (birthDate) return { kind: 'birthdate', value: birthDate }; const phoneDigits = trimmed.replace(/\D/g, ''); if (/^[\d\s().+-]+$/.test(trimmed) && phoneDigits.length >= 3) { return { kind: 'phone', value: phoneDigits }; } return { kind: 'name', value: searchTerm }; }; /** * The FHIR search params for one typed term. A name term goes one word per `name` param: FHIR matches * each against any single name part and repeated params must all match, so `Patient One` finds Patient One. */ export const patientSearchParams = (searchTerm: string): [string, string][] => { const handle = patientSearchHandle(searchTerm); if (handle.kind !== 'name') return [[handle.kind, handle.value]]; const words = handle.value.trim().split(/\s+/).filter(Boolean); return (words.length ? words : [handle.value]).map((word): [string, string] => ['name', word]); }; /** * Other readings of a multi-word name, asked only when the word-per-param search finds nobody. One name can * hold a space: registration takes the first name in one box, so "ZZ Desk" is stored as ONE given name, and * a family name can be "Van Buren". The store matches each param against the start of a whole name part, so * "Desk" alone matches nothing. These keep adjacent words together, in order: the whole term, all but the last * word as one name, and all but the first. A reading the first search already asked is left out. */ export const patientSearchFallbacks = (searchTerm: string): [string, string][][] => { const handle = patientSearchHandle(searchTerm); if (handle.kind !== 'name') return []; const words = handle.value.trim().split(/\s+/).filter(Boolean); if (words.length < 2) return []; const readings = [ [words.join(' ')], [words.slice(0, -1).join(' '), words[words.length - 1]], [words[0], words.slice(1).join(' ')], ]; const asked = new Set(); return readings .filter((parts) => parts.length < words.length) .filter((parts) => { const key = parts.join('\n'); if (asked.has(key)) return false; asked.add(key); return true; }) .map((parts) => parts.map((part): [string, string] => ['name', part])); };