import type { Reference } from 'fhir/r4'; import { WITHHELD_COLLEAGUE_NAME } from './staff/withheldColleague'; export type OverviewRowKind = 'patient' | 'colleague' | 'team' | 'aside'; export interface OverviewThreadRef { kind: OverviewRowKind; threadId: string; // reply target: CommunicationRequest.id (direct) or collab thread id requestId?: string; // direct deep-link (patient/colleague) teamId?: string; // collab deep-link (team/aside) variant?: 'public' | 'aside'; recipientReferences: Reference[]; // [] for collab (server derives); [counterparty] for direct closed?: boolean; // dissolved team -> read-only } export interface OverviewRawRow { key: string; kind: OverviewRowKind; counterpartyName?: string; // patient/colleague display name patientName?: string; // team/aside patient teamName?: string; // team/aside team preview: string; timestamp: number; // ms epoch, for sort unread: number; /** * - the last word is theirs and no reply of ours follows it. * * NOT `unread > 0`, and the distinction is the whole point of the row it feeds: a message you READ * and did not answer is still owed, while an unread count goes quiet exactly while the work piles up. * Under a shared inbox it is worse still - a read receipt clears the moment anybody merely OPENS it. */ awaitingReply: boolean; /** * The id of the message that was LATEST when this row was built - what the reader actually saw. * * - THIS IS A GUARD, NOT A CONVENIENCE, and it exists because dismissing "the current last * message" is a read-then-write race. A dismissal marks the newest message; if the patient writes * between the row rendering and the button being pressed, re-deriving "newest" at click time marks the * message that just arrived - one the reader never saw - and the thread leaves the owed count carrying * an unanswered question. * * That failure runs in the worst direction: the practitioner believes they dealt with it, and the * patient is waiting on a reply that is no longer counted anywhere. It is worse than the empty * "you're welcome" the dismissal exists to replace. * * Carrying the observed id lets the dismissal say WHICH message it is answering for, so a thread that * has moved on can be refused instead of silently cleared. `undefined` for a thread with no messages * yet, which cannot be dismissed at all. */ observedLatestMessageId?: string; threadRef: OverviewThreadRef; } export interface OverviewMessageRow extends OverviewRawRow { title: string; } export interface OverviewMessageFeed { rows: OverviewMessageRow[]; totalUnread: number; /** * Every thread owed a reply, in the same order - **UNCAPPED**, unlike `rows`. * * The owed-work panel lists these and the card counts them, so they must be the same set. `rows` is * truncated to `OVERVIEW_FEED_CAP`, and a panel built from it beside a count built from everything is * the header-disagrees-with-the-list defect this codebase has paid for more than once. */ awaitingReplyRows: OverviewMessageRow[]; /** * `awaitingReplyRows.length`, BY CONSTRUCTION rather than by agreement. Two expressions that happen * to produce the same number agree right up until one of them grows a filter. */ awaitingReplyCount: number; } export const OVERVIEW_FEED_CAP = 6; export const buildOverviewRowTitle = ( row: Pick, ): string => { const teamSuffix = row.teamName ? ` — ${row.teamName}` : ''; switch (row.kind) { case 'patient': return `Patient · ${row.counterpartyName ?? 'Patient'}`; // The fifth site of the same answer. Four agreeing and one differing is how a reader learns // to distrust all five, and this one is the same condition: the counterparty did not resolve. case 'colleague': return `Colleague · ${row.counterpartyName ?? WITHHELD_COLLEAGUE_NAME}`; case 'team': return `Collaboration team · ${row.patientName ?? 'Patient'}${teamSuffix}`; case 'aside': return `Aside · ${row.patientName ?? 'Patient'}${teamSuffix}`; } }; export const buildOverviewFeed = ( rows: OverviewRawRow[], cap: number = OVERVIEW_FEED_CAP, ): OverviewMessageFeed => { const sorted = [...rows].sort((a, b) => { const au = a.unread > 0 ? 1 : 0; const bu = b.unread > 0 ? 1 : 0; if (au !== bu) return bu - au; // unread first return b.timestamp - a.timestamp; // then most recent }); const totalUnread = rows.reduce((sum, r) => sum + r.unread, 0); const withTitle = (r: OverviewRawRow): OverviewMessageRow => ({ ...r, title: buildOverviewRowTitle(r) }); const awaitingReplyRows = sorted.filter((r) => r.awaitingReply).map(withTitle); const titled = sorted.slice(0, cap).map(withTitle); return { rows: titled, totalUnread, awaitingReplyRows, awaitingReplyCount: awaitingReplyRows.length }; };