marmar-context / web / context / src / clinician / patient / Shared.tsx
Shared.tsx
Raw
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { clinicianApi } from "../../api/client";
import type { PatientDetail, SharedEnvelope, SharedSection } from "../../api/types";
import { formatDate } from "../../flow";
import { asOfLabel, firstName, isNewSince, scheduleLabel, whenLabel } from "../../logic/format";
import { sharedSections } from "../../logic/patients";
import { DoseBars, LabsPanel, NewPill } from "../charts";
import { Card, cn, Icon, type IconName, Notice, SectionHeader, StatusPill } from "../kit";
import { ErrorNote, Spinner, useLoad } from "../ui";

const New = ({ at, since }: { at: string; since: string | null }) => (isNewSince(at, since) ? <NewPill /> : null);

/** Load one shared section, refresh it every minute, and record that the clinician looked (SHR-6). */
function useShared<T extends SharedEnvelope>(patientId: string, section: SharedSection, load: () => Promise<T>) {
  const data = useLoad(load, [patientId, section]);
  const marked = useRef(false);
  useEffect(() => {
    const t = setInterval(() => data.reload(true), 60_000);
    return () => clearInterval(t);
  }, [data.reload]);
  useEffect(() => {
    if (!data.data || marked.current) return;
    marked.current = true;
    void clinicianApi.viewed(patientId, section).catch(() => undefined);
  }, [data.data, patientId, section]);
  return data;
}

function Section<T extends SharedEnvelope>({
  title,
  icon,
  tint,
  state,
  children,
}: {
  title: string;
  icon: IconName;
  tint: string;
  state: ReturnType<typeof useShared<T>>;
  children: (d: T) => ReactNode;
}) {
  return (
    <section aria-label={title}>
      <Card as="div" className="flex flex-col gap-4">
        <SectionHeader
          title={
            <>
              <span className={cn("flex size-7 items-center justify-center rounded-lg bg-page shadow-card", tint)}>
                <Icon name={icon} />
              </span>
              {title}
            </>
          }
          action={state.data ? <span className="font-mono text-[12px] text-subtle">{asOfLabel(state.data.asOf)}</span> : undefined}
        />
        {state.loading && !state.data && <Spinner />}
        <ErrorNote error={state.error} onRetry={() => state.reload()} />
        {state.data && children(state.data)}
      </Card>
    </section>
  );
}

const rows = "m-0 flex list-none flex-col p-0";
const row = "flex flex-col gap-1 py-3 shadow-[inset_0_-1px_0_var(--color-hairline)] last:shadow-none sm:flex-row sm:items-start sm:justify-between sm:gap-4";
const meta = "text-[12.5px] leading-[18px] text-muted";
const Sub = ({ children }: { children: ReactNode }) => <h3 className="m-0 pt-1 text-[13px] leading-[18px] font-medium text-muted">{children}</h3>;

