marmar-context / web / context / test / clinician-checkins.test.ts
clinician-checkins.test.ts
Raw
import { describe, expect, it } from "vitest";
import type { CheckIn, CheckInEvent, CheckInNote, PatientDetail, WindowRules } from "../src/api/types";
import { checkInEligibility } from "../src/logic/eligibility";
import { buildScript, closingAvailable, moveQuestion, precheckQuestion } from "../src/logic/questions";
import { confirmationValid, notReachedAlert, urgentGate } from "../src/logic/urgent";
import { allowedWindows, canStartNow, clampExpiry, clinicCloseToday, limitState, slots, windowLabel, zoneOffset } from "../src/logic/windows";
import { applyEvent, applyEvents, isSettled, unacknowledged } from "../src/logic/checkin-events";

const mariam: PatientDetail = {
  id: "p-mariam",
  kind: "connected",
  name: "Mariam Adeyemi",
  firstName: "Mariam",
  age: 67,
  sharing: {
    status: "connected",
    sharedBy: ["Mariam", "Funke"],
    scopes: ["medicines", "doses", "check_ins", "records"],
    allowsCheckInCalls: true,
    withheldSome: false,
    changedAt: "2026-10-30T09:00:00Z",
  },
  medicines: { count: 3 },
  doses: { taken: 12, expected: 14, onTime: 12 },
  lastCheckIn: null,
  attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: false },
  lastActivityAt: "2026-10-30T09:00:00Z",
  dateOfBirth: "1959-03-04",
  birthYear: 1959,
  sex: "female",
  phone: "+234 803 000 0000",
  pronoun: "she",
  preferredTitle: "Mrs Adeyemi",
  language: "en",
  hasOwnAccount: true,
  identityOnFile: "date_of_birth",
  timeZone: "Africa/Lagos",
  quietHours: { start: "22:00", end: "07:00" },
  blockedTimes: [],
  checkInLimits: { usedToday: 0, usedThisWeek: 0, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null },
  activeCheckInId: null,
  lastCheckId: null,
  newInformation: null,
};

describe("check-in eligibility (YCI-1)", () => {
  it("allows an adult connected patient who allowed calls", () => {
    expect(checkInEligibility(mariam)).toEqual({ allowed: true, notes: [] });
  });
  it("offers the app invite for a clinic-added patient", () => {
    const result = checkInEligibility({ ...mariam, kind: "clinic_added", sharing: null });
    expect(result).toMatchObject({ allowed: false, code: "clinic_added", action: "invite_app" });
    expect(!result.allowed && result.message).toBe("Invite Mariam to MARMAR Health to use check-ins.");
  });
  it("says what is missing, in order", () => {
    const cases: [Partial<PatientDetail>, string][] = [
      [{ sharing: { ...mariam.sharing!, status: "stopped" } }, "sharing_stopped"],
      [{ sharing: { ...mariam.sharing!, status: "paused" } }, "sharing_paused"],
      [{ hasOwnAccount: false }, "no_own_account"],
      [{ sharing: { ...mariam.sharing!, allowsCheckInCalls: false } }, "calls_not_allowed"],
      [{ identityOnFile: "none", dateOfBirth: null, birthYear: null }, "no_date_of_birth"],
      [{ age: 16 }, "under_18"],
      [{ activeCheckInId: "c1" }, "active_check_in"],
      [{ checkInLimits: { ...mariam.checkInLimits, usedToday: 1 } }, "limit_reached"],
    ];
    for (const [change, code] of cases) expect(checkInEligibility({ ...mariam, ...change })).toMatchObject({ allowed: false, code });
  });
  it("uses they for a patient whose pronouns are not stated", () => {
    const result = checkInEligibility({ ...mariam, pronoun: null, sharing: { ...mariam.sharing!, status: "paused" } });
    expect(!result.allowed && result.message).toContain("when they resume");
  });
  it("allows a birth year alone, and says the phone's unlock is the second check", () => {
    const yearOnly: PatientDetail = { ...mariam, identityOnFile: "birth_year", dateOfBirth: null };
    const result = checkInEligibility(yearOnly);
    expect(result.allowed).toBe(true);
    expect(result.allowed && result.notes[0]).toContain("her unlocked phone");
  });
});

