marmar-context / web / context / src / clinician / today / TodayPage.tsx
TodayPage.tsx
Raw
import { useEffect, useMemo } from "react";
import { clinicianApi } from "../../api/client";
import type { ClinicProfile, Invitation } from "../../api/types";
import { asOfLabel } from "../../logic/format";
import { clinicHoursLabel, greeting, longDate, needsYou, summaryLine, todayStats, type Todo } from "../../logic/today";
import { zoneOffset } from "../../logic/windows";
import { Card, Dot, Icon, SectionHeader, StatusPill } from "../kit";
import { InvitationCard } from "../patients/Invitations";
import { displayName, useClinician } from "../session";
import Shell from "../Shell";
import { useShellAlerts } from "../shell/context";
import { ErrorNote, Spinner, useLoad, useToast } from "../ui";
import { DosesThisWeek, RecentCheckIns } from "./Lists";
import { NeedsYou } from "./NeedsYou";

/** Today: what needs the clinician first, then doses and check-ins across their patients. */
export default function TodayPage() {
  return (
    <Shell crumbs={[{ label: "Today" }]} active="today">
      <TodayBody />
    </Shell>
  );
}

function hoursPill(clinic: ClinicProfile, now: Date) {
  if (!clinic.openingHours.length) return null;
  try {
    return clinicHoursLabel(clinic.openingHours, now, zoneOffset(clinic.timeZone, now));
  } catch {
    return null;
  }
}

function TodayBody() {
  const { me, clinic } = useClinician();
  const alerts = useShellAlerts();
  const patients = useLoad(() => clinicianApi.patients(), []);
  const invitations = useLoad(() => clinicianApi.invitations(), []);
  const toast = useToast();
  useEffect(() => {
    const t = setInterval(() => patients.reload(true), 30_000);
    return () => clearInterval(t);
  }, [patients.reload]);
  const now = new Date();
  const list = patients.data?.patients;
  const pending = invitations.data?.invitations ?? [];
  const todos = useMemo(() => needsYou(list ?? [], alerts), [list, alerts]);
  const hours = hoursPill(clinic, now);

  return (
    <div className="mx-auto flex w-full max-w-[920px] flex-col gap-6 pt-2 pb-12 md:pt-6">
      <header className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
        <div className="flex min-w-0 flex-col gap-0.5">
          <span className="text-[13.5px] text-muted">
            {greeting(now)} · {longDate(now)}
          </span>
          <h1 className="m-0 text-[26px] leading-8 font-semibold tracking-[-0.02em] text-ink md:text-[30px] md:leading-9">{displayName(me)}</h1>
        </div>
        <span className="inline-flex flex-wrap items-center gap-2 text-[13px] text-cell">
          <Icon name="hospital-line" size={16} className="text-blue-icon" />
          {clinic.name}
          {hours && (
            <StatusPill tone={hours.open ? "green" : "grey"} size="sm">
              <Dot tone={hours.open ? "green" : "grey"} />
              {hours.label}
            </StatusPill>
          )}
        </span>
      </header>
      {toast.node}
      <ErrorNote error={patients.error} onRetry={() => patients.reload()} />
      {!list && patients.loading && <Spinner label="Loading today" />}
      {list && (
        <>
          <Summary todos={todos} pending={pending} stats={todayStats(list, now)} />
          <NeedsYou todos={todos} />
        </>
      )}
      <ErrorNote error={invitations.error} onRetry={() => invitations.reload()} />
      {pending.length > 0 && (
        <section aria-label="Invitations" className="flex flex-col gap-3">
          <SectionHeader title="Invitations" count={pending.length} to="/app/patients" />
          {pending.map((inv) => (
            <InvitationCard
              key={inv.id}
              invitation={inv}
              onDone={(message) => {
                toast.show(message);
                invitations.reload(true);
              }}
            />
          ))}
        </section>
      )}
      {list && (
        <>
          <div className="grid gap-6 lg:grid-cols-2">
            <DosesThisWeek patients={list} />
            <RecentCheckIns patients={list} now={now} />
          </div>
          <p className="m-0 text-[12px] text-subtle">Updated {asOfLabel(patients.data!.asOf)}</p>
        </>
      )}
    </div>
  );
}

const plural = (n: number, one: string) => `${n} ${n === 1 ? one : `${one}s`}`;

/** The big number: who needs you, of how many, the one plain line, and four small counts. */
function Summary({ todos, pending, stats }: { todos: Todo[]; pending: Invitation[]; stats: ReturnType<typeof todayStats> }) {
  const mini = [
    { label: "Sharing with you", value: stats.sharing, tail: stats.total ? `of ${stats.total}` : "", tone: "text-subtle" },
    { label: "Check-ins today", value: stats.checkedInToday, tail: "", tone: "text-subtle" },
    { label: "Unread notes", value: stats.unreadNotes, tail: "", tone: "text-subtle" },
    { label: "Invitations", value: pending.length, tail: pending.length ? "waiting" : "", tone: "text-indigo-text" },
  ];
  return (
    <Card className="flex flex-wrap justify-between gap-6 p-6">
      <div className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5">
        <span className="text-[13.5px] text-muted">Needs you</span>
        <span className="text-[44px] leading-[48px] font-semibold tracking-[-0.03em] text-ink tabular-nums">
          {todos.length}
          {stats.total > 0 && <span className="ml-2 text-[16px] font-medium tracking-normal text-subtle">of {plural(stats.total, "patient")}</span>}
        </span>
        <p className="m-0 mt-1 max-w-md text-[14px] leading-5 text-body">{summaryLine(todos, pending)}</p>
      </div>
      <dl className="m-0 grid min-w-0 flex-1 basis-64 grid-cols-2 content-start gap-2.5">
        {mini.map((m) => (
          <div key={m.label} className="flex min-w-0 flex-col gap-1 rounded-xl bg-tile px-3.5 py-3">
            <dt className="truncate text-[12.5px] text-muted">{m.label}</dt>
            <dd className="m-0 text-[20px] leading-6 font-semibold text-ink tabular-nums">
              {m.value}
              {m.tail && <span className={`ml-1.5 text-[12.5px] font-medium ${m.tone}`}>{m.tail}</span>}
            </dd>
          </div>
        ))}
      </dl>
    </Card>
  );
}