marmar-context / web / context / src / logic / today.ts
today.ts
Raw
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" };
}