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<Pronoun, string> = { 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<PatientSummary, "age" | "pronoun">): 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<TodoKind, number> = { 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<string, UrgentAlert[]>();
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<Invitation, "id">[]): 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" };
}