marmar-context / web / context / src / clinician / Shell.tsx
Shell.tsx
Raw
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { clinicianApi } from "../api/client";
import type { UrgentAlert } from "../api/types";
import { TipProvider } from "./kit";
import { notify } from "./notify";
import { navigate } from "./router";
import { useClinician } from "./session";
import { CommandMenu } from "./shell/CommandMenu";
import { AlertsContext, SEARCH_EVENT } from "./shell/context";
import { useShellKeys } from "./shell/keys";
import type { NavId } from "./shell/nav";
import { readCollapsed, saveCollapsed } from "./shell/prefs";
import { MobileSheet, Sidebar } from "./shell/Sidebar";
import { TopBar, type Crumb } from "./shell/TopBar";
import { UrgentBanner } from "./shell/UrgentBanner";

export type { Crumb };
const ALERT_POLL_MS = 15_000;

/** Open urgent notes across patients, polled in the background (YCI-29, NTF-2). */
function useAlerts(browser: boolean) {
  const [alerts, setAlerts] = useState<UrgentAlert[]>([]);
  const seen = useRef<Set<string> | null>(null);
  useEffect(() => {
    let alive = true;
    const poll = async () => {
      try {
        const { alerts: next } = await clinicianApi.alerts();
        if (!alive) return;
        if (seen.current && browser)
          for (const a of next)
            if (!seen.current.has(a.id))
              notify("Urgent check-in note", `${a.patientName}'s check-in has an urgent note in Context.`, a.id, () =>
                navigate(`/app/patients/${a.patientId}/check-ins/${a.checkInId}`),
              );
        seen.current = new Set(next.map((a) => a.id));
        setAlerts(next);
      } catch {
        // The banner keeps its last state; the next poll tries again.
      }
    };
    void poll();
    const t = setInterval(poll, ALERT_POLL_MS);
    const refresh = () => void poll();
    window.addEventListener("context-alerts-changed", refresh);
    return () => {
      alive = false;
      clearInterval(t);
      window.removeEventListener("context-alerts-changed", refresh);
    };
  }, [browser]);
  return alerts;
}
export const alertsChanged = () => window.dispatchEvent(new Event("context-alerts-changed"));

/** The frame of every signed-in page: the rail or sidebar, the top bar with search, and the urgent banner. */
export default function Shell({ crumbs, active, children }: { crumbs: Crumb[]; active: NavId; children: ReactNode }) {
  const { me } = useClinician();
  const alerts = useAlerts(me.notifications.browser);
  const [collapsed, setCollapsed] = useState(readCollapsed);
  const [menuOpen, setMenuOpen] = useState(false);
  const [searchOpen, setSearchOpen] = useState(false);
  const toggle = useCallback(() => {
    saveCollapsed(!collapsed);
    setCollapsed(!collapsed);
  }, [collapsed]);
  const search = useCallback(() => setSearchOpen((o) => !o), []);
  useShellKeys({ toggle, search });
  useEffect(() => {
    const open = () => setSearchOpen(true);
    window.addEventListener(SEARCH_EVENT, open);
    return () => window.removeEventListener(SEARCH_EVENT, open);
  }, []);
  const nav = { active, urgent: alerts.length };
  return (
    <TipProvider>
      <AlertsContext.Provider value={alerts}>
        <div className="min-h-dvh bg-frame font-sans text-ink">
          <a className="skip-link text-ink no-underline" href="#content">
            Skip to content
          </a>
          <div className="flex min-h-dvh md:pr-2.5">
            <Sidebar {...nav} collapsed={collapsed} onToggle={toggle} />
            <div className="flex min-w-0 flex-1 flex-col bg-page md:my-2.5 md:rounded-[20px] md:shadow-card">
              <TopBar crumbs={crumbs} menuOpen={menuOpen} onMenu={() => setMenuOpen(true)} onSearch={() => setSearchOpen(true)} />
              <main id="content" tabIndex={-1} className="flex flex-1 flex-col gap-5 px-4 pt-4 outline-none lg:px-6 lg:pt-5">
                {alerts.length > 0 && <UrgentBanner alerts={alerts} />}
                {children}
              </main>
            </div>
          </div>
          <MobileSheet {...nav} open={menuOpen} onOpenChange={setMenuOpen} />
          <CommandMenu open={searchOpen} onOpenChange={setSearchOpen} />
        </div>
      </AlertsContext.Provider>
    </TipProvider>
  );
}