import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import type { RecordThing } from '../utils/recordThingSearch'; import type { RecordThingTally } from '../utils/recordThingTally'; /** * What this practitioner adds to records most often. * * SPENCER RULED IT SERVER-SIDE, 2026-09-04, over the cheaper per-browser version. The case the whole * surface is built around is a practitioner writing up in the evening - often not at the desk they saw * the patient from - and a list kept in one browser is blank exactly then. * * IT IS KEYED ON THE CALLER, NEVER ON A PATIENT, and it holds no patient data: a coding, a display and * a count. It is the clinician's own working set, in the sense a paper prescriber's most-worn page is. * * THE ROUTE LIVES ON THE BASE API, beside `record-proposals` and for the same reason - it reads and * writes no FHIR resource. * * A PROVIDER WITH A TOLERANT ACCESSOR, which is load-bearing rather than convention. The note editor's * rule, learned on the scribe and again on the proposal service: an add-on must never be able to take * down note writing. Absent the provider there is simply no shortcut group. */ /** * THE ROUTE IS ON THE BASE API STACK, NOT ON fhir-base. The two names are one word apart and the * hostnames are not: `apiUrl` is `api.staging...`, `fhirBaseApi` is `fhir-base.staging...`. * * `/R4/my-frequent-record-items` is declared in `templates/api.yaml` and in no other template. Pointed * at fhir-base it was refused at PREFLIGHT - the host serves no such route, so the browser never got * as far as sending it. Watched on staging 2026-09-08: `net::ERR_FAILED`, with every header the request * carried already on the allow-list, which is what says the headers were never the problem. * * IT FAILED SILENTLY IN BOTH DIRECTIONS, which is why it survived. The read is caught and returns * nothing, and "What you add most" is deliberately drawn as ABSENT rather than as an empty heading - so * a dead read and a clinician with no history render identically. The write is fire-and-forget and * swallows its own failure, so nothing was ever tallied either. The feature has never worked and * nothing anywhere said so. * * Moving a route between stacks moves its hostname. That is the trap this is an instance of. */ const FREQUENT_ITEMS_API_URL = environment.apiUrl; /** * WHAT THE SERVER ACTUALLY SENDS, which is not what a RecordThingTally is. It sends `display` and * `code`; it sends no `id` and no `label`. Declaring the response as RecordThingTally[] told * TypeScript otherwise at the boundary, and the shortcut row drew an empty label for nine days * without a single type error. Describe the wire, then map to the domain. */ export interface FrequentRecordItemWire { kind: RecordThingTally['kind']; code?: string; system?: string; display?: string; count?: number; lastAddedAt?: string; } export interface FrequentRecordItemsResult { items: FrequentRecordItemWire[]; } export interface FrequentRecordItemsApi { /** What this clinician adds most. Answers an empty list rather than failing. */ listFrequent: () => Promise; /** Record that one more item of this thing was filed. Never throws. */ recordFiled: (thing: RecordThing) => Promise; } const FrequentRecordItemsContext = createContext(undefined); /** * The accessor, tolerant of an absent provider. * * A DEGRADATION RATHER THAN A BREAK: the alternative is a missing provider taking down the surface a * clinician writes notes on, in order to add a list of shortcuts to it. */ export const useOptionalFrequentRecordItemsService = (): FrequentRecordItemsApi | undefined => useContext(FrequentRecordItemsContext); export const FrequentRecordItemsProvider = ({ children }: { children: ReactNode }) => { const { get, post } = useHttpService(); /** * NEVER THROWS, AND THE EMPTY LIST IS HONEST. A clinician with no history and a lookup that failed * see the same thing - the group simply is not drawn - and that is the right outcome for both: it * is a shortcut, and its absence costs nothing but the search box beneath it. */ const listFrequent = useCallback(async (): Promise => { try { const answer = await get( `${FREQUENT_ITEMS_API_URL}/my-frequent-record-items`, ); // A response that is not a list is not an empty result - it is a shape we do not // understand, and treating it as "you have added nothing" is the same erasure one layer on. const sent = Array.isArray(answer?.items) ? answer.items : []; // MAP, NEVER CAST. Same rule as thingFromFiledValues: nothing uncoded and nothing // unlabelled is offered back, because picking an unlabelled shortcut files a bare code // where the clinician meant a word. return sent.flatMap((item): RecordThingTally[] => { const label = item?.display?.trim(); if (!item?.code || !label) return []; return [{ id: item.code, label, kind: item.kind, code: item.code, ...(item.system ? { system: item.system } : {}), count: typeof item.count === 'number' ? item.count : 0, lastAddedAt: item.lastAddedAt ?? '', }]; }); } catch (error) { console.error('Could not read what this clinician adds most', error); return []; } }, [get]); /** * RECORDING IS NEVER ALLOWED TO FAIL THE FILE, and this is the same judgement as the chart refresh * beside it. The resource is already on the record; a rejection here would tell the clinician their * item was not added, which is false, and they would file it a second time. A shortcut list that is * one entry out of date costs nothing at all. */ const recordFiled = useCallback(async (thing: RecordThing): Promise => { // Nothing uncoded is ever recorded - it could not be offered back without filing words rather // than a coded item, which is the failure the coded search exists to prevent. if (!thing.code) return; try { await post(`${FREQUENT_ITEMS_API_URL}/my-frequent-record-items`, { kind: thing.kind, code: thing.code, display: thing.label, ...(thing.system ? { system: thing.system } : {}), }); } catch (error) { console.error('Filed, but could not record it as one this clinician adds often', error); } }, [post]); const api = useMemo(() => ({ listFrequent, recordFiled }), [listFrequent, recordFiled]); return ( {children} ); };