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");
});
});