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