describe("urgent first (YCI-1a)", () => {
  const now = new Date("2026-10-30T10:00:00Z");
  const base = { patientId: "p-mariam", patientName: "Mariam Adeyemi", now, clinicOffset: 60 };
  it("needs nothing without an Urgent review finding", () => {
    expect(urgentGate({ ...base, findings: [{ id: "f1", tier: "today" }], confirmation: null })).toEqual({ mode: "none", canAskYasmar: true });
  });
  it("leads with the call and holds Ask Yasmar until the clinician confirms", () => {
    const gate = urgentGate({ ...base, findings: [{ id: "f1", tier: "get_help_now" }], confirmation: null });
    expect(gate).toMatchObject({ mode: "call_first", canAskYasmar: false, callLine: "Call Mariam", confirmLine: "I will speak to Mariam today" });
    const confirmed = urgentGate({
      ...base,
      findings: [{ id: "f1", tier: "get_help_now" }],
      confirmation: { patientId: "p-mariam", confirmedAt: "2026-10-30T09:30:00Z" },
    });
    expect(confirmed.canAskYasmar).toBe(true);
  });
  it("forgets yesterday's confirmation and another patient's", () => {
    expect(confirmationValid({ patientId: "p-mariam", confirmedAt: "2026-10-29T20:00:00Z" }, "p-mariam", now, 60)).toBe(false);
    expect(confirmationValid({ patientId: "p-other", confirmedAt: "2026-10-30T09:00:00Z" }, "p-mariam", now, 60)).toBe(false);
    // 23:30 UTC on the 29th is 00:30 on the 30th in Lagos.
    expect(confirmationValid({ patientId: "p-mariam", confirmedAt: "2026-10-29T23:30:00Z" }, "p-mariam", now, 60)).toBe(true);
  });
  it("alerts when a pressing check-in has not finished by the end of clinic hours", () => {
    const close = new Date("2026-10-30T16:00:00Z");
    const input = { status: "ringing" as const, linkedTiers: ["today"], patientName: "Mariam Adeyemi", clinicCloseAt: close };
    expect(notReachedAlert({ ...input, now: new Date("2026-10-30T15:00:00Z") })).toBeNull();
    expect(notReachedAlert({ ...input, now: close })).toBe("Mariam not reached. Call today.");
    expect(notReachedAlert({ ...input, status: "completed", now: close })).toBeNull();
    expect(notReachedAlert({ ...input, linkedTiers: ["this_week"], now: close })).toBeNull();
    expect(notReachedAlert({ ...input, status: "not_reached", now: new Date("2026-10-30T11:00:00Z") })).not.toBeNull();
  });
});

describe("own questions (YCI-4)", () => {
  const ok = (text: string) => precheckQuestion(text, "Mariam");
  it("accepts a plain question", () => {
    expect(ok("Do you take the Metformin with food?")).toEqual({ ok: true, text: "Do you take the Metformin with food?" });
    expect(ok("Have you started the Amoxicillin?").ok).toBe(true);
  });
  it("rejects instructions with the PRD's reason", () => {
    expect(ok("Stop the amoxicillin today")).toEqual({
      ok: false,
      rule: "instruction",
      reason: "Yasmar can ask questions but cannot give instructions. Call Mariam to tell them this.",
    });
    expect(ok("Can you stop the Amoxicillin until I call?").ok).toBe(false);
    expect(ok("You should drink more water, is that okay?").ok).toBe(false);
  });
  it("rejects results, diagnosis and reassurance", () => {
    expect(ok("Did you know your kidney result was low?")).toMatchObject({ rule: "result" });
    expect(ok("Did they tell you that you have kidney disease?")).toMatchObject({ rule: "diagnosis" });
    expect(ok("The rash is nothing to worry about, okay?")).toMatchObject({ rule: "reassurance" });
  });
  it("needs one question, under 200 characters", () => {
    expect(ok("Tell me about your sleep")).toMatchObject({ ok: false });
    expect(ok("Where do you buy it")).toMatchObject({ rule: "not_question" });
    expect(ok("How often? And when?")).toMatchObject({ rule: "several" });
    expect(ok(`${"a".repeat(200)}?`)).toMatchObject({ rule: "too_long" });
    expect(ok("   ")).toMatchObject({ rule: "empty" });
  });
});

