import type { VisitAnswerRefusal, VisitInviteeProjection, VisitReservation } from '../types/visitInvitation'; // What an invitee is told about a team video visit, from the server's projection (team video slice 0 contract). One place, // so the banner, the list and the calendar row say the same thing. /** "ZZ Care team · Ada Lovelace", or a plain title when an older invitation has no team the server could place. */ export const visitTitle = (visit: Pick): string => { const patient = visit.patient?.display?.trim(); const team = visit.team?.name?.trim(); if (!team) return patient ? `Team video visit with ${patient}` : 'Team video visit'; return patient ? `${team} · ${patient}` : team; }; /** * Whether an accept is finished: only an explicit `reserved`, or `not-applicable` for someone who holds no time. A missing * reservation (an older or incomplete projection) cannot certify the hold, so it counts as not finished (amendment A2). */ export const holdIsConfirmed = (reservation: VisitReservation | undefined): boolean => reservation?.state === 'reserved' || reservation?.state === 'not-applicable'; // A visit that is over, by the appointment's own status: there is nothing left to answer on it. Only these; an unknown or // missing status is not taken to mean over. const OVER = new Set(['cancelled', 'noshow', 'entered-in-error', 'fulfilled']); export const visitIsOver = (appointmentStatus: string | undefined): boolean => OVER.has(appointmentStatus ?? ''); // A visit's place in time, from its own scheduled start and end, never from a guess. An invalid or missing timestamp is // not evidence of anything: it neither closes an answer nor ends a visit. const instant = (iso: string | undefined): number | undefined => { if (!iso) return undefined; const at = Date.parse(iso); return Number.isFinite(at) ? at : undefined; }; /** * - `upcoming`: before its start; it can be accepted. * - `begun`: from its start, before its end. The server accepts no answer from its start on, so Accept is closed; a * decline, which only gives time back, is still taken. * - `ended`: its scheduled end has passed (its start, when it has no end). Read-only history. This is the clock, not * the visit's clinical state: a booked visit stays booked. * - `unknown`: no valid start. Nothing is decided from it. */ export type VisitTimePhase = 'upcoming' | 'begun' | 'ended' | 'unknown'; export const visitTimePhase = (visit: { start?: string; end?: string }, now: number): VisitTimePhase => { const start = instant(visit.start); // Only a visit with no end falls back to its start. An end that is present but unreadable ends nothing. const endsAt = visit.end ? instant(visit.end) : start; if (endsAt !== undefined && now >= endsAt) return 'ended'; if (start === undefined) return 'unknown'; return now >= start ? 'begun' : 'upcoming'; }; /** Accept is closed once the visit has begun: the server refuses an accept from the start on. */ export const acceptIsClosed = (visit: { start?: string; end?: string }, now: number): boolean => { const phase = visitTimePhase(visit, now); return phase === 'begun' || phase === 'ended'; }; /** The next moment a visit's phase changes, so an open page moves on without a reload. */ export const nextVisitBoundary = (visit: { start?: string; end?: string }, now: number): number | undefined => { const start = instant(visit.start); const endsAt = visit.end ? instant(visit.end) : start; return [start, endsAt].filter((at): at is number => at !== undefined && at > now).sort((a, b) => a - b)[0]; }; export const VISIT_TIME_PASSED = "This visit's scheduled time has passed."; export const VISIT_ACCEPT_CLOSED = 'This visit has begun, so it can no longer be accepted.'; /** "Oct 14, 2026, 4:45 PM to 5:15 PM CDT (30 min)": the interval, its time zone, and its length where both ends are known. */ export const visitWhen = (visit: { start?: string; end?: string }, timeZone?: string): string | undefined => { const start = instant(visit.start); if (start === undefined) return undefined; const zone = timeZone ? { timeZone } : {}; const end = instant(visit.end); const day: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', ...zone }; if (end === undefined || end <= start) return new Date(start).toLocaleString(undefined, { ...day, timeZoneName: 'short' }); const from = new Date(start).toLocaleString(undefined, day); const to = new Date(end).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short', ...zone }); const minutes = Math.round((end - start) / 60000); const hours = Math.floor(minutes / 60); const length = hours > 0 ? `${hours} hr${minutes % 60 ? ` ${minutes % 60} min` : ''}` : `${minutes} min`; return `${from} to ${to} (${length})`; }; /** What a person asked to join a visit that is over is told instead of being offered an answer. */ /** * A visit its organizers can only look back on: over by its own status, or past its scheduled end. Its invitations are * history, so nothing on them is offered to change. An unknown status or an unreadable time is not taken for either. */ export const visitIsHistory = (visit: { appointmentStatus?: string; start?: string; end?: string }, now: number): boolean => visitIsOver(visit.appointmentStatus) || visitTimePhase(visit, now) === 'ended'; /** What an organizer is told about a visit that is history, by its own status. */ export const historySentence = (appointmentStatus: string | undefined): string => appointmentStatus === 'cancelled' ? 'This visit was cancelled. Its invitations are kept as they were.' : appointmentStatus === 'entered-in-error' ? 'This visit was recorded in error. Its invitations are kept as they were.' : appointmentStatus === 'noshow' ? 'This visit did not take place. Its invitations are kept as they were.' : appointmentStatus === 'fulfilled' ? 'This visit has taken place. Its invitations are kept as they were.' : `${VISIT_TIME_PASSED} Its invitations ended with it and are kept as they were.`; /** Beside a saved answer, what became of the visit by its own status; nothing for a status that is not final. */ export const answeredVisitStatus = (appointmentStatus: string | undefined): string | undefined => appointmentStatus === 'cancelled' ? 'This visit was cancelled.' : appointmentStatus === 'entered-in-error' ? 'This visit was recorded in error.' : appointmentStatus === 'noshow' ? 'This visit did not take place.' : appointmentStatus === 'fulfilled' ? 'This visit has taken place.' : undefined; export const overSentence = (appointmentStatus: string | undefined): string => appointmentStatus === 'cancelled' ? 'This visit was cancelled, so there is nothing to answer.' : appointmentStatus === 'fulfilled' ? 'This visit has already taken place, so there is nothing to answer.' : 'This visit is no longer going ahead, so there is nothing to answer.'; /** An accept that still needs the person: the time is not held, or nobody has said that it is. */ export const acceptIsUnfinished = (visit: { response?: string; reservation?: VisitReservation }): boolean => visit.response === 'accepted' && !holdIsConfirmed(visit.reservation); /** * The sentence after an accept, from where the hold actually stands. Only `reserved` is a finished booking; an accept whose * hold did not finish must not read as success (contract amendment A2). */ export const acceptedSentence = (reservation: VisitReservation | undefined): { tone: 'success' | 'warning'; text: string } => { switch (reservation?.state) { case 'reserved': return { tone: 'success', text: 'Accepted. This visit is on your calendar and the time is held for you.' }; case 'not-applicable': return { tone: 'success', text: 'Accepted. You can join from here at the time of the visit.' }; case 'conflict': return { tone: 'warning', text: 'Your answer is still pending: this time overlaps something already on your calendar.' }; case 'not-reserved': return { tone: 'warning', text: 'Your answer is saved, but your time is not held yet. Accept again to finish.' }; default: return { tone: 'warning', text: 'Your answer is saved, but your time is not confirmed as held. Accept again to finish.' }; } }; const timeRange = (start: string, end: string | undefined, timeZone?: string): string => { const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short', ...(timeZone ? { timeZone } : {}) }; const from = new Date(start).toLocaleString(undefined, options); if (!end) return from; const to = new Date(end).toLocaleTimeString(undefined, { timeStyle: 'short', ...(timeZone ? { timeZone } : {}) }); return `${from} to ${to}`; }; /** What a refused answer means to the person, and whether reading the invitation again is the way forward. */ export const refusalSentence = (refusal: VisitAnswerRefusal, timeZone?: string): { text: string; reread: boolean } => { switch (refusal.code) { case 'TIME_CHANGED': return { text: 'The time of this visit has changed. Check the new time, then answer again.', reread: true }; case 'STALE_REVISION': case 'REVISION_REQUIRED': return { text: 'This invitation changed while you were looking at it. Check it, then answer again.', reread: true }; case 'RESERVATION_CONFLICT': return refusal.conflictStart ? { text: `You already have something booked ${timeRange(refusal.conflictStart, refusal.conflictEnd, timeZone)}. Your answer is still pending.`, reread: false } : { text: 'This time overlaps something already on your calendar. Your answer is still pending.', reread: false }; case 'NOT_TELEHEALTH': return { text: 'This visit is in person, so there is no video invitation to answer.', reread: true }; default: return { text: refusal.message || 'That answer could not be saved.', reread: false }; } }; export const DECLINE_REASON_MAX = 200; /** A decline reason as the server will keep it: trimmed, at most 200 characters, or none. */ export const cleanDeclineReason = (reason: string | undefined): string | undefined => { const trimmed = (reason ?? '').trim(); return trimmed ? trimmed.slice(0, DECLINE_REASON_MAX) : undefined; }; /** Reads a refusal from a failed answer's error body; unknown shapes become `OTHER` with the server's message. */ export const readAnswerRefusal = (error: unknown): VisitAnswerRefusal => { const body = (error as { body?: unknown; response?: { data?: unknown } })?.body ?? (error as { response?: { data?: unknown } })?.response?.data ?? error; const data = (body && typeof body === 'object' ? body : {}) as Record; const code = typeof data.code === 'string' ? data.code : ''; const message = typeof data.message === 'string' ? data.message : undefined; if (code === 'TIME_CHANGED' || code === 'STALE_REVISION' || code === 'REVISION_REQUIRED') return { code, message }; if (code === 'RESERVATION_CONFLICT') { return { code, message, conflictStart: typeof data.conflictStart === 'string' ? data.conflictStart : undefined, conflictEnd: typeof data.conflictEnd === 'string' ? data.conflictEnd : undefined, reason: data.reason === 'not-visible' ? 'not-visible' : undefined, }; } if (code === 'NOT_TELEHEALTH') return { code, message }; return { code: 'OTHER', message: message ?? (error instanceof Error ? error.message : undefined) }; }; /** Who reads a visit sheet: the patient in their own record, a helper switched into somebody's, or the practice. */ export type VisitSheetAudience = | { kind: 'staff' } | { kind: 'self' } | { kind: 'helper'; subjectName?: string }; /** The empty sheet's reason, said to its reader. Who may be invited is the server's answer, never this sentence's. */ export const noOneToInviteSentence = (audience: VisitSheetAudience = { kind: 'staff' }): string => { if (audience.kind === 'self') return 'No one from your collaboration team can be invited to this visit.'; if (audience.kind === 'helper') { const name = audience.subjectName?.trim(); return `No one from ${name ? `${name}'s` : 'their'} collaboration team can be invited to this visit.`; } return 'This patient has no collaboration team members who can be invited to a visit.'; };