function Medicines({ patient }: { patient: PatientDetail }) {
  const state = useShared(patient.id, "medicines", () => clinicianApi.sharedMedicines(patient.id));
  return (
    <Section title="Medicines" icon="capsule-line" tint="text-indigo-icon" state={state}>
      {(d) => (
        <>
          {d.withheldSome && (
            <Notice tone="amber" icon="information-line">
              Not everything was shared. {firstName(patient.name)} held back some medicines or conditions.
            </Notice>
          )}
          <ul className={rows}>
            {d.medicines.map((m) => {
              const stopped = m.status === "stopped";
              return (
                <li key={m.id} className={row}>
                  <div className="flex min-w-0 flex-col gap-1">
                    <span className="flex flex-wrap items-center gap-2">
                      <strong className={cn("text-[14px] leading-5 font-medium", stopped ? "text-muted line-through" : "text-ink")}>
                        {m.name} {m.strength}
                      </strong>
                      {stopped && (
                        <StatusPill tone="grey" size="sm" icon="forbid-fill">
                          Stopped {m.stoppedAt ? formatDate(m.stoppedAt) : ""}
                        </StatusPill>
                      )}
                      <New at={m.changedAt} since={d.lastLookedAt} />
                    </span>
                    <span className={meta}>
                      {[scheduleLabel(m.schedule), m.purpose && `for ${m.purpose.toLowerCase()}`, m.startDate && `since ${formatDate(m.startDate)}`, m.origin.replace(/_/g, " "), m.prescriber && `prescribed by ${m.prescriber}`].filter(Boolean).join(" · ")}
                    </span>
                  </div>
                  <span className="shrink-0 text-[12px] leading-[18px] text-subtle">
                    Added by {m.addedBy} · {formatDate(m.addedAt.slice(0, 10))}
                  </span>
                </li>
              );
            })}
          </ul>
          <Sub>Allergies</Sub>
          {d.allergies.length ? (
            <ul className={rows}>
              {d.allergies.map((a) => (
                <li key={a.id} className={row}>
                  <div className="flex min-w-0 flex-col gap-1">
                    <span className="flex flex-wrap items-center gap-2">
                      <strong className="text-[14px] leading-5 font-medium text-ink">{a.substance}</strong> <New at={a.changedAt} since={d.lastLookedAt} />
                    </span>
                    <span className={meta}>
                      {[a.reaction ? `Reaction: ${a.reaction}` : "Reaction not recorded", a.onset, a.severity, a.year && `in ${a.year}`, a.treatment, a.recordedWhere && `recorded at ${a.recordedWhere}`].filter(Boolean).join(" · ")}
                    </span>
                  </div>
                  <span className="shrink-0 text-[12px] leading-[18px] text-subtle">Added by {a.addedBy}</span>
                </li>
              ))}
            </ul>
          ) : (
            <p className={cn("m-0", meta)}>No allergies shared. Unknown is not the same as none.</p>
          )}
          <Sub>Conditions and details</Sub>
          <dl className="m-0 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
            {[
              ["Conditions", d.conditions.map((c) => c.name).join(", ") || "None shared"],
              ["Date of birth", d.profile.dateOfBirth ? formatDate(d.profile.dateOfBirth) : d.profile.age ? `Age ${d.profile.age}` : "Not recorded"],
              ["Sex", d.profile.sex ?? "Not recorded"],
              ["Weight", d.profile.weightKg ? `${d.profile.weightKg} kg${d.profile.weightRecordedAt ? ` · ${formatDate(d.profile.weightRecordedAt.slice(0, 10))}` : ""}` : "Not recorded"],
              ...(d.profile.pregnancy ? [["Pregnancy", d.profile.pregnancy.replace("_", " ")]] : []),
            ].map(([term, value], i) => (
              <div key={term} className={cn("flex min-w-0 flex-col gap-1", i === 0 && "col-span-2 sm:col-span-4")}>
                <dt className="text-[12.5px] text-muted">{term}</dt>
                <dd className="m-0 text-[14px] leading-5 font-medium text-ink tabular-nums">{value}</dd>
              </div>
            ))}
          </dl>
        </>
      )}
    </Section>
  );
}

function Doses({ patient }: { patient: PatientDetail }) {
  const state = useShared(patient.id, "doses", () => clinicianApi.sharedDoses(patient.id));
  return (
    <Section title="Doses taken" icon="calendar-check-line" tint="text-green-icon" state={state}>
      {(d) => (
        <DoseBars
          doses={d}
          notes={
            d.patterns.length > 0 && (
              <div className="flex flex-col gap-2">
                {d.patterns.map((p) => (
                  <Notice key={p.text} tone="amber" icon="error-warning-line">
                    <span className="flex flex-wrap items-center gap-2">
                      {p.text} <New at={p.changedAt} since={d.lastLookedAt} />
                    </span>
                  </Notice>
                ))}
              </div>
            )
          }
        />
      )}
    </Section>
  );
}