describe("the script Yasmar will say (YCI-2, YCI-20)", () => {
  const template = {
    opening: ["Good afternoon. I'm Yasmar, MARMAR's computer assistant. I'm not a person."],
    identity: ["Am I speaking with Mariam Adeyemi?"],
    consent: "Your answers go to Doctor Okafor's clinic.",
    feeling: "How have you been feeling?",
    alwaysLast: "Anything else Doctor Okafor should know?",
    closings: [
      { id: "sent", text: "Thank you. I've sent your answers to Doctor Okafor's clinic.", requires: "none" as const, fixed: false },
      { id: "today", text: "I'll let Doctor Okafor know today.", requires: "read_today" as const, fixed: false },
      { id: "worse", text: "If anything gets worse, call his clinic.", requires: "none" as const, fixed: true },
    ],
    safetyNet: "If you have trouble breathing, call 767 or 112.",
  };
  const q = { id: "q1", text: "Have you started the Amoxicillin?", origin: "bank" as const, bankId: "amox-started", reason: "From the finding: Amoxicillin and a recorded Penicillin allergy", findingId: "f2" };
  it("keeps fixed lines first and the safety net last", () => {
    const script = buildScript(template, [q], ["sent"], false);
    expect(script.map((l) => l.part)).toEqual(["opening", "identity", "consent", "feeling", "question", "last", "closing", "closing", "safety"]);
    expect(script.filter((l) => !l.editable).length).toBe(7);
    expect(script[4].reason).toContain("From the finding");
  });
  it("offers 'know today' only when the clinician will read them today", () => {
    expect(buildScript(template, [], ["today"], false).some((l) => l.text.includes("know today"))).toBe(false);
    expect(buildScript(template, [], ["today"], true).some((l) => l.text.includes("know today"))).toBe(true);
    expect(closingAvailable(template.closings[1], false)).toBe(false);
  });
  it("moves questions within the list", () => {
    const list = [q, { ...q, id: "q2" }, { ...q, id: "q3" }];
    expect(moveQuestion(list, "q2", -1).map((x) => x.id)).toEqual(["q2", "q1", "q3"]);
    expect(moveQuestion(list, "q1", -1)).toBe(list);
  });
});

