import type { Invitation, PatientSummary, Pronoun, UrgentAlert, WeeklyHours } from "../api/types"; import { dayLabel, listNames } from "./format"; import { attentionRank, sharedByLine, sortPatients, visibleTabs, type PatientTab } from "./patients"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const MONTHS = "January February March April May June July August September October November December".split(" "); const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; /** "Good morning" before noon, "Good afternoon" until six, then "Good evening". */ export function greeting(now = new Date()): string { const h = now.getHours(); return h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; } /** "Saturday 31 October". */ export const longDate = (now = new Date()) => `${DAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`; const PRONOUNS: Record = { she: "she/her", he: "he/him", they: "they/them" }; /** "64 years · she/her": only what the patient stated, never a guess. */ export function personLine(p: Pick): string { return [p.age !== null ? `${p.age} years` : null, p.pronoun ? PRONOUNS[p.pronoun] : null].filter(Boolean).join(" · "); } /** PAT-2: an urgent note, unread check-in notes or new information since the last check. */ export const needsAttention = (p: PatientSummary) => attentionRank(p) < 3; export type TodoKind = "urgent" | "notes" | "new"; export type Todo = { kind: TodoKind; patientId: string; patientName: string; title: string; detail: string; /** Urgent notes, or unread notes, behind this card. */ count: number; at: string | null; tab: PatientTab; checkInId: string | null; }; const rank: Record = { urgent: 0, notes: 1, new: 2 }; function urgentTodo(id: string, name: string, open: UrgentAlert[]): Todo { const latest = open[0]; return { kind: "urgent", patientId: id, patientName: name, title: `${open.length > 1 ? `${open.length} urgent notes` : "Urgent note"} · ${name}`, detail: latest?.text || "A check-in has an urgent note.", count: Math.max(1, open.length), at: latest?.at ?? null, tab: "check_ins", checkInId: latest?.checkInId ?? null, }; } /** One card per patient who needs you, in PAT-2 order; urgent notes link to their call. */ export function needsYou(patients: PatientSummary[], alerts: UrgentAlert[], now = new Date()): Todo[] { const byPatient = new Map(); for (const a of [...alerts].sort((x, y) => Date.parse(y.at) - Date.parse(x.at))) byPatient.set(a.patientId, [...(byPatient.get(a.patientId) ?? []), a]); const known = new Set(patients.map((p) => p.id)); const todos: Todo[] = [...byPatient].filter(([id]) => !known.has(id)).map(([id, open]) => urgentTodo(id, open[0].patientName, open)); for (const p of sortPatients(patients)) { const open = byPatient.get(p.id) ?? []; const { urgent, unreadNotes, newSinceLastCheck } = p.attention; if (open.length || urgent) todos.push(urgentTodo(p.id, p.name, open)); else if (unreadNotes > 0) { const last = p.lastCheckIn; todos.push({ kind: "notes", patientId: p.id, patientName: p.name, title: `${unreadNotes === 1 ? "Unread note" : `${unreadNotes} unread notes`} · ${p.name}`, detail: last ? `From the ${last.kind === "yasmar_call" ? "Yasmar call" : "daily check-in"} · ${dayLabel(last.at, now)}` : "Open the check-ins to read them.", count: unreadNotes, at: last?.at ?? p.lastActivityAt, tab: "check_ins", checkInId: null, }); } else if (newSinceLastCheck) todos.push({ kind: "new", patientId: p.id, patientName: p.name, title: `New information · ${p.name}`, detail: p.sharing ? `${sharedByLine(p.sharing)} since your last check.` : "Added since your last check.", count: 1, at: p.sharing?.changedAt ?? p.lastActivityAt, tab: "check", checkInId: null, }); } const latest = (t: Todo) => (t.kind === "urgent" && t.at ? Date.parse(t.at) : 0); return todos.sort((a, b) => rank[a.kind] - rank[b.kind] || latest(b) - latest(a)); } /** The one plain line under the greeting: what is waiting, or that nothing is. */ export function summaryLine(todos: Todo[], invitations: Pick[]): string { const of = (kind: TodoKind) => todos.filter((t) => t.kind === kind); const urgent = of("urgent").reduce((sum, t) => sum + t.count, 0); const notes = of("notes").length; const fresh = of("new").length; const parts = [ urgent ? count(urgent, "urgent check-in note") : null, notes ? `unread notes for ${count(notes, "patient")}` : null, fresh ? `new information for ${count(fresh, "patient")}` : null, invitations.length ? `${count(invitations.length, "invitation")} to answer` : null, ].filter((p): p is string => !!p); if (!parts.length) return "Nothing needs you right now."; const line = listNames(parts); return `${line.charAt(0).toUpperCase()}${line.slice(1)}.`; } /** Sharing hides new data while paused and all of it once stopped (SHR-1); clinic patients show their own calls. */ const showsLive = (p: PatientSummary) => !p.sharing || p.sharing.status === "connected"; export type DoseRow = { patient: PatientSummary; taken: number; expected: number; onTime: number; ratio: number }; /** Patients who share doses with some due this week, the lowest share taken first. */ export function dosesThisWeek(patients: PatientSummary[]): DoseRow[] { return patients .filter((p) => p.sharing?.status === "connected" && p.sharing.scopes.includes("doses") && p.doses && p.doses.expected > 0) .map((p) => ({ patient: p, ...p.doses!, ratio: p.doses!.taken / p.doses!.expected })) .sort((a, b) => a.ratio - b.ratio || b.expected - a.expected || a.patient.name.localeCompare(b.patient.name)); } /** Each patient's latest check-in, newest first. */ export function recentCheckIns(patients: PatientSummary[]): PatientSummary[] { return patients .filter((p) => p.lastCheckIn && showsLive(p)) .sort((a, b) => Date.parse(b.lastCheckIn!.at) - Date.parse(a.lastCheckIn!.at) || a.name.localeCompare(b.name)); } /** A Yasmar call opens the Check-ins tab; a daily check-in lives with what the app shares. */ export function checkInTab(p: PatientSummary): PatientTab { if (p.lastCheckIn?.kind === "yasmar_call") return "check_ins"; return visibleTabs(p).includes("shared") ? "shared" : "overview"; } const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); /** The small numbers beside "Needs you". */ export function todayStats(patients: PatientSummary[], now = new Date()) { return { total: patients.length, sharing: patients.filter((p) => p.sharing?.status === "connected").length, checkedInToday: patients.filter((p) => p.lastCheckIn && showsLive(p) && sameDay(new Date(p.lastCheckIn.at), now)).length, unreadNotes: patients.reduce((sum, p) => sum + p.attention.unreadNotes, 0), }; } const minutesOf = (hhmm: string) => { const [h, m] = hhmm.split(":").map(Number); return (h || 0) * 60 + (m || 0); }; /** "Open until 17:00", "Opens at 08:00" or "Closed now", in the clinic's own clock (offset in minutes). */ export function clinicHoursLabel(hours: WeeklyHours, now: Date, offset: number): { open: boolean; label: string } { const local = new Date(now.getTime() + offset * 60_000); const day = local.getUTCDay(); const mins = local.getUTCHours() * 60 + local.getUTCMinutes(); const wraps = (s: { open: string; close: string }) => minutesOf(s.close) <= minutesOf(s.open); const fromYesterday = hours.find((s) => s.day === (day + 6) % 7 && wraps(s) && mins < minutesOf(s.close)); if (fromYesterday) return { open: true, label: `Open until ${fromYesterday.close}` }; const today = hours.filter((s) => s.day === day).sort((a, b) => minutesOf(a.open) - minutesOf(b.open)); const current = today.find((s) => mins >= minutesOf(s.open) && (wraps(s) || mins < minutesOf(s.close))); if (current) return { open: true, label: `Open until ${current.close}` }; const next = today.find((s) => minutesOf(s.open) > mins); return next ? { open: false, label: `Opens at ${next.open}` } : { open: false, label: "Closed now" }; }