marmar-context / web / context / src / clinician / checkin / PlanEditor.tsx
PlanEditor.tsx
Raw
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { clinicianApi } from "../../api/client";
import type { CheckInPlanTemplate, PatientDetail, PlanQuestion } from "../../api/types";
import { checkInEligibility } from "../../logic/eligibility";
import { firstName, pronouns } from "../../logic/format";
import { buildScript, closingAvailable, fromSuggestion, precheckQuestion } from "../../logic/questions";
import { urgentGate } from "../../logic/urgent";
import { allowedWindows, canStartNow, clampExpiry, limitState, slots, windowLabel, zoneOffset } from "../../logic/windows";
import { UrgentBanner } from "../check/FindingsView";
import { Button, Callout, cn, fieldClass, Icon } from "../kit";
import { tabPath } from "../patient/links";
import { Link, navigate, useLocation } from "../router";
import { useClinician } from "../session";
import { errorText, ErrorNote, Spinner, useLoad } from "../ui";
import { useCallConfirmation } from "./confirmations";
import { CheckRow, Closing, Expiry, Hint, PlanCard, QuestionOrder, ScriptPreview, WhenChoice } from "./PlanParts";

function NotNow({ patient, message }: { patient: PatientDetail; message: string }) {
  return (
    <PlanCard title="Ask Yasmar to check in">
      <p className="m-0 text-[14px] leading-[21px] text-ink">{message}</p>
      <Button variant="quiet" asChild className="self-start no-underline">
        <Link to={tabPath(patient.id, "check_ins")}>Back to check-ins</Link>
      </Button>
    </PlanCard>
  );
}

/** YCI-2 to YCI-6: the plan, exactly what Yasmar will say, and when. */
export function PlanEditor({ patient }: { patient: PatientDetail }) {
  const { query } = useLocation();
  const checkId = query.get("checkId") ?? patient.lastCheckId;
  const findingId = query.get("findingId");
  const template = useLoad(() => clinicianApi.planTemplate(patient.id, checkId, findingId), [patient.id, checkId, findingId]);
  const check = useLoad(() => (checkId ? clinicianApi.check(checkId) : Promise.resolve(null)), [checkId]);
  const eligibility = checkInEligibility(patient);
  if (!eligibility.allowed) return <NotNow patient={patient} message={eligibility.message} />;
  if (template.loading || check.loading) return <Spinner label="Preparing the plan" />;
  if (template.error || !template.data) return <ErrorNote error={template.error} onRetry={() => template.reload()} />;
  // The server checks eligibility too (YCI-1); its answer wins.
  if (!template.data.eligibility.ok) return <NotNow patient={patient} message={template.data.eligibility.message ?? "Yasmar cannot check in with this patient now."} />;
  return <Editor patient={patient} template={template.data} findings={check.data?.assessment.contextual.findings ?? []} checkId={checkId} findingId={findingId} notes={eligibility.notes} />;
}

