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