describe("when Yasmar may ring (YCI-5) and limits (YCI-6)", () => {
  const weekdays = (open: string, close: string) => [1, 2, 3, 4, 5, 6].map((day) => ({ day, open, close }));
  const rules: Omit<WindowRules, "patientTimeZone" | "clinicTimeZone" | "defaultExpiryHours" | "maxExpiryHours" | "known"> = {
    clinicHours: weekdays("08:00", "18:00"),
    alertWatchHours: weekdays("08:00", "18:00"),
    dayStart: "08:00",
    dayEnd: "20:00",
    quietHours: { start: "22:00", end: "07:00" },
    blockedTimes: [],
  };
  const lagos = { patient: 60, clinic: 60 };
  it("resolves Lagos as UTC+1", () => {
    expect(zoneOffset("Africa/Lagos", new Date("2026-10-30T12:00:00Z"))).toBe(60);
  });
  it("starts now inside clinic and watch hours", () => {
    const now = new Date("2026-10-30T10:00:00Z"); // Friday 11:00 Lagos
    const windows = allowedWindows(rules, now, lagos);
    expect(canStartNow(windows, now)).toBe(true);
    expect(windows[0].end.toISOString()).toBe("2026-10-30T17:00:00.000Z");
    expect(windowLabel(windows[0], 60, now)).toBe("today 11:00 to 18:00");
  });
  it("waits for 08:00 when asked at 21:00 patient time", () => {
    const now = new Date("2026-10-30T20:00:00Z"); // Friday 21:00 Lagos
    const windows = allowedWindows(rules, now, lagos);
    expect(canStartNow(windows, now)).toBe(false);
    expect(windows[0].start.toISOString()).toBe("2026-10-31T07:00:00.000Z"); // Saturday 08:00 Lagos
    expect(windowLabel(windows[0], 60, now)).toBe("tomorrow 08:00 to 18:00");
  });
  it("never rings in quiet hours, blocked times or outside 08:00 to 20:00", () => {
    const late = { ...rules, clinicHours: weekdays("06:00", "23:00"), alertWatchHours: weekdays("06:00", "23:00") };
    const windows = allowedWindows(
      { ...late, quietHours: { start: "19:00", end: "09:00" }, blockedTimes: [{ day: null, start: "13:00", end: "14:00", label: "Prayer" }] },
      new Date("2026-10-30T05:00:00Z"),
      lagos,
    );
    const friday = windows.filter((w) => w.start.toISOString().startsWith("2026-10-30"));
    expect(friday.map((w) => [w.start.toISOString(), w.end.toISOString()])).toEqual([
      ["2026-10-30T08:00:00.000Z", "2026-10-30T12:00:00.000Z"],
      ["2026-10-30T13:00:00.000Z", "2026-10-30T18:00:00.000Z"],
    ]);
  });
  it("uses the patient's clock when the clinic is in another zone", () => {
    // Clinic in Lagos (UTC+1), patient in Kigali (UTC+2): patient 08:00 is clinic 07:00.
    const now = new Date("2026-10-30T05:30:00Z");
    const windows = allowedWindows(rules, now, { patient: 120, clinic: 60 });
    expect(windows[0].start.toISOString()).toBe("2026-10-30T07:00:00.000Z");
    expect(windows[0].end.toISOString()).toBe("2026-10-30T17:00:00.000Z");
  });
  it("splits windows into slots and clamps the expiry", () => {
    const now = new Date("2026-10-30T10:00:00Z");
    expect(slots(allowedWindows(rules, now, lagos), 120)[0].end.toISOString()).toBe("2026-10-30T12:00:00.000Z");
    expect(clampExpiry(24)).toBe(24);
    expect(clampExpiry(100)).toBe(72);
    expect(clampExpiry(0)).toBe(24);
  });
  it("finds the end of today's clinic hours", () => {
    expect(clinicCloseToday(rules.clinicHours, new Date("2026-10-30T10:00:00Z"), 60)?.toISOString()).toBe("2026-10-30T17:00:00.000Z");
    expect(clinicCloseToday(rules.clinicHours, new Date("2026-11-01T10:00:00Z"), 60)).toBeNull(); // Sunday
  });
  it("blocks at one a day or three a week and says when", () => {
    const now = new Date("2026-10-30T10:00:00Z");
    const limits = { usedToday: 0, usedThisWeek: 2, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null };
    expect(limitState(limits, "Mariam", now)).toEqual({ blocked: false, message: "2 of 3 check-ins used this week, across all of Mariam's clinicians." });
    expect(limitState({ ...limits, usedToday: 1, nextPossibleAt: "2026-10-31T07:00:00Z" }, "Mariam", now).message).toContain("The next one is possible tomorrow at");
    expect(limitState({ ...limits, usedThisWeek: 3 }, "Mariam", now).blocked).toBe(true);
  });
});