function Editor({ patient, template, findings, checkId, findingId, notes }: { patient: PatientDetail; template: CheckInPlanTemplate; findings: { id: string; tier: string }[]; checkId: string | null; findingId: string | null; notes: string[] }) {
  const { clinic, me } = useClinician();
  const first = firstName(patient.name);
  const them = pronouns(patient.pronoun).object;
  const [chosen, setChosen] = useState<PlanQuestion[]>(() => {
    // Suggested questions; those from the finding the clinician came from go first.
    const fromFinding = (s: (typeof template.suggestions)[number]) => s.source.kind === "finding" && s.source.findingId === findingId;
    const picks = template.suggestions.filter((s) => s.suggested);
    // Leave room for the clinician's own questions under the plan's limit.
    return [...picks.filter(fromFinding), ...picks.filter((s) => !fromFinding(s))].slice(0, template.maxQuestions - template.maxOwnQuestions).map(fromSuggestion);
  });
  const [ownText, setOwnText] = useState("");
  const [ownError, setOwnError] = useState("");
  const [checking, setChecking] = useState(false);
  const [readToday, setReadToday] = useState(false);
  const [reminder, setReminder] = useState(false);
  const [closingIds, setClosingIds] = useState<string[]>(["sent"]);
  const [when, setWhen] = useState<"now" | number>("now");
  const [expiry, setExpiry] = useState(template.window.defaultExpiryHours);
  const [sending, setSending] = useState(false);
  const [error, setError] = useState("");
  const confirmation = useCallConfirmation(patient.id);

  const now = new Date();
  const offsets = { patient: zoneOffset(template.window.patientTimeZone, now), clinic: zoneOffset(template.window.clinicTimeZone, now) };
  const windows = useMemo(() => allowedWindows(template.window, new Date(), offsets), [template.window, offsets.patient, offsets.clinic]);
  const choices = useMemo(() => slots(windows, 120).slice(0, 12), [windows]);
  const nowOk = canStartNow(windows, now);
  const choice = when === "now" ? (nowOk ? "now" : null) : choices[when] ?? null;
  const limits = limitState(template.limits, first);
  const gate = urgentGate({ findings, patientId: patient.id, patientName: patient.name, confirmation, now, clinicOffset: offsets.clinic });
  const own = chosen.filter((q) => q.origin === "own");
  const effectiveClosings = closingIds.filter((id) => {
    const line = template.closings.find((c) => c.id === id);
    return line && closingAvailable(line, readToday, reminder);
  });
  const script = buildScript(template, chosen, effectiveClosings, readToday);
  const blocker = limits.blocked
    ? limits.message
    : !gate.canAskYasmar
      ? `Call ${first} first, then confirm you will speak to ${them} today.`
      : !choice
        ? "Choose when Yasmar may ring."
        : !chosen.length
          ? "Choose at least one question."
          : chosen.length > template.maxQuestions
            ? `Choose up to ${template.maxQuestions} questions.`
            : null;

  const toggle = (id: string) => {
    const s = template.suggestions.find((x) => x.id === id)!;
    setChosen((c) => (c.some((q) => q.id === id) ? c.filter((q) => q.id !== id) : [...c, fromSuggestion(s)]));
  };
  const addOwn = async () => {
    const pre = precheckQuestion(ownText, first, them);
    if (!pre.ok) return setOwnError(pre.reason);
    if (own.length >= template.maxOwnQuestions) return setOwnError(`Add up to ${template.maxOwnQuestions} questions of your own.`);
    setChecking(true);
    try {
      const result = await clinicianApi.validateQuestion({ patientId: patient.id, text: pre.text });
      if (!result.ok) return setOwnError(result.reason);
      setChosen((c) => [...c, { id: `own-${Date.now()}`, text: result.text, origin: "own", bankId: null, reason: "Your question", findingId: null }]);
      setOwnText("");
      setOwnError("");
    } catch (e) {
      setOwnError(errorText(e));
    } finally {
      setChecking(false);
    }
  };
  const send = async () => {
    if (blocker || !choice) return;
    setSending(true);
    setError("");
    try {
      const created = await clinicianApi.createCheckIn(patient.id, {
        questions: chosen,
        closingIds: effectiveClosings,
        window: choice === "now" ? { kind: "now" } : { kind: "window", start: choice.start.toISOString(), end: choice.end.toISOString() },
        expiresInHours: clampExpiry(expiry, template.window.maxExpiryHours),
        linkedFindingIds: [...new Set(chosen.map((q) => q.findingId).filter((x): x is string => !!x))],
        checkId,
        urgentCallConfirmedAt: confirmation?.confirmedAt ?? null,
        readToday,
      });
      navigate(`/app/patients/${patient.id}/check-ins/${created.id}`);
    } catch (e) {
      setError(errorText(e));
      setSending(false);
    }
  };

  const lead = (children: ReactNode) => <p className="m-0 max-w-[680px] text-[14px] leading-[21px] text-body">{children}</p>;
  if (!gate.canAskYasmar)
    return (
      <div className="flex flex-col gap-4">
        <UrgentBanner patient={patient} findings={findings} />
        {lead(<>Ask Yasmar to check in opens after you confirm you will speak to {first} today.</>)}
      </div>
    );
  return (
    <div className="flex min-w-0 flex-col gap-4">
      <header className="flex flex-col gap-2 pb-1">
        <h2 className="m-0 text-[20px] leading-[26px] font-semibold tracking-[-0.015em] text-ink">Ask Yasmar to check in with {first}</h2>
        {lead(
          <>
            Yasmar calls through {first}'s MARMAR Health app for {clinic.name}. She asks your questions one at a time and brings back the answers as notes. She never gives advice.
          </>,
        )}
        {notes.map((n) => (
          <Hint key={n}>{n}</Hint>
        ))}
      </header>
      <UrgentBanner patient={patient} findings={findings} />
      <PlanCard title="Yasmar starts with" icon="lock-line" note="Fixed, signed-off lines">
        <ol className="m-0 flex list-none flex-col gap-1 p-0">
          {[...template.opening, ...template.identity, template.consent, template.feeling].map((l) => (
            <li key={l} className="rounded-lg bg-tile px-3 py-2 text-[14px] leading-5 text-cell">
              {l}
            </li>
          ))}
        </ol>
        <Hint>Nothing clinical is said until {first}'s name and date of birth match MARMAR Health.</Hint>
      </PlanCard>
      <PlanCard title="Questions" note={`${chosen.length} chosen`}>
        <fieldset className="m-0 flex flex-col border-0 p-0">
          <legend className="mb-1 p-0 text-[12.5px] font-medium text-cell">Suggested from the check and {first}'s data</legend>
          {template.suggestions.map((s) => (
            <CheckRow key={s.id} checked={chosen.some((q) => q.id === s.id)} onChange={() => toggle(s.id)}>
              <b className="font-medium">{s.text}</b>
              <small className="text-[12.5px] leading-[18px] text-muted">{s.reason}</small>
              {s.followUps.length > 0 && <small className="text-[12px] leading-[17px] text-subtle">Follow-ups Yasmar may use: {s.followUps.join(" · ")}</small>}
            </CheckRow>
          ))}
        </fieldset>
        <div className="flex flex-col gap-1.5 pt-3 shadow-[inset_0_1px_0_var(--color-hairline)]">
          <label htmlFor="own-q" className="text-[12.5px] font-medium text-cell">
            Your own question{" "}
            <small className="font-normal text-muted">
              · up to {template.maxOwnQuestions}, {template.maxQuestionLength} characters each
            </small>
          </label>
          <div className="flex flex-wrap gap-2">
            <input
              id="own-q"
              className={cn(fieldClass, "min-w-0 flex-1 basis-64")}
              value={ownText}
              maxLength={template.maxQuestionLength + 20}
              placeholder="For example: Do you take the Metformin with food?"
              aria-invalid={ownError ? true : undefined}
              aria-describedby={ownError ? "own-q-error" : "own-q-count"}
              disabled={own.length >= template.maxOwnQuestions}
              onChange={(e) => {
                setOwnText(e.target.value);
                setOwnError("");
              }}
              onKeyDown={(e) => {
                if (e.key === "Enter") {
                  e.preventDefault();
                  void addOwn();
                }
              }}
            />
            <Button variant="quiet" className="h-[38px]" disabled={checking || !ownText.trim() || own.length >= template.maxOwnQuestions} onClick={() => void addOwn()}>
              <Icon name="add-line" size={15} /> {checking ? "Checking…" : "Check and add"}
            </Button>
          </div>
          <Hint id="own-q-count">
            <span className="font-mono tabular-nums">
              {ownText.trim().length} of {template.maxQuestionLength}
            </span>{" "}
            · questions only: no advice, instructions, results, diagnosis or medicine changes.
          </Hint>
          {ownError && (
            <p className="m-0 text-[12.5px] font-medium text-red-text" id="own-q-error" role="alert">
              {ownError}
            </p>
          )}
        </div>
        <h3 className="m-0 pt-2 text-[13px] font-semibold text-ink">Order Yasmar will ask them</h3>
        <QuestionOrder chosen={chosen} alwaysLast={template.alwaysLast} onChange={setChosen} />
      </PlanCard>
      <PlanCard title="Closing" note="Approved lines">
        <Closing template={template} first={first} readToday={readToday} reminder={reminder} closingIds={closingIds} onReadToday={setReadToday} onReminder={setReminder} onClosings={setClosingIds} />
      </PlanCard>
      <PlanCard title="When" icon="time-line">
        <WhenChoice nowOk={nowOk} choices={choices} when={when} patientOffset={offsets.patient} now={now} onWhen={setWhen} />
        {!nowOk && <Hint>Yasmar cannot ring {first} now. Choose a time below that is allowed.</Hint>}
        {!choices.length && !nowOk && (
          <Callout
            tone="amber"
            action={
              <Link to="/app/settings/hours" className="text-[13px] font-medium text-amber-text underline decoration-amber-line underline-offset-4 hover:decoration-amber-text">
                Open calling hours
              </Link>
            }
          >
            No allowed time today or tomorrow. Check your clinic and alert-watch hours in Settings.
          </Callout>
        )}
        <Hint>
          Yasmar rings only inside your clinic hours and the hours someone watches for urgent alerts, between {template.window.dayStart} and {template.window.dayEnd} for {first}, and never in {pronouns(patient.pronoun).possessive} quiet hours or blocked times. Times are {first}'s.
        </Hint>
        <Expiry value={expiry} max={template.window.maxExpiryHours} onChange={setExpiry} />
        <Hint className={limits.blocked ? "font-medium text-red-text" : undefined}>{limits.message}</Hint>
      </PlanCard>
      <PlanCard title="Exactly what Yasmar will say" note={`${me.spokenName} · ${clinic.name}`}>
        <ScriptPreview script={script} />
        <Hint>Yasmar may repeat back an unclear answer and use the approved follow-ups. If {first} mentions a red flag, she gives the emergency line and alerts you at once.</Hint>
      </PlanCard>
      {error && (
        <Callout tone="red" role="alert">
          {error}
        </Callout>
      )}
      <div className="sticky bottom-3 z-20 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-page px-4 py-3 shadow-pop [body:has(#nl-badge-frame)_&]:pr-[220px] max-sm:[body:has(#nl-badge-frame)_&]:pr-4">
        <span className={cn("min-w-0 flex-1 basis-56 text-[13px] leading-[19px]", blocker ? "text-amber-text" : "text-green-text")} role="status">
          {blocker ?? `${chosen.length} questions · ${choice === "now" ? "rings now" : choice ? windowLabel(choice, offsets.patient, now) : ""}`}
        </span>
        <Button variant="primary" disabled={!!blocker || sending} onClick={() => void send()} className="max-sm:flex-1">
          <Icon name="send-plane-line" size={15} /> {sending ? "Sending…" : "Ask Yasmar to check in"}
        </Button>
      </div>
    </div>
  );
}