import { dayKeyOf, type RecordEvent, type RecordEventKind } from "./patientRecordStream"; /** * FACETS INSTEAD OF FOLDERS -, step 3. * * THE RULING THAT SHAPED THIS, and it is a layout constraint with a data cause. Spencer, on the row * of selects the first drawing had: *"all of these selects in a row won't work in the real world, they * already run off the screen."* **The facet count is fixed and the width is not, so that row cannot be * made to fit.** ONE control opens all of them, and only what the reader CHOSE takes up room afterwards. * * THE OPTIONS ARE THE RECORD. Nothing here is authored into a list - every practice used, every * clinician seen, every condition somebody recorded is derived from the events themselves. That is what * lets every option carry a count, and it is why a facet with nothing to choose between does not appear. * * `About` IS THE ONE NO PORTAL DOES and it is the reason to build this: *everything about my * diabetes*, across every practice and every year. * * SHAPE ONLY - no clock is read here. `todayKey` is passed in, because a module that asks the machine * what day it is cannot be tested on the boundaries where date rules break. */ export type FacetKey = "when" | "where" | "who" | "what" | "about"; export interface FacetOption { /** What goes into `RecordFilters`. */ value: string; label: string; /** How many things the reader would be left with. Truthful, or it is worse than no number. */ count: number; } export interface Facet { key: FacetKey; /** "Where" - the fixed left column of the facet index. */ label: string; /** What the row reads when nothing is chosen: "All practices". */ anything: string; /** What the row reads now - the chosen option's label, or `anything`. */ current: string; options: FacetOption[]; /** The noun the selects search box uses "Find a condition", never "Find a about". */ find?: string; } export interface RecordFilters { /** Inclusive `yyyy-mm-dd` bounds. Either may stand alone: "since March" is a real question. */ from?: string; to?: string; /** Which quick answer produced the range, so the chip can read "This year" rather than two dates. */ quick?: string; where?: string; who?: string; what?: RecordEventKind; about?: string; /** * A READABLE NAME FOR `about`, and ONLY ever a display fallback. * * The health summary links a condition straight into the stream by its id. When the record * holds nothing about that condition the facet has no vocabulary for it, and the chip would * then read a raw UUID - on the one screen whose whole promise is answering in the reader's * own words. It never participates in matching: the id is what an event carries. */ aboutLabel?: string; /** The name of the practice `where` is set to, for when the record holds nothing from it. Display only, like `aboutLabel`. */ whereLabel?: string; } export const NO_FILTERS: RecordFilters = {}; /** * THE UNDATED EVENT IS THE WHOLE REASON THIS IS ITS OWN FUNCTION. * * A record with no date on it is still part of the record. It is invisible to a from-to range by * definition, and the tempting `dayKey >= from` comparison against `undefined` is `false` - so it * silently vanishes the moment anyone touches the When facet, and reappears when they clear it. A * patient reading that concludes the record changed. * It is EXCLUDED by an explicit rule, only ever when a bound is actually set, and never by accident. */ const withinRange = (event: RecordEvent, from?: string, to?: string): boolean => { if (!from && !to) return true; const day = dayKeyOf(event.when); if (!day) return false; if (from && day < from) return false; if (to && day > to) return false; return true; }; /** Does this event survive every filter EXCEPT the one named? The basis of an honest count. */ const matchesExcept = (event: RecordEvent, filters: RecordFilters, except?: FacetKey): boolean => { if (except !== "when" && !withinRange(event, filters.from, filters.to)) return false; if (except !== "where" && filters.where && event.organizationId !== filters.where) return false; if (except !== "who" && filters.who && event.clinicianId !== filters.who) return false; if (except !== "what" && filters.what && event.kind !== filters.what) return false; if (except !== "about" && filters.about && !(event.about ?? []).some((s) => s.id === filters.about)) { return false; } return true; }; export const applyRecordFilters = ( events: readonly RecordEvent[], filters: RecordFilters, ): RecordEvent[] => events.filter((event) => matchesExcept(event, filters)); /** How many facets are set - the number on the Filters button. When counts as ONE however it was set. */ export const activeFilterCount = (filters: RecordFilters): number => [ Boolean(filters.from || filters.to), Boolean(filters.where), Boolean(filters.who), Boolean(filters.what), Boolean(filters.about), ].filter(Boolean).length; const KIND_LABELS: Record = { visit: "Visits", care: "Courses of care", results: "Results", prescription: "Prescriptions", imaging: "Imaging", }; /** * The options for one facet, counted against everything the OTHER facets already allow. * * COUNTING AGAINST THE UNFILTERED RECORD IS THE WRONG NUMBER and it is the easy mistake. A reader who * has already picked *This year* and opens `Where` is asking *"how many THIS YEAR at each practice"*. * Showing the all-time count promises rows that are not going to be there, and the apply button's * *"Show 23 things"* would then disagree with the list it produced. */ const optionsFor = ( events: readonly RecordEvent[], filters: RecordFilters, key: FacetKey, read: (event: RecordEvent) => { value: string; label: string }[], ): FacetOption[] => { const tally = new Map(); for (const event of events) { if (!matchesExcept(event, filters, key)) continue; for (const { value, label } of read(event)) { const existing = tally.get(value); if (existing) existing.count += 1; else tally.set(value, { value, label, count: 1 }); } } // Commonest first, then alphabetically. A patient scanning for their own practice finds it near the // top because it is where most of their record came from - and ties do not shuffle between renders. return [...tally.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); }; /** What an event contributes to one facet. A visit is one practice but can be about two conditions. */ type FacetReader = (event: RecordEvent) => { value: string; label: string }[]; interface FacetDefinition { key: Exclude; label: string; anything: string; read: FacetReader; /** * An optional last word on whether a tallied option is worth OFFERING. Only `About` uses it * for `Where`, `Who` and `What` a count of one is genuinely useful (one visit at a practice you * went to once). It runs after the counts, because "appears more than once" is a fact about the * tally rather than about any single event. */ keep?: (option: FacetOption, event: RecordEvent) => boolean; /** The word the selects own search box uses "Find a condition", never "Find a about". */ find?: string; /** * DOES THIS FACET PARTITION THE RECORD? It changes what "nothing to choose between" MEANS. * * `Where`, `Who` and `What` each put every event in exactly one bucket with a single option, * picking it filters NOTHING and the facet is noise. That is why it is not offered. * `About` does NOT partition most events have no subject at all. With one condition among * nineteen events, picking it narrows nineteen rows to three, which is the whole point of the facet * and the one thing no other portal does. * Judging both by the same `> 1` rule hid a real condition behind a rule written for practices. */ partitions?: boolean; } const KIND_READER: FacetReader = (event) => [{ value: event.kind, label: KIND_LABELS[event.kind] }]; const DEFINITIONS: FacetDefinition[] = [ { key: "where", label: "Where", anything: "All practices", find: "practice", read: (event) => event.organizationId && event.organizationName ? [{ value: event.organizationId, label: event.organizationName }] : [], }, { key: "who", label: "Who", anything: "All clinicians", find: "clinician", read: (event) => event.clinicianId && event.clinicianName ? [{ value: event.clinicianId, label: event.clinicianName }] : [], }, { key: "what", label: "What", anything: "Everything", read: KIND_READER, find: "kind" }, { key: "about", label: "About", anything: "Anything", find: "condition", partitions: false, read: (event) => (event.about ?? []).map((subject) => ({ value: subject.id, label: subject.label })), /** * AN UNCODED SUBJECT HAS TO EARN ITS PLACE BY APPEARING MORE THAN ONCE. * * The deck promises these options are *"a condition somebody recorded"*. A free-text reason is * not one walked 2026-08-26, this facet filled with single visits: *"Video visit * meeting window layout."*, *"Bad cough"*, *"ZZTEST collapse ladder UI test"*. * An option matching ONE event is not a facet; picking it returns the row already on screen. * A CODED condition is always offered even at a count of one, because it is a real thing about * the patient and the next event about it will join it. */ keep: (option, event) => option.count > 1 || (event.about ?? []).some((subject) => subject.id === option.value && subject.coded), }, ]; /** * The facet index, as the sheet renders it. * * A FACET WITH NOTHING TO CHOOSE BETWEEN IS NOT OFFERED. One practice in the record means `Where` * answers a question the reader does not have - the same reasoning as the practice marker on a row. * * BUT OFFERABILITY IS DECIDED AGAINST THE WHOLE RECORD, NEVER AGAINST THE FILTERED ONE, and the first * version of this got it wrong in a way that reads as a broken screen. Judged after filtering, a facet * narrows to one option and then DISAPPEARS - taking the reader's own choice off the screen with it, * with no chip and no row left to undo it, while the filter goes on hiding rows. Three of this * module's tests failed on exactly that, including the one written to catch it. * And a facet the reader HAS SET is never dropped, whatever the record says. * `When` is always offered: a range is not derived from the options, so it has nothing to be empty of. */ export const deriveFacets = (events: readonly RecordEvent[], filters: RecordFilters): Facet[] => { const when: Facet = { key: "when", label: "When", anything: "Any time", current: describeRange(filters) ?? "Any time", options: [], }; const rest = DEFINITIONS.map((definition) => buildFacet(events, filters, definition)).filter( (facet): facet is Facet => Boolean(facet), ); return [when, ...rest]; }; const buildFacet = ( events: readonly RecordEvent[], filters: RecordFilters, { key, label, anything, read, keep, find, partitions = true }: FacetDefinition, ): Facet | undefined => { // Every value this facet could EVER offer, and the words for them - read from the whole record, so // neither the offer nor the label depends on what is filtered at the moment. const vocabulary = new Map(); for (const event of events) { for (const option of read(event)) if (!vocabulary.has(option.value)) vocabulary.set(option.value, option.label); } /** * `keep` IS APPLIED TO THE VOCABULARY TOO, NOT ONLY TO THE DISPLAYED OPTIONS. * * Judged on the raw vocabulary, a facet whose every option `keep` rejects is still OFFERED and * opens onto a panel containing nothing but "Anything". A facet with no options is worse than one * that is not there, because the reader spent a tap finding that out. * Measured against the WHOLE record, so this stays independent of what is filtered right now * the property the offerability rule exists to protect. */ const offerable = keep ? new Set( optionsFor(events, NO_FILTERS, key, read) .filter((option) => events.some((event) => keep(option, event))) .map((option) => option.value), ) : new Set(vocabulary.keys()); const chosen = filters[key]; // A practice the record has no words for reads by its given name, or neutrally; never by its id. const whereName = filters.whereLabel ?? "Selected practice"; // See `partitions`: one option is noise for a facet that buckets every event, and a real choice // for one that does not. if (offerable.size < (partitions ? 2 : 1) && !chosen) return undefined; // A chosen option is never dropped the reader would lose the control for a filter that is still // applied, which is the same defect the offerability rule above exists to prevent. const options = optionsFor(events, filters, key, read).filter( (option) => offerable.has(option.value) || option.value === chosen, ); // The reader's own choice, when another facet has filtered its last event away. Shown at a count // of ZERO rather than omitted: the empty list is then EXPLAINED by the row that caused it, instead of // quietly dropping the one option they are looking for. if (chosen && !options.some((option) => option.value === chosen)) { options.push({ value: chosen, label: vocabulary.get(chosen) ?? (key === "where" ? whereName : chosen), count: 0 }); } return { key, label, anything, find, current: chosen ? (vocabulary.get(chosen) ?? (key === "where" ? whereName : key === "about" ? (filters.aboutLabel ?? chosen) : chosen)) : anything, options, }; }; const DAY_LABEL: Intl.DateTimeFormatOptions = { day: "numeric", month: "long", year: "numeric" }; /** `2026-01-01` "1 January 2026", read from the PARTS so no timezone moves it. */ export const describeDay = (day: string): string => { const [year, month, date] = day.split("-").map(Number); if (!year || !month || !date) return day; return new Date(year, month - 1, date).toLocaleDateString(undefined, DAY_LABEL); }; /** The When facet in words: the quick answer when one produced it, otherwise the bounds themselves. */ export const describeRange = (filters: RecordFilters): string | undefined => { if (filters.quick) return filters.quick; if (filters.from && filters.to) return `${describeDay(filters.from)} – ${describeDay(filters.to)}`; if (filters.from) return `Since ${describeDay(filters.from)}`; if (filters.to) return `Up to ${describeDay(filters.to)}`; return undefined; }; export interface AppliedChip { key: FacetKey; label: string; } /** * WHAT THE READER CHOSE, and nothing else. * * The bar is EMPTY when nothing is filtered, so anything taking up room is something they asked for. * That is the whole trade that let one `Filters` control replace a row of selects. */ export const appliedChips = (facets: readonly Facet[], filters: RecordFilters): AppliedChip[] => { const chips: AppliedChip[] = []; const range = describeRange(filters); if (range) chips.push({ key: "when", label: range }); for (const facet of facets) { if (facet.key === "when") continue; const chosen = filters[facet.key as Exclude]; if (chosen) chips.push({ key: facet.key, label: facet.current }); } return chips; }; /** Clearing one facet. `when` clears all three of its fields, or the chip comes straight back. */ export const withoutFacet = (filters: RecordFilters, key: FacetKey): RecordFilters => { const next = { ...filters }; if (key === "when") { delete next.from; delete next.to; delete next.quick; } else { delete next[key as Exclude]; // Or the name outlives the filter it described, and the next choice inherits it. if (key === "about") delete next.aboutLabel; if (key === "where") delete next.whereLabel; } return next; }; export interface QuickRange { label: string; from?: string; to?: string; } /** * THE QUICK ANSWERS - *Any time - Last 12 months - This year - a named year*, one tap each. * * `todayKey` is a `yyyy-mm-dd` PARAMETER, never `new Date()`. The rules here are all date arithmetic, * which is precisely the code that breaks on a boundary, and a module that reads the machine's clock can * only be tested on the day you happen to run it. * The named year is LAST YEAR, not this one - "This year" already covers this one, and offering the * same span under two names is a control that appears to do nothing. */ export const quickRanges = (todayKey: string): QuickRange[] => { const year = Number(todayKey.slice(0, 4)); const lastYear = year - 1; const twelveMonthsAgo = (() => { const [y, m, d] = todayKey.split("-").map(Number); const at = new Date(y!, m! - 1, d!); at.setFullYear(at.getFullYear() - 1); return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`; })(); return [ { label: "Any time" }, { label: "Last 12 months", from: twelveMonthsAgo, to: todayKey }, { label: "This year", from: `${year}-01-01`, to: todayKey }, { label: `${lastYear}`, from: `${lastYear}-01-01`, to: `${lastYear}-12-31` }, ]; }; export interface CalendarDay { /** `yyyy-mm-dd`. What a tap sets a bound to, and what the range comparisons use. */ key: string; /** The numeral shown. */ day: number; /** A day from the month either side, drawn faint so the grid keeps its shape. */ outside: boolean; } /** * ONE MONTH AS A GRID, weeks starting Monday - the deck's `M T W T F S S`. * * ALWAYS SIX FULL WEEKS. A month that fits in five leaves the sheet a row shorter than the one * before it, so stepping through months makes the apply button walk up and down the screen and land * under the reader's thumb somewhere different each time. * * Built by walking a local `Date`, never by adding days to a string. `new Date(y, m, 0)` is the last * day of the previous month and it handles February and leap years for free - the two cases a * hand-rolled length table gets wrong, and gets wrong only once every four years. */ export const monthGrid = (monthKey: string): CalendarDay[] => { const [year, month] = monthKey.split("-").map(Number); if (!year || !month) return []; const first = new Date(year, month - 1, 1); // getDay() is 0 for Sunday; the grid starts on Monday, so Sunday is six columns in rather than none. const lead = (first.getDay() + 6) % 7; const cells: CalendarDay[] = []; for (let index = 0; index < 42; index += 1) { const at = new Date(year, month - 1, index - lead + 1); cells.push({ key: `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`, day: at.getDate(), outside: at.getMonth() !== month - 1, }); } return cells; }; /** "January 2026" - the grid's own heading, read from the parts so no timezone moves it. */ export const monthTitle = (monthKey: string): string => { const [year, month] = monthKey.split("-").map(Number); if (!year || !month) return monthKey; return new Date(year, month - 1, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" }); }; /** Stepping the grid. Month arithmetic through `Date`, so December steps to January of the next year. */ export const shiftMonth = (monthKey: string, by: number): string => { const [year, month] = monthKey.split("-").map(Number); if (!year || !month) return monthKey; const at = new Date(year, month - 1 + by, 1); return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}`; }; /** * A TAP ON A DAY, and the rule is the one every range picker gets asked for. * * The FIRST tap starts a new range and clears the old one - a reader correcting a mistake taps the * day they meant, and a picker that treats it as a second bound produces a range they did not ask for. * The second tap closes it, and taps in either order: picking the earlier day second is a correction, * not an error, so the bounds swap rather than refusing. * And the quick answer is dropped, because the range is no longer the one it named. */ export const pickDay = (filters: RecordFilters, day: string): RecordFilters => { const { quick: _quick, ...rest } = filters; if (!rest.from || rest.to) return { ...rest, from: day, to: undefined }; return day < rest.from ? { ...rest, from: day, to: rest.from } : { ...rest, to: day }; }; /** * AND THE SEARCH MUST SEARCH CONTENTS. * * Today the record's search box matches SECTION NAMES only, and says so in its own empty state. A * patient typing *metformin* is told nothing matches while the medicine sits one tap away. A rebuild * that keeps that behaviour has rebuilt the lie. * * So it reads the event's own words - what it is called, what it says, who was there, where it * happened, and what it is about. Every field a row shows the reader is a field they can search. */ export const eventMatchesSearch = (event: RecordEvent, query: string): boolean => { const needle = query.trim().toLowerCase(); if (!needle) return true; return [ event.lead, event.title, event.detail, event.organizationName, event.clinicianName, ...(event.about ?? []).map((subject) => subject.label), ] .filter((field): field is string => Boolean(field)) .some((field) => field.toLowerCase().includes(needle)); };