import type { Communication, CommunicationRequest, Reference } from 'fhir/r4'; import type { FormSubmission } from '../../../services/FormSubmissionsService'; import { submissionFormLabel } from '../FormSubmissionsPage/formLabel'; import type { RailMessage } from './FrontDeskRailColumn'; /** * - the rail's two rules: which threads are OWED a reply, and which roles the "On today" filter * may offer. * * MESSAGES ARE THREADS AWAITING OUR REPLY, NOT UNREAD THREADS (Spencer, 2026-08-13), and the two split * cleanly: **membership is operational, weight is personal.** A thread is on the rail because the practice * owes an answer; it is bold because *you* have not read it. * * WHY NOT UNREAD. Under the pooled front-desk model a patient messages **the desk**, so "unread by me" * is the wrong unit - receptionist A opens it, it clears from her board, and B never learns it existed. * And read receipts make a message vanish the moment someone merely OPENS it; at a busy desk opening is * not answering, which is how a patient waits three hours for a reply nobody realises is owed. * A colleague's already-answered thread leaves your board even if you never opened it. A thread you * read but did not answer STAYS - regular weight, still listed, still owed. * * Needs no new storage: it is derivable from the thread already loaded. */ const timeOf = (message: Communication): number => { const value = message.sent || message.meta?.lastUpdated; const ms = value ? new Date(value).getTime() : NaN; return Number.isNaN(ms) ? 0 : ms; }; /** The most recent message in a thread, or undefined when nothing has been said yet. */ export const latestMessageIn = (messages: readonly Communication[]): Communication | undefined => messages.reduce( (latest, message) => (!latest || timeOf(message) >= timeOf(latest) ? message : latest), undefined, ); /** * Does this thread owe a reply from our side? * * The rule in one sentence: **the latest message is theirs and no reply of ours follows it.** Because * "latest" is the last word spoken, those are the same test - which is why this reads the newest message * rather than scanning for a reply after some marker. * * A thread with NO messages falls back to who OPENED it, and that direction matters: a patient who * started a conversation and whose first words have not arrived as a `Communication` yet is still owed an * answer. Treating an empty thread as answered would hide exactly the newest request. */ export const threadAwaitsOurReply = ( request: CommunicationRequest, messages: readonly Communication[], isOurs: (reference?: string) => boolean, ): boolean => { const latest = latestMessageIn(messages); const speaker: Reference | undefined = latest?.sender ?? request.sender; // An unattributable last word counts as OWED. The alternative - assuming we answered - silently // drops a real thread off the only surface that lists it, and the failure directions are not // symmetric: an extra row is noise, a missing one is a patient waiting on nobody. if (!speaker?.reference) return true; return !isOurs(speaker.reference); }; /** * - THE SAME QUESTION, MINUS MY OWN BOOKKEEPING. * * `threadAwaitsOurReply` above states a fact about the CONVERSATION: the last word was theirs. This one * states a fact about MY LIST: that conversation is still mine to answer, and I have not decided it needs * no answer. * * **They are deliberately two functions rather than one with an optional argument.** An optional * predicate would let a surface omit it and silently lose the dismissal - the shape that ships inert - * and it would blur two genuinely different questions. **The front desk asks the first; the practitioner's * owed-work list asks the second.** * * **The dismissal is read off the LATEST message**, which is what makes a new inbound message re-owe * the thread for free: it is a different `Communication`, it carries no dismissal, and the answer flips * back on its own. There is no marker to expire and no state that can go stale. * * **An empty thread cannot be dismissed**, and that is correct rather than an omission - there is no * message to mark, and a thread whose first words have not arrived yet is exactly the newest request. */ export const threadNeedsMyReply = ( request: CommunicationRequest, messages: readonly Communication[], isOurs: (reference?: string) => boolean, isDismissedByMe: (message: Communication | undefined) => boolean, ): boolean => threadAwaitsOurReply(request, messages, isOurs) && !isDismissedByMe(latestMessageIn(messages)); const PATIENT_SIDE_PREFIXES = ['Patient/', 'RelatedPerson/']; /** For a patient thread, "ours" is anyone who is not the patient or their related person. */ export const isStaffSide = (reference?: string): boolean => Boolean(reference) && !PATIENT_SIDE_PREFIXES.some((prefix) => reference!.startsWith(prefix)); /** * For a colleague thread, "ours" is ME - a colleague's thread is owed a reply when the last word is * theirs. Not "any staff member": a direct thread between two colleagues is not pooled, and treating * another receptionist's reply as mine would clear a thread I am the one being asked about. */ export const isSelf = (currentStaffReference: string | undefined) => (reference?: string): boolean => Boolean(reference) && reference === currentStaffReference; /** "3 patients awaiting reply" - the header states the fact. Singular at one. */ /** * The rail heading, which since also has to render at ZERO - *"0 patients awaiting reply"*. * * Spencer, reviewing the quiet frame: *"I get the concept of if there isn't any to not show it but I * think we should at least show '0 PATIENTS AWAITING REPLY'... just for that confirmation."* * * `undefined` IS THE THIRD STATE, and it is the reason this takes an optional number rather than a * plain one. Loading, empty and *not read yet* are different: a zero is a CLAIM, and a heading that says * "0 patients awaiting reply" before the read lands states something nobody checked. Not read yet drops * the number entirely and names the section instead. * * ONE PLACE FOR THE COPY, which is why the unread form lives here rather than as a ternary at the call * site: the noun would otherwise be written twice per section, and the plural rule three times. */ export const awaitingReplyHeading = (count: number | undefined, noun: 'patient' | 'colleague'): string => { if (count === undefined) return `${noun === 'patient' ? 'Patients' : 'Colleagues'} awaiting reply`; return `${count} ${noun}${count === 1 ? '' : 's'} awaiting reply`; }; /** * The fourth heading, written beside the one it mirrors so the two cannot drift into different shapes. * * THE NUMBER IS IN THE LABEL, not in the Section's `count` slot, because that is how the two message * headings read and how the deck draws this one: "0 NEW FORMS SUBMITTED". "On today" is the odd one out * and stays that way - its number answers "how many are on" rather than "how much is owed". */ export const newFormsHeading = (count: number | undefined): string => count === undefined ? 'New forms submitted' : `${count} new form${count === 1 ? '' : 's'} submitted`; export interface RoleFilterOption { /** `undefined` is the "all roles" option. */ role?: string; label: string; count: number; } /** * The role filter's options, DERIVED FROM TODAY'S ROTA. * * NEVER OFFER AN OPTION THAT RETURNS NOTHING. 88 organisation-role codings exist and only five * auto-assign a workspace, so any list written into the client is wrong for somebody - and an option that * filters to an empty list reads as "nobody is in today" rather than "that role is not on the rota". * The counts sit in the option text so that is self-evident before you pick one. */ export const roleFilterOptions = (roles: readonly string[]): RoleFilterOption[] => { const counts = new Map(); for (const role of roles) { const key = role.trim(); if (!key) continue; counts.set(key, (counts.get(key) ?? 0) + 1); } return [ { label: 'All roles', count: roles.length }, ...[...counts.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([role, count]) => ({ role, label: `${role} (${count})`, count })), ]; }; export const formatRelativeAge = (timestamp: number, now = Date.now()) => { if (!timestamp) return "--"; const elapsedMinutes = Math.max(0, Math.floor((now - timestamp) / 60000)); if (elapsedMinutes < 1) return "now"; if (elapsedMinutes < 60) return `${elapsedMinutes}m`; const elapsedHours = Math.floor(elapsedMinutes / 60); if (elapsedHours < 24) return `${elapsedHours}h`; const elapsedDays = Math.floor(elapsedHours / 24); return `${elapsedDays}d`; }; /** Newest first; the submitter's own name leads, and the form it came from carries the preview. */ export const buildFormSubmissionItems = ( submissions: readonly FormSubmission[], now = Date.now(), ): RailMessage[] => [...submissions] .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0)) .map((submission) => { const form = submissionFormLabel(submission); const preview = submission.message?.trim(); return { id: submission.submissionId, name: submission.submitterContact?.name?.trim() || "Anonymous", message: preview ? `${form} — ${preview}` : form, age: formatRelativeAge(Date.parse(submission.createdAt) || 0, now), unread: false, }; });