marmar-context / web / context / test / fixture / plan.ts
plan.ts
Raw
// The fixture's question bank and plan template (YCI-2 to YCI-6, YCI-20). Fictional wording
// drawn from the PRD; the real bank is signed off by the clinical safety lead.
import type * as T from "../../src/api/types";
import { pronouns } from "../../src/logic/format";

type Patient = Pick<T.PatientDetail, "id" | "kind" | "name" | "firstName" | "phone" | "pronoun" | "timeZone" | "quietHours" | "blockedTimes" | "checkInLimits"> & {
  doses: { taken: number; expected: number } | null;
};

const greeting = (timeZone: string) => {
  const hour = Number(new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", hourCycle: "h23" }).format(new Date()));
  return hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
};

const suggestion = (
  id: string,
  text: string,
  reason: string,
  source: T.QuestionSource,
  suggested: boolean,
  followUps: string[] = [],
): T.QuestionSuggestion => ({ id: `s-${id}`, bankId: id, text, reason, source, followUps, suggested });

/** Questions from the findings, the missing factors and the bank, each with why it is there. */
export function suggestions(patient: Patient, check: T.CheckRun | null): T.QuestionSuggestion[] {
  const her = pronouns(patient.pronoun).possessive;
  const out: T.QuestionSuggestion[] = [];
  const symptom = ["When did it start?", "How often?", "How bad is it?"];
  for (const finding of check?.assessment.contextual.findings ?? []) {
    const detail = check!.details[finding.id];
    const title = detail?.clinicianTitle ?? finding.title;
    const from = `From the finding: ${title}`;
    const source: T.QuestionSource = { kind: "finding", findingId: finding.id, title };
    const medicine = finding.medicines[0]?.name ?? "the medicine";
    if (finding.ruleId.startsWith("allergy-")) {
      const allergen = check!.assessment.includedFacts.find((f) => f.kind === "allergy")?.label.toLowerCase() ?? "it";
      out.push(
        suggestion("medicine-bought", `Have you bought the ${medicine}, the new antibiotic?`, from, source, true),
        suggestion("allergy-history", `Your records say ${allergen} gave you a reaction. What happened?`, from, source, true, [
          "How soon after?",
          "Did your face or lips swell, or was breathing hard?",
          "Did you go to hospital?",
        ]),
        suggestion("medicine-started", "Have you started it?", from, source, false),
        suggestion("medicine-pack", "What name is on the pack?", from, source, false),
      );
    } else if (finding.ruleId.startsWith("lab-metformin")) {
      out.push(
        suggestion("metformin-stomach", "Since starting Metformin, have you had stomach upset or loose stools?", from, source, false, symptom),
        suggestion("kidney-test", "Have you had a kidney test since October?", from, source, false, ["Where?"]),
      );
    } else if (finding.ruleId.startsWith("lab-potassium")) {
      out.push(suggestion("potassium-symptoms", "Have you had any muscle weakness, or a fast or uneven heartbeat?", from, source, true, symptom));
    }
  }
  for (const factor of check?.missingFactors ?? [])
    if (factor.id === "weight")
      out.push(suggestion("weight", "Do you know your weight?", `Missing: weight · ${factor.reason.toLowerCase()}`, { kind: "missing", factor: "weight" }, false));
  if (patient.doses && patient.doses.taken < patient.doses.expected)
    out.push(
      suggestion(
        "missed-doses",
        "Many people miss doses. Did you miss any this week?",
        `From ${her} doses: ${patient.doses.taken} of ${patient.doses.expected} taken this week`,
        { kind: "bank", topic: "doses" },
        false,
      ),
    );
  out.push(
    suggestion("other-medicines", "Do you take anything from the chemist or the market, or any herbs?", "From the question bank: other medicines", { kind: "bank", topic: "other medicines" }, true, [
      "For pain, like Alabukun?",
      "Which agbo, and how often?",
    ]),
  );
  const seen = new Set<string>();
  return out.filter((s) => !seen.has(s.bankId) && seen.add(s.bankId));
}

/** GET /patients/:id/check-ins/plan. */
export function planTemplate(
  patient: Patient,
  clinician: T.ClinicianProfile,
  clinic: T.ClinicProfile,
  check: T.CheckRun | null,
  alwaysOpen = false,
  eligibility: T.CheckInPlanTemplate["eligibility"] = { ok: true, code: null, message: null },
  used = 0,
): T.CheckInPlanTemplate {
  const doctor = clinician.spokenName;
  const he = pronouns(clinician.pronoun);
  const emergency = clinic.emergencyNumbers.map((n) => n.number).join(" or ");
  return {
    patientId: patient.id,
    protocolVersion: "yci-protocol-2026-10-30-draft",
    bankVersion: "yci-bank-2026-10-30-draft",
    eligibility,
    urgentFirst: check?.assessment.contextual.findings.some((f) => f.tier === "get_help_now")
      ? { phone: patient.phone, confirmLine: `I will speak to ${patient.firstName} today.` }
      : null,
    fairUse: { used, cap: 30 },
    opening: [`${greeting(patient.timeZone)}. I'm Yasmar, MARMAR's computer assistant. I'm not a person. I'm calling for ${doctor}'s clinic.`],
    identity: [`Am I speaking with ${patient.name}?`, "Please tell me your date of birth."],
    consent: `Your answers go to ${doctor}'s clinic, which may read them while we talk. MARMAR keeps the text of this call for 12 months. I can't give medical advice, and this isn't an emergency line. You can skip any question or stop at any time. Shall we go on?`,
    feeling: "How have you been feeling?",
    feelingFollowUps: ["When did it start?", "How often?", "How bad is it?"],
    suggestions: suggestions(patient, check),
    alwaysLast: `Anything else ${doctor} should know?`,
    closings: [
      { id: "sent", text: `Thank you. I've sent your answers to ${doctor}'s clinic.`, requires: "none", fixed: false },
      { id: "read_today", text: `I'll let ${doctor} know today.`, requires: "read_today", fixed: false },
      { id: "see_in_clinic", text: `${he.subject[0].toUpperCase()}${he.subject.slice(1)} may want to see you in clinic soon.`, requires: "none", fixed: false },
      { id: "call_by_5", text: "The clinic will call you by 5 pm.", requires: "reminder", fixed: false },
      { id: "safety_net", text: `If anything gets worse, call ${he.possessive} clinic on ${clinic.phone}; if it's closed, go to a hospital.`, requires: "none", fixed: true },
    ],
    safetyNet: `If you have trouble breathing, chest pain, a swollen face or lips, if you faint, or feel much worse, call ${emergency} or go to the nearest hospital now.`,
    window: {
      patientTimeZone: patient.timeZone,
      clinicTimeZone: clinic.timeZone,
      clinicHours: clinic.openingHours,
      alertWatchHours: clinic.alertWatchHours,
      dayStart: alwaysOpen ? "00:00" : "08:00",
      dayEnd: alwaysOpen ? "24:00" : "20:00",
      quietHours: alwaysOpen ? { start: "00:00", end: "00:00" } : patient.quietHours,
      blockedTimes: patient.blockedTimes,
      defaultExpiryHours: 24,
      maxExpiryHours: 72,
      known: patient.kind === "connected",
    },
    limits: patient.checkInLimits,
    maxQuestions: 10,
    maxOwnQuestions: 3,
    maxQuestionLength: 200,
  };
}