marmar-context / web / context / src / logic / patients.ts
patients.ts
Raw
import type { PatientSummary, ShareScope, Sharing } from "../api/types";
import type { Pronoun } from "../api/types";
import { dayLabel, listNames, pronouns, scopeLabels, statusLabels } from "./format";

/** PAT-2: an urgent check-in flag, unread check-in notes, new information, then the rest. */
export function attentionRank(p: PatientSummary): number {
  if (p.attention.urgent) return 0;
  if (p.attention.unreadNotes > 0) return 1;
  if (p.attention.newSinceLastCheck) return 2;
  return 3;
}

/** PAT-2 order; within each group, the most recent activity first. */
export function sortPatients(list: PatientSummary[]): PatientSummary[] {
  return [...list].sort(
    (a, b) =>
      attentionRank(a) - attentionRank(b) ||
      new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime() ||
      a.name.localeCompare(b.name),
  );
}

export const normalizeName = (s: string) =>
  s
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()
    .replace(/\s+/g, " ")
    .trim();

/** Search by name: every word typed must start a word of the name, in any order. */
export function searchPatients<T extends { name: string }>(list: T[], query: string): T[] {
  const words = normalizeName(query).split(" ").filter(Boolean);
  if (!words.length) return list;
  return list.filter((p) => {
    const parts = normalizeName(p.name).split(" ");
    return words.every((w) => parts.some((part) => part.startsWith(w)));
  });
}

export function attentionLabel(p: PatientSummary): string | null {
  if (p.attention.urgent) return "Urgent check-in note";
  if (p.attention.unreadNotes) return `${p.attention.unreadNotes} unread check-in ${p.attention.unreadNotes === 1 ? "note" : "notes"}`;
  if (p.attention.newSinceLastCheck) return "New information since your last check";
  return null;
}

export type PatientTab = "overview" | "check" | "check_ins" | "shared" | "records" | "activity";
export const tabNames: Record<PatientTab, string> = {
  overview: "Overview",
  check: "Check",
  check_ins: "Check-ins",
  shared: "From MARMAR Health",
  records: "Records",
  activity: "Activity",
};

const live = (s: Sharing | null) => !!s && s.status !== "stopped";

/**
 * PAT-3 tabs, driven by what the patient shares (INV-3). A connected patient who shares
 * neither check-ins nor calls shows no Check-ins tab; nothing shared hides the app view.
 * Activity is not a tab: it is in the page's rail, beside every tab.
 */
export function visibleTabs(p: Pick<PatientSummary, "kind" | "sharing">): PatientTab[] {
  const tabs: PatientTab[] = ["overview"];
  if (p.kind === "connected" && live(p.sharing) && p.sharing!.scopes.length) tabs.push("shared");
  tabs.push("check");
  if (
    p.kind === "clinic_added" ||
    (live(p.sharing) && (p.sharing!.allowsCheckInCalls || p.sharing!.scopes.includes("check_ins")))
  )
    tabs.push("check_ins");
  tabs.push("records");
  return tabs;
}

/** The shared sections to show and to fetch. Stopped sharing shows none (INV-8). */
export function sharedSections(sharing: Sharing | null): ShareScope[] {
  if (!live(sharing)) return [];
  const order: ShareScope[] = ["medicines", "doses", "check_ins", "records"];
  return order.filter((s) => sharing!.scopes.includes(s));
}

/** "Shared by Mariam and Funke". */
export const sharedByLine = (s: Sharing) => (s.sharedBy.length ? `Shared by ${listNames(s.sharedBy)}` : "Shared from MARMAR Health");

/** "shares medicines, doses taken, check-ins and records" (INV-11). */
export function sharesLine(scopes: ShareScope[]): string {
  const order: ShareScope[] = ["medicines", "doses", "check_ins", "records"];
  const named = order.filter((s) => scopes.includes(s)).map((s) => scopeLabels[s]);
  return named.length ? `shares ${listNames(named)}` : "shares nothing yet";
}

export const sharingLabels = {
  connected: { label: "Connected", tone: "green" },
  paused: { label: "Paused", tone: "amber" },
  stopped: { label: "Sharing stopped", tone: "" },
} as const;

export type TileCell = { value: string; note: string };
export type TileCells = { medicines: TileCell; doses: TileCell; lastCheckIn: TileCell };

const NOT_SHARED: TileCell = { value: "Not shared", note: "Not part of this share" };
const PAUSED: TileCell = { value: "Paused", note: "Hidden until sharing resumes" };
const UNKNOWN: TileCell = { value: "Shared", note: "Open the patient to see" };

/** "Completed", "Missed": a check-in's state in words. */
export function checkInStateLabel(status: string): string {
  const known = (statusLabels as Record<string, { label: string }>)[status];
  if (known) return known.label;
  const words = status.replace(/_/g, " ");
  return words.charAt(0).toUpperCase() + words.slice(1);
}

/**
 * SHR-1: the three tiles, each from what the patient shares. An area that is not shared says
 * "Not shared"; while sharing is paused, nothing new shows. Null when sharing has stopped.
 */
export function tileValues(p: PatientSummary, now = new Date()): TileCells | null {
  const sharing = p.sharing;
  if (!sharing || sharing.status === "stopped") return null;
  if (sharing.status === "paused") return { medicines: PAUSED, doses: PAUSED, lastCheckIn: PAUSED };
  const scopes = sharing.scopes;
  const their = pronouns(p.pronoun).possessive;
  const medicines: TileCell = !scopes.includes("medicines")
    ? NOT_SHARED
    : p.medicines
      ? { value: String(p.medicines.count), note: p.medicines.count ? `From ${their} app` : `None listed in ${their} app` }
      : UNKNOWN;
  const d = p.doses;
  const doses: TileCell = !scopes.includes("doses")
    ? NOT_SHARED
    : !d
      ? UNKNOWN
      : !d.expected
      ? { value: "None due", note: "No doses were due this week" }
      : { value: `${d.taken} of ${d.expected}`, note: d.taken && d.onTime === d.taken ? "Taken on time" : `${d.onTime} on time` };
  const last = p.lastCheckIn;
  const lastCheckIn: TileCell = last
    ? { value: dayLabel(last.at, now), note: [last.kind === "yasmar_call" ? "Yasmar call" : "Daily check-in", last.status ? checkInStateLabel(last.status) : null].filter(Boolean).join(" · ") }
    : scopes.includes("check_ins") || sharing.allowsCheckInCalls
      ? { value: "None yet", note: "No check-in yet" }
      : NOT_SHARED;
  return { medicines, doses, lastCheckIn };
}

/** "with her daughter Funke" (INV-11); they/them when the patient's pronouns are not stated. */
export function withLine(person: { name: string; relation: string } | null, pronoun: Pronoun | null): string {
  if (!person) return "";
  return `with ${pronouns(pronoun).possessive} ${person.relation} ${person.name}`;
}