function CheckIns({ patient }: { patient: PatientDetail }) {
  const state = useShared(patient.id, "check_ins", () => clinicianApi.sharedCheckIns(patient.id));
  return (
    <Section title="Daily check-ins" icon="chat-1-line" tint="text-blue-icon" state={state}>
      {(d) => (
        <>
          {d.daily.length > 0 && (
            <ul className={rows}>
              {d.daily.map((c) => (
                <li key={c.id} className="flex flex-col gap-1 py-3 shadow-[inset_0_-1px_0_var(--color-hairline)] last:shadow-none">
                  <span className="flex flex-wrap items-center gap-2">
                    <strong className="text-[14px] leading-5 font-medium text-ink">{formatDate(c.date)}</strong> <New at={c.changedAt} since={d.lastLookedAt} />
                  </span>
                  <span className={meta}>{[c.mood && `Mood ${c.mood.toLowerCase()}`, c.energy && `energy ${c.energy.toLowerCase()}`, c.sleep && `sleep ${c.sleep.toLowerCase()}`].filter(Boolean).join(" · ")}</span>
                  {c.sideEffects.length > 0 && <span className={meta}>Side effects: {c.sideEffects.join(", ")}</span>}
                  {c.note && <q className="text-[13px] leading-[19px] text-body">{c.note}</q>}
                </li>
              ))}
            </ul>
          )}
          {!d.daily.length && <p className={cn("m-0", meta)}>No daily check-ins in the last 30 days.</p>}
          <p className={cn("m-0", meta)}>Check-ins you asked Yasmar for are on the Check-ins tab. Other clinicians' check-ins are never shown.</p>
        </>
      )}
    </Section>
  );
}

function Records({ patient }: { patient: PatientDetail }) {
  const state = useShared(patient.id, "records", () => clinicianApi.sharedRecords(patient.id));
  return (
    <Section title="Records" icon="test-tube-line" tint="text-teal-icon" state={state}>
      {(d) => (
        <>
          <LabsPanel labs={d.labs} lastLookedAt={d.lastLookedAt} />
          {d.documents.length > 0 && (
            <>
              <Sub>Documents</Sub>
              <ul className={rows}>
                {d.documents.map((doc) => (
                  <li key={doc.id} className="flex items-center gap-3 py-3 shadow-[inset_0_-1px_0_var(--color-hairline)] last:shadow-none">
                    <Icon name="file-text-line" className="text-muted" />
                    <span className="flex min-w-0 flex-col gap-0.5">
                      <strong className="text-[14px] leading-5 font-medium text-ink">{doc.title}</strong>
                      <span className={meta}>
                        {doc.kind}
                        {doc.date ? ` · ${formatDate(doc.date)}` : ""} · added by {doc.addedBy} {whenLabel(doc.addedAt)}
                      </span>
                    </span>
                  </li>
                ))}
              </ul>
            </>
          )}
          <p className={cn("m-0", meta)}>To use these in a check, read the records on the Check tab: app data goes through review like any record.</p>
        </>
      )}
    </Section>
  );
}

/** SHR-2 to SHR-6, read-only (SHR-8). Each area shows only if the patient shares it. */
export function SharedTab({ patient }: { patient: PatientDetail }) {
  const sections = sharedSections(patient.sharing);
  return (
    <div className="flex flex-col gap-4">
      <p className="m-0 text-[13.5px] leading-5 text-muted">
        What {firstName(patient.name)} shares from MARMAR Health. It is read-only here: changes happen in the app.
        {patient.sharing?.status === "paused" ? " Sharing is paused, so this is as it was when it paused." : ""}
      </p>
      {sections.includes("medicines") && <Medicines patient={patient} />}
      {sections.includes("doses") && <Doses patient={patient} />}
      {sections.includes("check_ins") && <CheckIns patient={patient} />}
      {sections.includes("records") && <Records patient={patient} />}
    </div>
  );
}