import { useEffect, useMemo, useRef, useState } from "react";
import { clinicianApi } from "../../api/client";
import { followCheckIn, type StreamMode } from "../../api/events";
import type { CheckIn, PatientDetail } from "../../api/types";
import { applyEvents, isEnded, isLive } from "../../logic/checkin-events";
import { clock, dayLabel, firstName } from "../../logic/format";
import { checkInPill } from "../../logic/pills";
import { notReachedAlert } from "../../logic/urgent";
import { clinicCloseToday, zoneOffset } from "../../logic/windows";
import { Button, Callout, cn, Icon, linkButtonClass } from "../kit";
import { askForNotifications, notificationsAllowed, notificationsSupported, notify } from "../notify";
import { tabPath } from "../patient/links";
import { Pill } from "../Pill";
import { Link, navigate } from "../router";
import { alertsChanged } from "../Shell";
import { shortName, useClinician } from "../session";
import { errorText, ErrorNote, Spinner } from "../ui";
import { CallText, NotesList, StatusTrail, SummaryCard, UrgentBox } from "./LiveParts";
const tel = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
const onRed = "bg-page text-red-text hover:bg-white/80 no-underline";
/** YCI-23 to YCI-29: "Check-in call · Live", notes as they arrive, the summary and the call text. */
export function LivePanel({ patient, checkInId, onChanged }: { patient: PatientDetail; checkInId: string; onChanged: () => void }) {
const { me, clinic } = useClinician();
const [call, setCall] = useState<CheckIn | null>(null);
const [error, setError] = useState<unknown>(null);
const [mode, setMode] = useState<StreamMode>("closed");
const [picked, setPicked] = useState<string[]>([]);
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(0);
const [lit, setLit] = useState<string | null>(null);
const [notifyOn, setNotifyOn] = useState(notificationsAllowed() && me.notifications.browser);
const prev = useRef<CheckIn | null>(null);
const first = firstName(patient.name);
const doctor = shortName(me);
useEffect(() => {
let sub: { close: () => void } | null = null;
let alive = true;
clinicianApi.checkIn(checkInId).then(
(snapshot) => {
if (!alive) return;
setCall(snapshot);
sub = followCheckIn(checkInId, snapshot.lastEventId, (events) => setCall((c) => (c ? applyEvents(c, events) : c)), setMode);
},
(e) => alive && setError(e),
);
return () => {
alive = false;
sub?.close();
};
}, [checkInId]);
// Read receipts, browser notifications and the patient list react to changes.
useEffect(() => {
if (!call) return;
const before = prev.current;
prev.current = call;
if (call.notes.length && (!before || before.notes.length !== call.notes.length || before.status !== call.status)) void clinicianApi.markCheckInRead(call.id).catch(() => undefined);
if (!before) return;
const newUrgent = call.notes.filter((n) => n.urgent && !before.notes.some((b) => b.id === n.id));
if (newUrgent.length) {
alertsChanged();
if (notifyOn) notify("Urgent check-in note", `${patient.name}'s check-in has an urgent note in Context.`, `urgent-${call.id}`);
}
if (before.status !== "completed" && call.status === "completed") {
onChanged();
if (notifyOn && document.hidden) notify("Check-in finished", `${patient.name}'s check-in is ready in Context.`, `done-${call.id}`);
}
}, [call, notifyOn, onChanged, patient.name]);
const close = useMemo(() => clinicCloseToday(clinic.openingHours, new Date(), zoneOffset(clinic.timeZone)), [clinic]);
if (error) return <ErrorNote error={error} />;
if (!call) return <Spinner label="Opening the check-in" />;
const late = notReachedAlert({ status: call.status, linkedTiers: call.plan.linkedTiers, patientName: patient.name, clinicCloseAt: close, now: new Date() });
const noteNumber = (id: string) => call.notes.findIndex((n) => n.id === id) + 1;
const jump = (lineIds: string[]) => {
const id = lineIds[0];
if (!id) return;
setLit(id);
document.getElementById(`line-${call.id}-${id}`)?.scrollIntoView({ behavior: "smooth", block: "center" });
};
const acknowledge = async (noteId: string) => {
try {
const note = await clinicianApi.acknowledgeNote(call.id, noteId);
setCall((c) => (c ? { ...c, notes: c.notes.map((n) => (n.id === note.id ? note : n)) } : c));
alertsChanged();
} catch (e) {
setError(e);
}
};
const addToCheck = async () => {
setAdding(true);
try {
// One record from the call for the whole choice, as the backend's batch route makes it.
const { notes } = await clinicianApi.addNotesToCheck(call.id, picked);
const byId = new Map(notes.map((note) => [note.id, note]));
setCall((c) => (c ? { ...c, notes: c.notes.map((n) => byId.get(n.id) ?? n) } : c));
setAdded(picked.length);
setPicked([]);
onChanged();
} catch (e) {
setError(errorText(e));
} finally {
setAdding(false);
}
};
const callButton = (className: string) =>
patient.phone && (
<Button asChild size="sm" className={className}>
<a href={tel(patient.phone)}>
<Icon name="phone-line" size={14} /> Call {first}
</a>
</Button>
);
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="grid grid-cols-1 items-start gap-5 @min-[1100px]/pd:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
<section aria-label={`Check-in call with ${patient.name}`} className="flex min-w-0 flex-col gap-4 rounded-2xl bg-page p-5 shadow-card max-sm:p-4">
<header className="flex flex-wrap items-center gap-x-3 gap-y-2">
<img src="/assets/logo-mark.svg" alt="" width={28} height={28} />
<h2 className="m-0 min-w-0 flex-1 text-[17px] leading-6 font-semibold tracking-[-0.01em] text-ink">Check-in call · {patient.name}</h2>
<span role="status" className="inline-flex items-center gap-2">
{isLive(call.status) && <span aria-hidden="true" className="size-2 animate-pulse rounded-full bg-green-icon motion-reduce:animate-none" />}
<Pill pill={checkInPill(call.status)} />
<small className="font-mono text-[12px] text-subtle">· {clock(call.statusAt)}</small>
</span>
</header>
<p className="m-0 text-[13.5px] leading-5 text-cell">
Yasmar asks, {first} answers. Notes appear here for {doctor}.
<span className="text-muted"> {mode === "stream" ? "Live updates." : mode === "polling" ? "Updating every 3 seconds." : ""}</span>
</p>
<StatusTrail call={call} />
{call.status === "later" && call.later && (
<Callout tone="blue" icon="time-line">
{first} chose a later time: {dayLabel(call.later.start).toLowerCase()} {clock(call.later.start)} to {clock(call.later.end)}.
</Callout>
)}
{call.alerts.map((a) => (
<Callout key={a.id} tone={a.kind === "not_reached" || a.kind === "escalated" ? "red" : "grey"} role="status">
{a.message} <small className="font-mono text-[11.5px] opacity-70">· {clock(a.at)}</small>
</Callout>
))}
{late && <UrgentBox title={late} actions={callButton("bg-red-icon text-white hover:bg-red-text no-underline")} />}
{call.notes
.filter((n) => n.urgent)
.map((n) => (
<UrgentBox
key={n.id}
title={`Urgent: ${n.text}`}
actions={
<>
{callButton(onRed)}
{!n.acknowledgedAt && (
<Button size="sm" variant="red" onClick={() => void acknowledge(n.id)}>
<Icon name="checkbox-circle-line" size={15} /> Acknowledge
</Button>
)}
</>
}
>
<q>{n.quote}</q>
<small className="text-[12.5px] leading-[18px]">
{clock(n.at)} · Yasmar gave {first} the emergency line.{" "}
{n.acknowledgedAt ? `Acknowledged by ${n.acknowledgedBy} at ${clock(n.acknowledgedAt)}.` : "Waiting for you to acknowledge."}
</small>
</UrgentBox>
))}
<NotesList call={call} first={first} picked={picked} onPick={(id, on) => setPicked((p) => (on ? [...p, id] : p.filter((x) => x !== id)))} onJump={jump} />
<SummaryCard call={call} doctor={doctor} noteNumber={noteNumber} />
{isEnded(call.status) && call.notes.some((n) => !n.urgent) && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3 shadow-[inset_0_0_0_1px_var(--color-line)]">
{added > 0 ? (
<>
<span className="min-w-0 flex-1 basis-64 text-[13px] leading-[19px] text-green-text">
{added} {added === 1 ? "note" : "notes"} added to {first}'s records, with this call as the source. Notes never remove a recorded fact.
</span>
<Button variant="primary" onClick={() => navigate(tabPath(patient.id, "check"))}>
<Icon name="refresh-line" size={15} /> Run the check again
</Button>
</>
) : (
<>
<span className="min-w-0 flex-1 basis-64 text-[13px] leading-[19px] text-muted">Choose the notes that matter, then add them to the check. They go through review like any fact.</span>
<Button variant="primary" disabled={!picked.length || adding} onClick={() => void addToCheck()}>
{adding ? "Adding…" : `Add ${picked.length || ""} to check`.replace(" ", " ")}
</Button>
</>
)}
</div>
)}
{notificationsSupported() && !notifyOn && (
<p className="m-0 text-[12.5px] text-muted">
<button
className={cn(linkButtonClass, "inline-flex items-center gap-1.5 text-[12.5px] font-normal text-cell")}
onClick={async () => {
const ok = await askForNotifications();
setNotifyOn(ok);
if (ok) void clinicianApi.updateMe({ notifications: { ...me.notifications, browser: true } }).catch(() => undefined);
}}
>
<Icon name="notification-3-line" size={14} /> Tell me in this browser when a check-in finishes or has an urgent note
</button>
</p>
)}
</section>
<CallText call={call} first={first} lit={lit} noteNumber={noteNumber} />
</div>
<p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-muted">
<Link to={tabPath(patient.id, "check_ins")} className="inline-flex items-center gap-1.5 font-medium text-ink no-underline hover:underline hover:decoration-line hover:underline-offset-4">
<Icon name="arrow-left-line" size={14} /> All check-ins for {first}
</Link>
{["sent", "ringing", "later", "missed"].includes(call.status) && (
<>
<span aria-hidden="true">·</span>
<button className={cn(linkButtonClass, "font-normal text-muted")} onClick={() => void clinicianApi.cancelCheckIn(call.id).then(setCall, setError)}>
Cancel this check-in
</button>
</>
)}
</p>
</div>
);
}