// / My History -- collapse raw audit rows into what a person actually wants to read. // // Individual reads are noise; accesses and changes are signal (Spencer, 2026-07-29). Reads by one actor // inside a session window become a single "accessed your record" row that expands to the detail; every // CHANGE stays its own row, always. // // PURE -- no React, no HTTP -- so the rule is testable without rendering. export interface MyHistoryEvent { id: string | undefined; recorded: string | undefined; action: string | undefined; resourceType: string | undefined; resourceId: string | undefined; actorReference: string | undefined; actorName: string; // The organization NAME, resolved server-side. Never its id - a uuid on a patient's screen is // noise, and the server omits it rather than falling back to one. organizationName: string | undefined; // The practice as an identity, for grouping only; a screen shows organizationName. 'none' is a proven // act under no practice; 'unknown', or a server that sends neither, is never the same practice as anything. organizationAssociation?: 'organization' | 'none' | 'unknown'; organizationReference?: string; outcome: string | undefined; // S2: the label the SERVER chose for this event. Rendered verbatim, never rewritten here. entityName?: string | undefined; // S2: which session a sign-in / sign-out belongs to, so the two ends pair into one row. Optional - // a row without it stays unpaired rather than being dropped. sessionId?: string | undefined; // The version the write produced, so this row pairs with exactly that version. versionId?: string | undefined; //: how long a proxy (act-as) session lasted, in MILLISECONDS, sent as a real number by the // server rather than buried in the human description - so this can be sorted or reformatted without // parsing prose. // // `undefined` means NOT APPLICABLE (virtually every row) and must never be rendered as 0. A // formatter has to distinguish the two; a `?? 0` here would put "0s" on every ordinary row in the // feed. Zero itself IS a legitimate value - a session opened and closed within one millisecond. durationMs?: number | undefined; // Which end of a helper's act-as session this row records, decided by the server: start, exit or timeout. // Absent on every other row, and on a session row the server could not classify, which then reads as an // ordinary row. An older server that does not send it changes nothing. accessSession?: 'start' | 'exit' | 'timeout' | undefined; // Set by the server when the read showed record content to the reader; today only "assistant". disclosure?: string; // Whether this change may be opened to show WHAT changed. Decided SERVER-side and never // recomputed here: expanding fetches the resource's _history, a second read path the feed's own // redaction filter does not cover, so which types are safe is a confidentiality call. Absent means // NOT expandable - an older server that does not send it must not have the expansion assumed open. expandable?: boolean | undefined; } export interface HistoryRow { kind: 'access' | 'change' | 'session' | 'visit' | 'reminder' | 'proxySession' | 'joinAttempt' | 'conference'; actorReference: string | undefined; actorName: string; // The organization NAME, resolved server-side. Never its id - a uuid on a patient's screen is // noise, and the server omits it rather than falling back to one. organizationName: string | undefined; organizationAssociation?: MyHistoryEvent['organizationAssociation']; organizationReference?: string; from: string | undefined; to: string | undefined; count: number; detail: MyHistoryEvent[]; // S2: carried up so the component renders the server's exact wording. See SESSION_LABELS. entityName?: string | undefined; // S2: the session these ends belong to. Used to pair a sign-in with its own sign-out. sessionId?: string | undefined; //: how long a proxy (act-as) session lasted, in milliseconds. Undefined = NOT APPLICABLE, // which is nearly every row - never render it as zero. See MyHistoryEvent.durationMs. durationMs?: number | undefined; // Which end of a helper's act-as session this row is, as the server classified it; only on a proxySession row. accessSession?: MyHistoryEvent['accessSession']; // NOTE: `expandable` is deliberately NOT lifted onto the row. It is read off the underlying event // where the server put it — copying a confidentiality flag would create a second place for it to be // wrong, and the collapsed row is a display shape, not an authority. } // S2 - THE CLIENT MAY RECOGNISE THIS VOCABULARY; IT MAY NOT INVENT OR REWRITE IT. // // "Signed out" means the user explicitly ended the session. "Session ended" means a TTL expired and // nobody ever saw them leave - Cognito has no logout trigger, so that is the only signal a closed tab // produces. Only the server knows which happened, so it picks the words and this module passes them // through untouched. Relabelling an inferred end as a definitive sign-out would state as fact // something nobody observed, on the one feed whose entire value is that it does not overstate. // // This set is used ONLY to classify a row, never to produce display text. It has to agree with // utils/patientSessionAudit.ts in the api service; both sides are pinned by tests. // // Classifying on the action alone would be wrong: EXECUTE is not exclusive to sessions (BillingExport // and the care-view legal export both use it, and rbac maps any unrecognised HTTP method to it), so a // billing export would render as a login. const SESSION_LABELS = new Set(['Signed in', 'Signed out', 'Session ended']); const isSession = (event: MyHistoryEvent): boolean => event.action === 'E' && Boolean(event.entityName) && SESSION_LABELS.has(event.entityName as string); // A visit is a real-world event, not an edit to a record. Encounter start/end are stamped server-side // with these labels (S1 Task 5, Encounter/put.ts), and without this they fell through to the CHANGE // branch and rendered as "You updated a visit" - wrong, and the least useful possible description of // the row a patient is most likely to recognise ("was I even there that day?"). // // Keyed on the LABEL, not the resource type: an ordinary Encounter edit that is not a start or an end // is a genuine change and must stay one. Same discriminator discipline as sessions. // 'Visit reopened' was stamped server-side and NOT added here, so every reopen fell through to the // CHANGE branch and rendered as "changed the patient's Encounter" - indistinguishable from any other // edit, which is the exact thing the label was introduced to make visible. This set is the client half // of a server contract: a label added there and not here is not a smaller bug than no label at all. const VISIT_LABELS = new Set(['Visit started', 'Visit ended', 'Visit reopened', 'Video visit started', 'Video visit ended']); // Clinicians conferring about the patient: shown so the patient knows it took place, never as a visit and never // with who else was in it or what was said. A refused start carries the same label with a failed outcome. const isConference = (event: MyHistoryEvent): boolean => event.entityName === 'Collaboration team conference started'; const isVisit = (event: MyHistoryEvent): boolean => Boolean(event.entityName) && VISIT_LABELS.has(event.entityName as string); // The reminder sweep stamps these on the Appointment as EXECUTE. As changes they read "changed an // appointment", or "tried to change ... did not go through" for a withheld one, and nothing changed it. // Client half of the labels in the api's AppointmentReminderSweep, including a reminder its quiet hours held back. const REMINDER_LABELS = new Set([ 'Appointment reminder sent', 'Appointment reminder not sent', 'Appointment reminder failed', 'Appointment reminder suppressed by quiet hours', ]); const isReminder = (event: MyHistoryEvent): boolean => event.action === 'E' && Boolean(event.entityName) && REMINDER_LABELS.has(event.entityName as string); // A starting value, not a derived constant -- kept in one place so it can be tuned after real use. // A sign-in, visit or reminder row whose name the server wrote for people to read; any other entity // name is an audit label ("Encounter ") and is never shown. export const hasReadableEntityName = (event: MyHistoryEvent): boolean => isSession(event) || isVisit(event) || isConference(event) || isReminder(event); export const ACCESS_SESSION_GAP_MS = 30 * 60 * 1000; // Video JOIN/LEAVE/VIEW fire as EXECUTE. Treating them as changes splits one sitting into a row per // heartbeat, so the thirty-minute access collapse never runs. They are presence, not edits. const VIDEO_TELEMETRY_LABELS = new Set([ 'Joined the video visit', 'Left the video visit', 'Viewed the video visit', 'Removed from the video visit by the host', 'Collaboration team invited to the visit', ]); // A refused join is an attempt, not presence: only the join label on a video session with a failure outcome FHIR // defines. Other video presence that failed keeps its current reading; it is not a join. const FAILED_OUTCOMES = new Set(['4', '8', '12']); /** The server recorded the act as refused or failed, so it did not happen. */ export const didNotHappen = (outcome?: string): boolean => outcome !== undefined && FAILED_OUTCOMES.has(outcome); const isRefusedJoin = (event: MyHistoryEvent): boolean => event.action === 'E' && event.resourceType === 'VideoSession' && event.entityName === 'Joined the video visit' && event.outcome !== undefined && FAILED_OUTCOMES.has(event.outcome); const isRead = (event: MyHistoryEvent): boolean => event.action === 'R' || Boolean(event.entityName && VIDEO_TELEMETRY_LABELS.has(event.entityName)); // Only the end of a helper's session the server classified has a length; a start, a read or any other row has none, // whatever detail it carries. const endLength = (event: MyHistoryEvent, kind: HistoryRow['kind']): number | undefined => kind === 'proxySession' && (event.accessSession === 'exit' || event.accessSession === 'timeout') ? event.durationMs : undefined; const rowOf = (event: MyHistoryEvent, kind: HistoryRow['kind']): HistoryRow => ({ kind, actorReference: event.actorReference, actorName: event.actorName, organizationName: event.organizationName, organizationAssociation: event.organizationAssociation, organizationReference: event.organizationReference, from: event.recorded, to: event.recorded, count: 1, detail: [event], entityName: event.entityName, sessionId: event.sessionId, durationMs: endLength(event, kind), accessSession: kind === 'proxySession' ? event.accessSession : undefined, }); type PracticeOf = Pick; const samePractice = (a: PracticeOf, b: PracticeOf): boolean => a.organizationReference || b.organizationReference ? a.organizationReference === b.organizationReference : a.organizationAssociation === 'none' && b.organizationAssociation === 'none'; const millis = (iso: string | undefined): number => { const value = new Date(iso ?? '').getTime(); return Number.isFinite(value) ? value : 0; }; /** joinAttempts: show a refused video join as its own attempt row (My History); off, the collapse is unchanged. */ export function collapseHistory(events: MyHistoryEvent[], options: { joinAttempts?: boolean } = {}): HistoryRow[] { const rows: HistoryRow[] = []; for (const event of events) { if (options.joinAttempts && isRefusedJoin(event)) { rows.push(rowOf(event, 'joinAttempt')); continue; } // S2 sessions are checked BEFORE the read/change split. Two SEPARATE logins are always two rows - // hiding one inside another would conceal exactly what someone opens this feed to notice - but // the two ENDS of ONE session are a single event and are paired below. if (isSession(event)) { // Design §4 row type 4 is "You signed in 9:04am signed out 9:26am": ONE row. We shipped // two, and because a session can run 12 hours the halves land far apart in a // reverse-chronological feed with everything in between, so a patient cannot pair them by // eye. Paired here on the server-supplied sessionId. // // Events arrive newest-first, so the END is seen before its sign-in: on meeting the sign-in // we extend the already-emitted end row BACKWARDS to the true start. The row therefore keeps // the END's wording, which is the half carrying the observed-vs-inferred distinction // ("Signed out" vs "Session ended") - the one thing the client must not decide for itself. const openEnd = event.sessionId ? rows.find((candidate) => candidate.kind === 'session' && candidate.sessionId === event.sessionId) : undefined; if (openEnd) { openEnd.from = event.recorded; openEnd.count += 1; openEnd.detail.push(event); continue; } rows.push(rowOf(event, 'session')); continue; } // Each start, exit or timeout of a helper's act-as session the server classified is its own row: never folded // into the helper's reads, never paired by a guessed session, never given a length it did not carry. if (event.accessSession) { rows.push(rowOf(event, 'proxySession')); continue; } // Checked before the read/change split for the same reason sessions are: a visit is its own // kind of event and must never be collapsed into a neighbouring row. if (isConference(event)) { rows.push(rowOf(event, 'conference')); continue; } if (isVisit(event)) { rows.push(rowOf(event, 'visit')); continue; } if (isReminder(event)) { rows.push(rowOf(event, 'reminder')); continue; } if (!isRead(event)) { // Changes are never collapsed -- itemised, always. rows.push(rowOf(event, 'change')); continue; } // THE ROW THIS EXTENDS IS THIS ACTOR'S MOST RECENT ACCESS ROW, WHICH IS NOT NECESSARILY THE // LAST ROW EMITTED. // // Only the last row used to be considered, and that is not the stated rule - reads by ONE // actor within thirty minutes collapse. Two people working the same record at the same time // interleave, so each read's previous row belonged to the other actor and NOTHING ever // collapsed: measured on staging, a practitioner and a receptionist alternating for // twenty-six minutes produced fifteen rows, most of them reading "1 thing". A row every // thirty seconds is precisely the shape this feed exists to avoid, because a patient reads it // to SPOT the access that should not have happened. // // The reason behind the old rule is still honoured, and it is the reason the scan stops // rather than filtering: SCANNING PAST A CHANGE WOULD HIDE IT inside a collapsed access row, // and a change is the single most important row in this feed. So the scan walks back over // other actors' ACCESS rows, and halts at a CHANGE. Nothing is ever merged across one. // // A SESSION OR A VISIT ROW DOES NOT STOP IT, and that is a correction rather than a widening. // The scan used to halt at any row that was not an access, which swept both in on the change's // reasoning without their having any of their own - and they do not share it, because neither // is a change to the record and neither can be hidden by this: a session row and a visit row // are emitted as their own rows and stay exactly where they were emitted whatever the reads // around them do. // // What it cost was the rule itself. `patient-my-history-design.md` states it so it cannot be // read two ways: *"Reads collapse into one session row when they share the same actor and the // gap is under half an hour."* Actor (at one practice) and gap, and nothing else. The patient's own sign-ins and // sign-outs land in the feed interleaved with everybody else's reads, so a staff member reading // steadily through a morning was cut into a row per read by rows belonging to somebody else // entirely - the same one-thing-per-row shape the actor scan was written to end, arriving by a // different door. let target: HistoryRow | undefined; for (let index = rows.length - 1; index >= 0; index -= 1) { const candidate = rows[index]; if (candidate.kind === 'change') break; // A helper's own session boundary closes their sitting: their reads on either side are two sittings. if (candidate.kind === 'proxySession' && candidate.actorReference === event.actorReference) break; if (candidate.kind !== 'access') continue; // An unidentified actor is not evidence of ONE actor. Merging two unknowns would invent a // session that may never have happened, and this feed has to be trustworthy about who did // what -- so an unresolvable actor always starts its own row. // One person can work at two practices; the row names one, so it holds that practice's reads only. if ( Boolean(event.actorReference) && candidate.actorReference === event.actorReference && samePractice(candidate, event) ) { target = candidate; break; } } // Measured against `to`, which holds the row's OLDEST member so far: events arrive // newest-first, so that is the nearest one in time to the event being placed. The window is a // gap between consecutive reads, never a cap on the row's total span - somebody reading // steadily for two hours is one sitting, and thirteen rows would not describe it better. const withinWindow = target && Math.abs(millis(event.recorded) - millis(target.to)) < ACCESS_SESSION_GAP_MS; if (withinWindow && target) { target.to = event.recorded; target.count += 1; target.detail.push(event); continue; } rows.push(rowOf(event, 'access')); } return rows; }