describe("the check-in event stream", () => {
  const start: CheckIn = {
    id: "c1",
    patientId: "p-mariam",
    patientName: "Mariam Adeyemi",
    requestedBy: "Dr Emeka Okafor",
    requestedAt: "2026-10-30T10:00:00Z",
    plan: { questions: [], closingIds: [], window: { kind: "now" }, expiresAt: "2026-10-31T10:00:00Z", linkedFindingIds: [], linkedTiers: [], readToday: true },
    status: "sent",
    statusAt: "2026-10-30T10:00:00Z",
    history: [{ status: "sent", at: "2026-10-30T10:00:00Z", detail: null }],
    later: null,
    window: null,
    urgent: false,
    urgentAcknowledgedAt: null,
    notes: [],
    summary: null,
    transcript: [],
    alerts: [],
    lastEventId: 1,
  };
  const note = (id: string, at: string, urgent = false): CheckInNote => ({
    id,
    kind: urgent ? "red_flag" : "symptom",
    text: id,
    quote: "",
    questionId: null,
    transcriptLineIds: [],
    at,
    urgent,
    acknowledgedAt: null,
    acknowledgedBy: null,
    disputed: false,
    addedToCheckAt: null,
    validation: "matched",
    endedEarly: false,
  });
  const events: CheckInEvent[] = [
    { id: 2, type: "status", at: "2026-10-30T10:00:02Z", status: "ringing", detail: null, later: null },
    { id: 3, type: "status", at: "2026-10-30T10:00:08Z", status: "in_progress", detail: null, later: null },
    { id: 5, type: "note", at: "2026-10-30T10:00:20Z", note: note("n2", "2026-10-30T10:00:20Z") },
    { id: 4, type: "note", at: "2026-10-30T10:00:15Z", note: note("n1", "2026-10-30T10:00:15Z") },
    { id: 6, type: "transcript", at: "2026-10-30T10:00:21Z", line: { id: "t1", speaker: "patient", text: "No vomiting.", offsetSeconds: 13, at: "2026-10-30T10:00:21Z" } },
  ];
  it("folds events in id order and skips replays", () => {
    const state = applyEvents(start, events);
    expect(state.status).toBe("in_progress");
    expect(state.notes.map((n) => n.id)).toEqual(["n1", "n2"]);
    expect(state.lastEventId).toBe(6);
    expect(state.history.map((h) => h.status)).toEqual(["sent", "ringing", "in_progress"]);
    // A reconnect replays from Last-Event-ID 3; nothing is doubled.
    const again = applyEvents(state, events.slice(2));
    expect(again).toBe(state);
  });
  it("marks the call urgent on a red flag and tracks acknowledgement", () => {
    const state = applyEvents(start, [...events, { id: 7, type: "note", at: "2026-10-30T10:00:30Z", note: note("n3", "2026-10-30T10:00:30Z", true) }]);
    expect(state.urgent).toBe(true);
    expect(unacknowledged(state).map((n) => n.id)).toEqual(["n3"]);
    const acked = applyEvent(state, { id: 8, type: "note_updated", at: "2026-10-30T10:01:00Z", note: { ...note("n3", "2026-10-30T10:00:30Z", true), acknowledgedAt: "2026-10-30T10:01:00Z" } });
    expect(unacknowledged(acked)).toEqual([]);
    expect(acked.notes).toHaveLength(3);
  });
  it("settles only when a completed call has its summary", () => {
    const done = applyEvent(start, { id: 9, type: "status", at: "2026-10-30T10:01:00Z", status: "completed", detail: null, later: null });
    expect(isSettled(done)).toBe(false);
    const summarised = applyEvent(done, { id: 10, type: "summary", at: "2026-10-30T10:01:05Z", summary: { sentences: [], createdAt: "2026-10-30T10:01:05Z" } });
    expect(isSettled(summarised)).toBe(true);
    expect(isSettled({ status: "declined", summary: null })).toBe(true);
  });
  it("keeps the chosen later time", () => {
    const later = applyEvent(start, { id: 2, type: "status", at: "2026-10-30T10:01:00Z", status: "later", detail: null, later: { start: "2026-10-30T15:00:00Z", end: "2026-10-30T17:00:00Z" } });
    expect(later.later?.start).toBe("2026-10-30T15:00:00Z");
  });
});