Installation
npx shadcn@latest add https://boardcn.dev/r/medical-profile.jsonnpx shadcn@latest add https://boardcn.dev/r/medical-profile.jsonnpm packages
- @remixicon/react
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
What it composes
20 BoardCN components, installed automatically.
Source
The 5 files the CLI copies into your project.
"use client";
import { useEffect, useRef, useState } from "react";
import {
RiFileAddLine,
RiFilter3Line,
RiMenuLine,
RiNotification3Line,
} from "@remixicon/react";
import { DashboardSidebar } from "@/components/blocks/dashboard/dashboard-sidebar";
import { ImportantAlertsCard, type ImportantAlert } from "@/components/blocks/medical/important-alerts-card";
import { PatientInfoCard, type PatientDetail } from "@/components/blocks/medical/patient-info-card";
import { NotificationCenter } from "@/components/blocks/notification-center/notification-center";
import type { NotificationCenterItem } from "@/components/blocks/notification-center/notification-center";
import { Avatar } from "@/components/base/avatar/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/components/base/breadcrumb/breadcrumb";
import { Button } from "@/components/base/buttons/button";
import {
Dropdown,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { ActivityRingsCard } from "@/components/charts/activity-rings-card";
import type { ActivityDay, ActivityMetric } from "@/components/charts/activity-rings-card";
import { MostActiveDaysCard } from "@/components/charts/most-active-days-card";
import { cx } from "@/utils/cx";
import { PatientsTable, type MedicalPatient, type PatientActions } from "./patients-table";
import { SleepScoreCard, type SleepMetric } from "./sleep-score-card";
import { StepsCard, type StepsWeekDatum } from "./steps-card";
function MobileNavigation({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", onKeyDown);
};
}, [onClose, open]);
return (
<div className={cx("fixed inset-0 z-50 lg:hidden", open ? "pointer-events-auto" : "pointer-events-none")} aria-hidden={!open}>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
className={cx("absolute inset-0 bg-black/40 transition-opacity duration-300", open ? "opacity-100" : "opacity-0")}
tabIndex={open ? 0 : -1}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Navigation"
className={cx(
"absolute inset-y-0 left-0 flex w-[min(88vw,288px)] flex-col bg-background-full p-3 shadow-sidebar transition-transform duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<button ref={closeRef} type="button" onClick={onClose} className="sr-only">Close navigation</button>
<DashboardSidebar mobile flat selected="medical" className="min-h-0 flex-1" />
</div>
</div>
);
}
export interface MedicalProfileTemplateProps extends PatientActions {
className?: string;
onFilters?: () => void;
onFileReport?: () => void;
patientName: string;
patientInitials: string;
patientDetails: readonly PatientDetail[];
alerts: readonly ImportantAlert[];
alertsWeekCount: number;
alertsWeekRangeLabel: string;
patients: readonly MedicalPatient[];
stepsWeeks: readonly StepsWeekDatum[];
sleepMetrics: readonly SleepMetric[];
sleepWeekRangeLabel: string;
sleepScore?: number;
sleepLabel?: string;
notifications: readonly NotificationCenterItem[];
activityMetrics: readonly ActivityMetric[];
mostActiveDaysTotalSteps: number;
}
/**
* Complete BoardCN medical profile template.
* Patient and alert content are supplied by the consumer.
*/
export function MedicalProfileTemplate({
className,
onFilters,
onFileReport,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
patientName,
patientInitials,
patientDetails,
alerts,
alertsWeekCount,
alertsWeekRangeLabel,
patients,
stepsWeeks,
sleepMetrics,
sleepWeekRangeLabel,
sleepScore,
sleepLabel,
notifications,
activityMetrics,
mostActiveDaysTotalSteps,
}: MedicalProfileTemplateProps) {
const [navigationOpen, setNavigationOpen] = useState(false);
const [selectedDay, setSelectedDay] = useState<ActivityDay>({ month: 6, day: 10 });
const navigationTriggerRef = useRef<HTMLButtonElement>(null);
const closeNavigation = () => {
setNavigationOpen(false);
requestAnimationFrame(() => navigationTriggerRef.current?.focus());
};
return (
<div className={cx("flex min-h-screen w-full bg-background-full p-3", className)}>
<DashboardSidebar selected="medical" className="sticky top-3 hidden h-[calc(100vh-24px)] lg:flex" />
<MobileNavigation open={navigationOpen} onClose={closeNavigation} />
<main className="relative z-20 -my-3 -mr-3 flex min-w-0 flex-1 justify-center overflow-x-hidden overflow-y-auto bg-background-full p-3 will-change-transform sm:pt-6 lg:z-0 lg:!transform-none lg:!rounded-none lg:overflow-visible">
<div className="flex w-full max-w-[1300px] flex-col gap-2.5">
<header className="flex w-full flex-col gap-2">
<Breadcrumb>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="B" /> BoardCN team
</BreadcrumbItem>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="M" /> Mertcan
</BreadcrumbItem>
<BreadcrumbItem current>Medical Profile</BreadcrumbItem>
</Breadcrumb>
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex items-center gap-2">
<button
ref={navigationTriggerRef}
type="button"
aria-label="Open navigation"
onClick={() => setNavigationOpen(true)}
className="inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs lg:hidden"
>
<RiMenuLine className="size-5" aria-hidden />
</button>
<h1 className="text-title-2-medium text-text-primary">Medical Profile</h1>
</div>
<div className="flex items-center gap-2">
<Dropdown>
<DropdownTrigger
aria-label="Notifications"
className="relative inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiNotification3Line className="size-5" aria-hidden />
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-button-danger px-1 text-[10px] leading-4 font-semibold text-white">5</span>
</DropdownTrigger>
<DropdownPopover aria-label="Notifications" placement="bottom end" className="w-[min(420px,calc(100vw-24px))] p-0">
<NotificationCenter notifications={[...notifications]} className="max-h-[520px] overflow-y-auto" />
</DropdownPopover>
</Dropdown>
<Button variant="secondary" size="small" leadingIcon={RiFilter3Line} onClick={() => {
onFilters?.();
document.getElementById("patients-table")?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}}>
Filters
</Button>
<Button size="small" leadingIcon={RiFileAddLine} onClick={onFileReport}>
File a report
</Button>
</div>
</div>
</header>
<div className="flex w-full flex-col gap-4">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<PatientInfoCard name={patientName} initials={patientInitials} details={patientDetails} />
<StepsCard weeks={stepsWeeks} />
<SleepScoreCard metrics={sleepMetrics} weekRangeLabel={sleepWeekRangeLabel} score={sleepScore} label={sleepLabel} />
<MostActiveDaysCard
totalSteps={mostActiveDaysTotalSteps}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
/>
<ActivityRingsCard metrics={activityMetrics} selectedDay={selectedDay} />
<ImportantAlertsCard
alerts={alerts}
weekCount={alertsWeekCount}
weekRangeLabel={alertsWeekRangeLabel}
/>
</div>
<div id="patients-table" className="scroll-mt-3">
<PatientsTable
patients={patients}
onViewChart={onViewChart}
onEditPatient={onEditPatient}
onMorePatientActions={onMorePatientActions}
onViewPatient={onViewPatient}
onCopyMedicalId={onCopyMedicalId}
onRemovePatient={onRemovePatient}
/>
</div>
</div>
</div>
</main>
</div>
);
}
export default MedicalProfileTemplate;"use client";
import { useEffect, useRef, useState } from "react";
import {
RiFileAddLine,
RiFilter3Line,
RiMenuLine,
RiNotification3Line,
} from "@remixicon/react";
import { DashboardSidebar } from "@/components/blocks/dashboard/dashboard-sidebar";
import { ImportantAlertsCard, type ImportantAlert } from "@/components/blocks/medical/important-alerts-card";
import { PatientInfoCard, type PatientDetail } from "@/components/blocks/medical/patient-info-card";
import { NotificationCenter } from "@/components/blocks/notification-center/notification-center";
import type { NotificationCenterItem } from "@/components/blocks/notification-center/notification-center";
import { Avatar } from "@/components/base/avatar/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/components/base/breadcrumb/breadcrumb";
import { Button } from "@/components/base/buttons/button";
import {
Dropdown,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { ActivityRingsCard } from "@/components/charts/activity-rings-card";
import type { ActivityDay, ActivityMetric } from "@/components/charts/activity-rings-card";
import { MostActiveDaysCard } from "@/components/charts/most-active-days-card";
import { cx } from "@/utils/cx";
import { PatientsTable, type MedicalPatient, type PatientActions } from "./patients-table";
import { SleepScoreCard, type SleepMetric } from "./sleep-score-card";
import { StepsCard, type StepsWeekDatum } from "./steps-card";
function MobileNavigation({ open, onClose }: { open: boolean; onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", onKeyDown);
};
}, [onClose, open]);
return (
<div className={cx("fixed inset-0 z-50 lg:hidden", open ? "pointer-events-auto" : "pointer-events-none")} aria-hidden={!open}>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
className={cx("absolute inset-0 bg-black/40 transition-opacity duration-300", open ? "opacity-100" : "opacity-0")}
tabIndex={open ? 0 : -1}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Navigation"
className={cx(
"absolute inset-y-0 left-0 flex w-[min(88vw,288px)] flex-col bg-background-full p-3 shadow-sidebar transition-transform duration-300 ease-in-out",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<button ref={closeRef} type="button" onClick={onClose} className="sr-only">Close navigation</button>
<DashboardSidebar mobile flat selected="medical" className="min-h-0 flex-1" />
</div>
</div>
);
}
export interface MedicalProfileTemplateProps extends PatientActions {
className?: string;
onFilters?: () => void;
onFileReport?: () => void;
patientName: string;
patientInitials: string;
patientDetails: readonly PatientDetail[];
alerts: readonly ImportantAlert[];
alertsWeekCount: number;
alertsWeekRangeLabel: string;
patients: readonly MedicalPatient[];
stepsWeeks: readonly StepsWeekDatum[];
sleepMetrics: readonly SleepMetric[];
sleepWeekRangeLabel: string;
sleepScore?: number;
sleepLabel?: string;
notifications: readonly NotificationCenterItem[];
activityMetrics: readonly ActivityMetric[];
mostActiveDaysTotalSteps: number;
}
/**
* Complete BoardCN medical profile template.
* Patient and alert content are supplied by the consumer.
*/
export function MedicalProfileTemplate({
className,
onFilters,
onFileReport,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
patientName,
patientInitials,
patientDetails,
alerts,
alertsWeekCount,
alertsWeekRangeLabel,
patients,
stepsWeeks,
sleepMetrics,
sleepWeekRangeLabel,
sleepScore,
sleepLabel,
notifications,
activityMetrics,
mostActiveDaysTotalSteps,
}: MedicalProfileTemplateProps) {
const [navigationOpen, setNavigationOpen] = useState(false);
const [selectedDay, setSelectedDay] = useState<ActivityDay>({ month: 6, day: 10 });
const navigationTriggerRef = useRef<HTMLButtonElement>(null);
const closeNavigation = () => {
setNavigationOpen(false);
requestAnimationFrame(() => navigationTriggerRef.current?.focus());
};
return (
<div className={cx("flex min-h-screen w-full bg-background-full p-3", className)}>
<DashboardSidebar selected="medical" className="sticky top-3 hidden h-[calc(100vh-24px)] lg:flex" />
<MobileNavigation open={navigationOpen} onClose={closeNavigation} />
<main className="relative z-20 -my-3 -mr-3 flex min-w-0 flex-1 justify-center overflow-x-hidden overflow-y-auto bg-background-full p-3 will-change-transform sm:pt-6 lg:z-0 lg:!transform-none lg:!rounded-none lg:overflow-visible">
<div className="flex w-full max-w-[1300px] flex-col gap-2.5">
<header className="flex w-full flex-col gap-2">
<Breadcrumb>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="B" /> BoardCN team
</BreadcrumbItem>
<BreadcrumbItem href="/templates/dashboard">
<Avatar size="xs" initials="M" /> Mertcan
</BreadcrumbItem>
<BreadcrumbItem current>Medical Profile</BreadcrumbItem>
</Breadcrumb>
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex items-center gap-2">
<button
ref={navigationTriggerRef}
type="button"
aria-label="Open navigation"
onClick={() => setNavigationOpen(true)}
className="inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs lg:hidden"
>
<RiMenuLine className="size-5" aria-hidden />
</button>
<h1 className="text-title-2-medium text-text-primary">Medical Profile</h1>
</div>
<div className="flex items-center gap-2">
<Dropdown>
<DropdownTrigger
aria-label="Notifications"
className="relative inline-flex size-9 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiNotification3Line className="size-5" aria-hidden />
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-button-danger px-1 text-[10px] leading-4 font-semibold text-white">5</span>
</DropdownTrigger>
<DropdownPopover aria-label="Notifications" placement="bottom end" className="w-[min(420px,calc(100vw-24px))] p-0">
<NotificationCenter notifications={[...notifications]} className="max-h-[520px] overflow-y-auto" />
</DropdownPopover>
</Dropdown>
<Button variant="secondary" size="small" leadingIcon={RiFilter3Line} onClick={() => {
onFilters?.();
document.getElementById("patients-table")?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}}>
Filters
</Button>
<Button size="small" leadingIcon={RiFileAddLine} onClick={onFileReport}>
File a report
</Button>
</div>
</div>
</header>
<div className="flex w-full flex-col gap-4">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<PatientInfoCard name={patientName} initials={patientInitials} details={patientDetails} />
<StepsCard weeks={stepsWeeks} />
<SleepScoreCard metrics={sleepMetrics} weekRangeLabel={sleepWeekRangeLabel} score={sleepScore} label={sleepLabel} />
<MostActiveDaysCard
totalSteps={mostActiveDaysTotalSteps}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
/>
<ActivityRingsCard metrics={activityMetrics} selectedDay={selectedDay} />
<ImportantAlertsCard
alerts={alerts}
weekCount={alertsWeekCount}
weekRangeLabel={alertsWeekRangeLabel}
/>
</div>
<div id="patients-table" className="scroll-mt-3">
<PatientsTable
patients={patients}
onViewChart={onViewChart}
onEditPatient={onEditPatient}
onMorePatientActions={onMorePatientActions}
onViewPatient={onViewPatient}
onCopyMedicalId={onCopyMedicalId}
onRemovePatient={onRemovePatient}
/>
</div>
</div>
</div>
</main>
</div>
);
}
export default MedicalProfileTemplate;export { MedicalProfileTemplate, default } from "./medical-profile";
export type { MedicalProfileTemplateProps } from "./medical-profile";
export { PatientsTable } from "./patients-table";
export type { MedicalPatient, PatientActions, PatientsTableProps } from "./patients-table";
export { SleepScoreCard } from "./sleep-score-card";
export type { SleepMetric, SleepScoreCardProps } from "./sleep-score-card";
export { StepsCard } from "./steps-card";
export type { StepsCardProps, StepsWeekDatum } from "./steps-card";export { MedicalProfileTemplate, default } from "./medical-profile";
export type { MedicalProfileTemplateProps } from "./medical-profile";
export { PatientsTable } from "./patients-table";
export type { MedicalPatient, PatientActions, PatientsTableProps } from "./patients-table";
export { SleepScoreCard } from "./sleep-score-card";
export type { SleepMetric, SleepScoreCardProps } from "./sleep-score-card";
export { StepsCard } from "./steps-card";
export type { StepsCardProps, StepsWeekDatum } from "./steps-card";"use client";
import { useMemo, useState } from "react";
import {
RiBarChart2Line,
RiDeleteBin6Line,
RiEditLine,
RiFileCopyLine,
RiMore2Fill,
RiSearchLine,
RiUserLine,
} from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Chip } from "@/components/base/badges/chip";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/components/base/table/table";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
const ADMISSIONS = ["Inpatient", "Outpatient", "Discharged", "Emergency"] as const;
const HEALTH_STATES = ["Recovering", "In treatment", "Under observation", "Stable", "Critical"] as const;
const CONDITIONS = ["Diabetes", "Hypertension", "Asthma", "Fracture", "Post-op", "Pregnancy", "Allergy", "Migraine"] as const;
const PAGE_SIZE = 12;
type Admission = (typeof ADMISSIONS)[number];
type HealthState = (typeof HEALTH_STATES)[number];
type Condition = (typeof CONDITIONS)[number];
export type MedicalPatient = {
id: string;
name: string;
admission: Admission;
status: HealthState;
conditions: Condition[];
appointment: string;
appointmentTs: number;
};
const STATUS_COLORS: Record<HealthState, "lime" | "blue" | "yellow" | "cyan" | "rose"> = {
Recovering: "lime",
"In treatment": "blue",
"Under observation": "yellow",
Stable: "cyan",
Critical: "rose",
};
function initials(name: string) {
return name.split(" ").map((part) => part[0]).join("").slice(0, 2).toUpperCase();
}
function SortLabel({
label,
active,
direction,
onClick,
}: {
label: string;
active: boolean;
direction: "asc" | "desc";
onClick: () => void;
}) {
return (
<button type="button" onClick={onClick} className="flex cursor-pointer items-center gap-0.5" aria-label={`Sort by ${label}`}>
{label}
<ChevronSortDown
className={cx(
"size-6 shrink-0 transition-[transform,color] duration-150",
active && direction === "asc" && "rotate-180",
active ? "text-text-secondary" : "text-text-tertiary",
)}
/>
</button>
);
}
export interface PatientActions {
onViewChart?: (patient: MedicalPatient) => void;
onEditPatient?: (patient: MedicalPatient) => void;
onMorePatientActions?: (patient: MedicalPatient) => void;
onViewPatient?: (patient: MedicalPatient) => void;
onCopyMedicalId?: (patient: MedicalPatient) => void;
onRemovePatient?: (patient: MedicalPatient) => void;
}
function PatientRowActions({
patient,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
}: { patient: MedicalPatient } & PatientActions) {
const [open, setOpen] = useState(false);
const select = (action?: (patient: MedicalPatient) => void) => () => {
setOpen(false);
action?.(patient);
};
const copyMedicalId = () => {
setOpen(false);
void navigator.clipboard?.writeText(patient.id);
onCopyMedicalId?.(patient);
};
return (
<div className="flex items-center gap-1.5">
<IconButton icon={RiBarChart2Line} size="small" aria-label={`View chart for ${patient.name}`} onClick={() => onViewChart?.(patient)} />
<IconButton icon={RiEditLine} size="small" aria-label={`Edit ${patient.name}`} onClick={() => onEditPatient?.(patient)} />
<Dropdown
isOpen={open}
onOpenChange={(next) => {
setOpen(next);
if (next) onMorePatientActions?.(patient);
}}
>
<DropdownTrigger
aria-label={`More actions for ${patient.name}`}
className="inline-flex size-8 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiMore2Fill className="size-4" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`More actions for ${patient.name}`} placement="bottom end" className="w-[220px] p-2">
<DropdownGroup>
<DropdownItem onSelect={select(onViewPatient)}><RiUserLine className="size-[18px]" aria-hidden />View patient</DropdownItem>
<DropdownItem onSelect={copyMedicalId}><RiFileCopyLine className="size-[18px]" aria-hidden />Copy medical ID</DropdownItem>
<DropdownItem onSelect={select(onRemovePatient)} className="text-text-error"><RiDeleteBin6Line className="size-[18px]" aria-hidden />Remove patient</DropdownItem>
</DropdownGroup>
</DropdownPopover>
</Dropdown>
</div>
);
}
export interface PatientsTableProps extends PatientActions {
patients: readonly MedicalPatient[];
className?: string;
}
export function PatientsTable({
patients,
className,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
}: PatientsTableProps) {
const [statusFilter, setStatusFilter] = useState("all");
const [conditionFilter, setConditionFilter] = useState("all");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<"name" | "appointment">("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [selected, setSelected] = useState<Set<string>>(() => new Set(["aspen", "michael"]));
const [admissions, setAdmissions] = useState<Record<string, Admission>>({});
const [removedIds, setRemovedIds] = useState<Set<string>>(() => new Set());
const removePatient = (patient: MedicalPatient) => {
setRemovedIds((current) => new Set(current).add(patient.id));
setSelected((current) => {
const next = new Set(current);
next.delete(patient.id);
return next;
});
onRemovePatient?.(patient);
};
const filteredRows = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return patients.filter(
(patient) =>
!removedIds.has(patient.id) &&
(statusFilter === "all" || patient.status === statusFilter) &&
(conditionFilter === "all" || patient.conditions.includes(conditionFilter as Condition)) &&
(normalizedQuery === "" || patient.name.toLocaleLowerCase().includes(normalizedQuery)),
).sort((a, b) => {
const delta = sortKey === "name" ? a.name.localeCompare(b.name) : a.appointmentTs - b.appointmentTs;
return sortDirection === "asc" ? delta : -delta;
});
}, [conditionFilter, patients, query, removedIds, sortDirection, sortKey, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const rows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const allSelected = rows.length > 0 && rows.every((patient) => selected.has(patient.id));
const someSelected = rows.some((patient) => selected.has(patient.id));
const toggleSort = (key: "name" | "appointment") => {
if (sortKey === key) setSortDirection((value) => value === "asc" ? "desc" : "asc");
else {
setSortKey(key);
setSortDirection("asc");
}
};
return (
<section className={cx("flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3", className)}>
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col justify-center">
<p className="text-body-medium whitespace-nowrap text-text-tertiary">Total Results</p>
<p className="text-body-medium whitespace-nowrap text-text-primary">{filteredRows.length.toLocaleString()} patients</p>
</div>
<div className="-mx-3 flex w-[calc(100%+1.5rem)] items-center gap-2.5 overflow-x-auto px-3 sm:mx-0 sm:w-auto sm:flex-wrap sm:justify-end sm:overflow-visible sm:px-0">
<Select
aria-label="Filter by status"
className="w-[142px] shrink-0"
popoverClassName="min-w-48"
selectedKey={statusFilter}
onSelectionChange={(key) => { setStatusFilter(String(key)); setPage(1); }}
>
<SelectItem id="all" textValue="All statuses">All statuses</SelectItem>
{HEALTH_STATES.map((status) => <SelectItem key={status} id={status} textValue={status}>{status}</SelectItem>)}
</Select>
<Select
aria-label="Filter by condition"
className="w-[147px] shrink-0"
popoverClassName="min-w-44"
selectedKey={conditionFilter}
onSelectionChange={(key) => { setConditionFilter(String(key)); setPage(1); }}
>
<SelectItem id="all" textValue="All conditions">All conditions</SelectItem>
{CONDITIONS.map((condition) => <SelectItem key={condition} id={condition} textValue={condition}>{condition}</SelectItem>)}
</Select>
<InputBase
aria-label="Search patients"
placeholder="Search"
leadingIcon={RiSearchLine}
value={query}
onChange={(event) => { setQuery(event.target.value); setPage(1); }}
fieldClassName="min-w-[153px] flex-1 rounded-full bg-background-secondary-default sm:w-[153px] sm:min-w-0 sm:flex-none"
className="text-body-medium"
/>
</div>
</div>
<div className="mt-2">
<Table aria-label="Patients" selectionMode="none" className="min-w-[1020px]">
<TableHeader>
<TableColumn id="patient" isRowHeader className="w-[250px]">
<div className="flex items-center gap-2">
<Checkbox
slot={null}
aria-label="Select all patients on this page"
isSelected={allSelected}
isIndeterminate={someSelected && !allSelected}
onChange={(isSelected) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((patient) => isSelected ? next.add(patient.id) : next.delete(patient.id));
return next;
});
}}
/>
<SortLabel label="Patient" active={sortKey === "name"} direction={sortDirection} onClick={() => toggleSort("name")} />
</div>
</TableColumn>
<TableColumn id="admission" className="w-[170px]">Admission</TableColumn>
<TableColumn id="status" className="w-[160px]">Status</TableColumn>
<TableColumn id="condition" className="w-[210px]">Condition</TableColumn>
<TableColumn id="appointment" className="w-[185px]">
<SortLabel label="Next appointment" active={sortKey === "appointment"} direction={sortDirection} onClick={() => toggleSort("appointment")} />
</TableColumn>
<TableColumn id="actions" className="w-[132px]">Actions</TableColumn>
</TableHeader>
<TableBody renderEmptyState={() => <div className="flex h-40 items-center justify-center text-body-medium text-text-tertiary">No patients match your filters.</div>}>
{rows.map((patient) => (
<TableRow
key={patient.id}
id={patient.id}
style={selected.has(patient.id) ? { backgroundColor: "var(--color-background-secondary-default)" } : undefined}
>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Checkbox
slot={null}
aria-label={`Select ${patient.name}`}
isSelected={selected.has(patient.id)}
onChange={(isSelected) => setSelected((current) => {
const next = new Set(current);
if (isSelected) next.add(patient.id); else next.delete(patient.id);
return next;
})}
/>
<Avatar size="sm" initials={initials(patient.name)} color={patient.id === "john" ? "blue" : "neutral"} />
<span className="truncate text-body-medium text-text-primary">{patient.name}</span>
</div>
</TableCell>
<TableCell>
<Select
aria-label={`Admission status for ${patient.name}`}
size="sm"
className="w-[130px]"
popoverClassName="min-w-40"
selectedKey={admissions[patient.id] ?? patient.admission}
onSelectionChange={(key) => setAdmissions((current) => ({ ...current, [patient.id]: String(key) as Admission }))}
>
{ADMISSIONS.map((admission) => <SelectItem key={admission} id={admission} textValue={admission}>{admission}</SelectItem>)}
</Select>
</TableCell>
<TableCell><Chip color={STATUS_COLORS[patient.status]}>{patient.status}</Chip></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{patient.conditions.map((condition) => <Chip key={condition} variant="caption" color="soft">{condition}</Chip>)}
</div>
</TableCell>
<TableCell><span className="whitespace-nowrap text-body-medium text-text-primary">{patient.appointment}</span></TableCell>
<TableCell>
<PatientRowActions
patient={patient}
onViewChart={onViewChart}
onEditPatient={onEditPatient}
onMorePatientActions={onMorePatientActions}
onViewPatient={onViewPatient}
onCopyMedicalId={onCopyMedicalId}
onRemovePatient={removePatient}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="px-3 pt-3">
<Pagination page={Math.min(page, totalPages)} totalPages={totalPages} onChange={setPage} />
</div>
</section>
);
}"use client";
import { useMemo, useState } from "react";
import {
RiBarChart2Line,
RiDeleteBin6Line,
RiEditLine,
RiFileCopyLine,
RiMore2Fill,
RiSearchLine,
RiUserLine,
} from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Chip } from "@/components/base/badges/chip";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/components/base/table/table";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
const ADMISSIONS = ["Inpatient", "Outpatient", "Discharged", "Emergency"] as const;
const HEALTH_STATES = ["Recovering", "In treatment", "Under observation", "Stable", "Critical"] as const;
const CONDITIONS = ["Diabetes", "Hypertension", "Asthma", "Fracture", "Post-op", "Pregnancy", "Allergy", "Migraine"] as const;
const PAGE_SIZE = 12;
type Admission = (typeof ADMISSIONS)[number];
type HealthState = (typeof HEALTH_STATES)[number];
type Condition = (typeof CONDITIONS)[number];
export type MedicalPatient = {
id: string;
name: string;
admission: Admission;
status: HealthState;
conditions: Condition[];
appointment: string;
appointmentTs: number;
};
const STATUS_COLORS: Record<HealthState, "lime" | "blue" | "yellow" | "cyan" | "rose"> = {
Recovering: "lime",
"In treatment": "blue",
"Under observation": "yellow",
Stable: "cyan",
Critical: "rose",
};
function initials(name: string) {
return name.split(" ").map((part) => part[0]).join("").slice(0, 2).toUpperCase();
}
function SortLabel({
label,
active,
direction,
onClick,
}: {
label: string;
active: boolean;
direction: "asc" | "desc";
onClick: () => void;
}) {
return (
<button type="button" onClick={onClick} className="flex cursor-pointer items-center gap-0.5" aria-label={`Sort by ${label}`}>
{label}
<ChevronSortDown
className={cx(
"size-6 shrink-0 transition-[transform,color] duration-150",
active && direction === "asc" && "rotate-180",
active ? "text-text-secondary" : "text-text-tertiary",
)}
/>
</button>
);
}
export interface PatientActions {
onViewChart?: (patient: MedicalPatient) => void;
onEditPatient?: (patient: MedicalPatient) => void;
onMorePatientActions?: (patient: MedicalPatient) => void;
onViewPatient?: (patient: MedicalPatient) => void;
onCopyMedicalId?: (patient: MedicalPatient) => void;
onRemovePatient?: (patient: MedicalPatient) => void;
}
function PatientRowActions({
patient,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
}: { patient: MedicalPatient } & PatientActions) {
const [open, setOpen] = useState(false);
const select = (action?: (patient: MedicalPatient) => void) => () => {
setOpen(false);
action?.(patient);
};
const copyMedicalId = () => {
setOpen(false);
void navigator.clipboard?.writeText(patient.id);
onCopyMedicalId?.(patient);
};
return (
<div className="flex items-center gap-1.5">
<IconButton icon={RiBarChart2Line} size="small" aria-label={`View chart for ${patient.name}`} onClick={() => onViewChart?.(patient)} />
<IconButton icon={RiEditLine} size="small" aria-label={`Edit ${patient.name}`} onClick={() => onEditPatient?.(patient)} />
<Dropdown
isOpen={open}
onOpenChange={(next) => {
setOpen(next);
if (next) onMorePatientActions?.(patient);
}}
>
<DropdownTrigger
aria-label={`More actions for ${patient.name}`}
className="inline-flex size-8 items-center justify-center rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs hover:bg-background-primary-hover"
>
<RiMore2Fill className="size-4" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`More actions for ${patient.name}`} placement="bottom end" className="w-[220px] p-2">
<DropdownGroup>
<DropdownItem onSelect={select(onViewPatient)}><RiUserLine className="size-[18px]" aria-hidden />View patient</DropdownItem>
<DropdownItem onSelect={copyMedicalId}><RiFileCopyLine className="size-[18px]" aria-hidden />Copy medical ID</DropdownItem>
<DropdownItem onSelect={select(onRemovePatient)} className="text-text-error"><RiDeleteBin6Line className="size-[18px]" aria-hidden />Remove patient</DropdownItem>
</DropdownGroup>
</DropdownPopover>
</Dropdown>
</div>
);
}
export interface PatientsTableProps extends PatientActions {
patients: readonly MedicalPatient[];
className?: string;
}
export function PatientsTable({
patients,
className,
onViewChart,
onEditPatient,
onMorePatientActions,
onViewPatient,
onCopyMedicalId,
onRemovePatient,
}: PatientsTableProps) {
const [statusFilter, setStatusFilter] = useState("all");
const [conditionFilter, setConditionFilter] = useState("all");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<"name" | "appointment">("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [selected, setSelected] = useState<Set<string>>(() => new Set(["aspen", "michael"]));
const [admissions, setAdmissions] = useState<Record<string, Admission>>({});
const [removedIds, setRemovedIds] = useState<Set<string>>(() => new Set());
const removePatient = (patient: MedicalPatient) => {
setRemovedIds((current) => new Set(current).add(patient.id));
setSelected((current) => {
const next = new Set(current);
next.delete(patient.id);
return next;
});
onRemovePatient?.(patient);
};
const filteredRows = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
return patients.filter(
(patient) =>
!removedIds.has(patient.id) &&
(statusFilter === "all" || patient.status === statusFilter) &&
(conditionFilter === "all" || patient.conditions.includes(conditionFilter as Condition)) &&
(normalizedQuery === "" || patient.name.toLocaleLowerCase().includes(normalizedQuery)),
).sort((a, b) => {
const delta = sortKey === "name" ? a.name.localeCompare(b.name) : a.appointmentTs - b.appointmentTs;
return sortDirection === "asc" ? delta : -delta;
});
}, [conditionFilter, patients, query, removedIds, sortDirection, sortKey, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const rows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const allSelected = rows.length > 0 && rows.every((patient) => selected.has(patient.id));
const someSelected = rows.some((patient) => selected.has(patient.id));
const toggleSort = (key: "name" | "appointment") => {
if (sortKey === key) setSortDirection((value) => value === "asc" ? "desc" : "asc");
else {
setSortKey(key);
setSortDirection("asc");
}
};
return (
<section className={cx("flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3", className)}>
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col justify-center">
<p className="text-body-medium whitespace-nowrap text-text-tertiary">Total Results</p>
<p className="text-body-medium whitespace-nowrap text-text-primary">{filteredRows.length.toLocaleString()} patients</p>
</div>
<div className="-mx-3 flex w-[calc(100%+1.5rem)] items-center gap-2.5 overflow-x-auto px-3 sm:mx-0 sm:w-auto sm:flex-wrap sm:justify-end sm:overflow-visible sm:px-0">
<Select
aria-label="Filter by status"
className="w-[142px] shrink-0"
popoverClassName="min-w-48"
selectedKey={statusFilter}
onSelectionChange={(key) => { setStatusFilter(String(key)); setPage(1); }}
>
<SelectItem id="all" textValue="All statuses">All statuses</SelectItem>
{HEALTH_STATES.map((status) => <SelectItem key={status} id={status} textValue={status}>{status}</SelectItem>)}
</Select>
<Select
aria-label="Filter by condition"
className="w-[147px] shrink-0"
popoverClassName="min-w-44"
selectedKey={conditionFilter}
onSelectionChange={(key) => { setConditionFilter(String(key)); setPage(1); }}
>
<SelectItem id="all" textValue="All conditions">All conditions</SelectItem>
{CONDITIONS.map((condition) => <SelectItem key={condition} id={condition} textValue={condition}>{condition}</SelectItem>)}
</Select>
<InputBase
aria-label="Search patients"
placeholder="Search"
leadingIcon={RiSearchLine}
value={query}
onChange={(event) => { setQuery(event.target.value); setPage(1); }}
fieldClassName="min-w-[153px] flex-1 rounded-full bg-background-secondary-default sm:w-[153px] sm:min-w-0 sm:flex-none"
className="text-body-medium"
/>
</div>
</div>
<div className="mt-2">
<Table aria-label="Patients" selectionMode="none" className="min-w-[1020px]">
<TableHeader>
<TableColumn id="patient" isRowHeader className="w-[250px]">
<div className="flex items-center gap-2">
<Checkbox
slot={null}
aria-label="Select all patients on this page"
isSelected={allSelected}
isIndeterminate={someSelected && !allSelected}
onChange={(isSelected) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((patient) => isSelected ? next.add(patient.id) : next.delete(patient.id));
return next;
});
}}
/>
<SortLabel label="Patient" active={sortKey === "name"} direction={sortDirection} onClick={() => toggleSort("name")} />
</div>
</TableColumn>
<TableColumn id="admission" className="w-[170px]">Admission</TableColumn>
<TableColumn id="status" className="w-[160px]">Status</TableColumn>
<TableColumn id="condition" className="w-[210px]">Condition</TableColumn>
<TableColumn id="appointment" className="w-[185px]">
<SortLabel label="Next appointment" active={sortKey === "appointment"} direction={sortDirection} onClick={() => toggleSort("appointment")} />
</TableColumn>
<TableColumn id="actions" className="w-[132px]">Actions</TableColumn>
</TableHeader>
<TableBody renderEmptyState={() => <div className="flex h-40 items-center justify-center text-body-medium text-text-tertiary">No patients match your filters.</div>}>
{rows.map((patient) => (
<TableRow
key={patient.id}
id={patient.id}
style={selected.has(patient.id) ? { backgroundColor: "var(--color-background-secondary-default)" } : undefined}
>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Checkbox
slot={null}
aria-label={`Select ${patient.name}`}
isSelected={selected.has(patient.id)}
onChange={(isSelected) => setSelected((current) => {
const next = new Set(current);
if (isSelected) next.add(patient.id); else next.delete(patient.id);
return next;
})}
/>
<Avatar size="sm" initials={initials(patient.name)} color={patient.id === "john" ? "blue" : "neutral"} />
<span className="truncate text-body-medium text-text-primary">{patient.name}</span>
</div>
</TableCell>
<TableCell>
<Select
aria-label={`Admission status for ${patient.name}`}
size="sm"
className="w-[130px]"
popoverClassName="min-w-40"
selectedKey={admissions[patient.id] ?? patient.admission}
onSelectionChange={(key) => setAdmissions((current) => ({ ...current, [patient.id]: String(key) as Admission }))}
>
{ADMISSIONS.map((admission) => <SelectItem key={admission} id={admission} textValue={admission}>{admission}</SelectItem>)}
</Select>
</TableCell>
<TableCell><Chip color={STATUS_COLORS[patient.status]}>{patient.status}</Chip></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{patient.conditions.map((condition) => <Chip key={condition} variant="caption" color="soft">{condition}</Chip>)}
</div>
</TableCell>
<TableCell><span className="whitespace-nowrap text-body-medium text-text-primary">{patient.appointment}</span></TableCell>
<TableCell>
<PatientRowActions
patient={patient}
onViewChart={onViewChart}
onEditPatient={onEditPatient}
onMorePatientActions={onMorePatientActions}
onViewPatient={onViewPatient}
onCopyMedicalId={onCopyMedicalId}
onRemovePatient={removePatient}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="px-3 pt-3">
<Pagination page={Math.min(page, totalPages)} totalPages={totalPages} onChange={setPage} />
</div>
</section>
);
}"use client";
import { useEffect, useState, type ComponentType } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";
export type SleepMetric = {
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
label: string;
value: string;
score: string;
};
export interface SleepScoreCardProps {
metrics: readonly SleepMetric[];
weekRangeLabel: string;
score?: number;
label?: string;
className?: string;
}
export function SleepScoreCard({
metrics,
weekRangeLabel,
score = 98,
label = "Excellent",
className,
}: SleepScoreCardProps) {
const [visibleScore, setVisibleScore] = useState(0);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisibleScore(score));
return () => cancelAnimationFrame(frame);
}, [score]);
const circumference = 2 * Math.PI * 44;
const progress = circumference * (score / 100);
return (
<section
className={cx(
"flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
className,
)}
>
<div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-body-medium text-text-secondary">Sleep score</p>
<p className="text-title-1-medium whitespace-nowrap text-text-primary">{label}</p>
</div>
<WeekRangePill label={weekRangeLabel} />
</div>
<div className="relative -mt-2 h-[104px] w-full shrink-0">
<svg viewBox="0 0 351 104" className="block size-full" role="img" aria-label={`Sleep score ${score}, ${label.toLowerCase()}`}>
<g transform="translate(175.5 52) rotate(-90)">
<circle r={44} fill="none" stroke="var(--color-chart-track)" strokeWidth={15} />
<circle
r={44}
fill="none"
stroke="var(--color-chart-5)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.5} ${circumference - progress * 0.5}`}
className="transition-[stroke-dasharray] duration-700 ease-out motion-reduce:transition-none"
/>
<circle
r={44}
fill="none"
stroke="var(--color-chart-3)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.29} ${circumference - progress * 0.29}`}
strokeDashoffset={-(progress * 0.52)}
/>
<circle
r={44}
fill="none"
stroke="var(--color-chart-6)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.19} ${circumference - progress * 0.19}`}
strokeDashoffset={-(progress * 0.83)}
/>
</g>
</svg>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="animate-number-fade text-display-4-medium text-text-primary tabular-nums">
{visibleScore}
</span>
</div>
</div>
<div className="flex w-full flex-1 flex-col rounded-2lg bg-background-inner-default pl-2.5">
{metrics.map((metric, index) => (
<div
key={metric.label}
className={cx(
"flex w-full flex-1 items-center justify-between py-2 pr-2.5",
index < metrics.length - 1 && "border-b border-separator-border-strong",
)}
>
<div className="flex items-center gap-1.5">
<metric.icon className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="text-body-regular text-text-secondary">
{metric.label}: <span className="text-text-primary">{metric.value}</span>
</span>
</div>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{metric.score}</span>
</div>
))}
</div>
</section>
);
}"use client";
import { useEffect, useState, type ComponentType } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";
export type SleepMetric = {
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
label: string;
value: string;
score: string;
};
export interface SleepScoreCardProps {
metrics: readonly SleepMetric[];
weekRangeLabel: string;
score?: number;
label?: string;
className?: string;
}
export function SleepScoreCard({
metrics,
weekRangeLabel,
score = 98,
label = "Excellent",
className,
}: SleepScoreCardProps) {
const [visibleScore, setVisibleScore] = useState(0);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisibleScore(score));
return () => cancelAnimationFrame(frame);
}, [score]);
const circumference = 2 * Math.PI * 44;
const progress = circumference * (score / 100);
return (
<section
className={cx(
"flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
className,
)}
>
<div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-body-medium text-text-secondary">Sleep score</p>
<p className="text-title-1-medium whitespace-nowrap text-text-primary">{label}</p>
</div>
<WeekRangePill label={weekRangeLabel} />
</div>
<div className="relative -mt-2 h-[104px] w-full shrink-0">
<svg viewBox="0 0 351 104" className="block size-full" role="img" aria-label={`Sleep score ${score}, ${label.toLowerCase()}`}>
<g transform="translate(175.5 52) rotate(-90)">
<circle r={44} fill="none" stroke="var(--color-chart-track)" strokeWidth={15} />
<circle
r={44}
fill="none"
stroke="var(--color-chart-5)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.5} ${circumference - progress * 0.5}`}
className="transition-[stroke-dasharray] duration-700 ease-out motion-reduce:transition-none"
/>
<circle
r={44}
fill="none"
stroke="var(--color-chart-3)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.29} ${circumference - progress * 0.29}`}
strokeDashoffset={-(progress * 0.52)}
/>
<circle
r={44}
fill="none"
stroke="var(--color-chart-6)"
strokeWidth={15}
strokeLinecap="round"
strokeDasharray={`${progress * 0.19} ${circumference - progress * 0.19}`}
strokeDashoffset={-(progress * 0.83)}
/>
</g>
</svg>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="animate-number-fade text-display-4-medium text-text-primary tabular-nums">
{visibleScore}
</span>
</div>
</div>
<div className="flex w-full flex-1 flex-col rounded-2lg bg-background-inner-default pl-2.5">
{metrics.map((metric, index) => (
<div
key={metric.label}
className={cx(
"flex w-full flex-1 items-center justify-between py-2 pr-2.5",
index < metrics.length - 1 && "border-b border-separator-border-strong",
)}
>
<div className="flex items-center gap-1.5">
<metric.icon className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="text-body-regular text-text-secondary">
{metric.label}: <span className="text-text-primary">{metric.value}</span>
</span>
</div>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{metric.score}</span>
</div>
))}
</div>
</section>
);
}"use client";
import { useMemo, useState } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";
export type StepsWeekDatum = {
label: string;
total: number;
values: readonly number[];
};
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
export interface StepsCardProps {
weeks: readonly StepsWeekDatum[];
className?: string;
}
export function StepsCard({ weeks, className }: StepsCardProps) {
const [week, setWeek] = useState(Math.min(1, Math.max(0, weeks.length - 1)));
const data = weeks[week] ?? weeks[0];
const bars = useMemo(
() => (data?.values ?? []).map((value, index) => ({ day: DAYS[index], value })),
[data],
);
if (!data) return null;
return (
<section
className={cx(
"flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
className,
)}
>
<div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-body-medium text-text-secondary">Steps</p>
<div className="flex items-baseline gap-1">
<p className="text-title-1-medium whitespace-nowrap text-text-primary tabular-nums">
{data.total.toLocaleString()}
</p>
<span className="text-caption-1-medium whitespace-nowrap text-text-secondary">total steps</span>
</div>
</div>
<WeekRangePill
label={data.label}
onPrev={() => setWeek((value) => Math.max(0, value - 1))}
onNext={() => setWeek((value) => Math.min(weeks.length - 1, value + 1))}
/>
</div>
<div className="-mx-[5.5px] min-h-0 flex-1">
<svg
viewBox="0 0 362 232"
preserveAspectRatio="none"
className="block size-full overflow-visible"
role="img"
aria-label={`${data.total.toLocaleString()} total steps for ${data.label}`}
>
{bars.map(({ day, value }, index) => {
const slot = 362 / 7;
const x = slot * index + 4.5;
const height = 202 * (value / 100);
const y = 206 - height;
return (
<g key={day} className="group outline-none" tabIndex={0} aria-label={`${day}: ${Math.round(value)} percent of weekly maximum`}>
<rect x={x} y={4} width={43} height={202} rx={10} fill="var(--color-chart-track)" />
<rect
x={x - 3}
y={1}
width={49}
height={208}
rx={13}
fill="none"
stroke="var(--color-chart-cursor)"
strokeWidth={2}
className="opacity-0 transition-opacity duration-150 ease-out group-hover:opacity-100 group-focus:opacity-100"
/>
<rect
x={x}
y={y}
width={43}
height={height}
rx={10}
fill="var(--color-chart-1)"
className="transition-[height,y,opacity] duration-300 ease-out group-hover:opacity-80 motion-reduce:transition-none"
/>
<text
x={slot * index + slot / 2}
y={224}
textAnchor="middle"
fill="var(--color-text-secondary)"
className="text-caption-1-medium"
>
{day}
</text>
</g>
);
})}
</svg>
</div>
</section>
);
}"use client";
import { useMemo, useState } from "react";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";
export type StepsWeekDatum = {
label: string;
total: number;
values: readonly number[];
};
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
export interface StepsCardProps {
weeks: readonly StepsWeekDatum[];
className?: string;
}
export function StepsCard({ weeks, className }: StepsCardProps) {
const [week, setWeek] = useState(Math.min(1, Math.max(0, weeks.length - 1)));
const data = weeks[week] ?? weeks[0];
const bars = useMemo(
() => (data?.values ?? []).map((value, index) => ({ day: DAYS[index], value })),
[data],
);
if (!data) return null;
return (
<section
className={cx(
"flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
className,
)}
>
<div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-body-medium text-text-secondary">Steps</p>
<div className="flex items-baseline gap-1">
<p className="text-title-1-medium whitespace-nowrap text-text-primary tabular-nums">
{data.total.toLocaleString()}
</p>
<span className="text-caption-1-medium whitespace-nowrap text-text-secondary">total steps</span>
</div>
</div>
<WeekRangePill
label={data.label}
onPrev={() => setWeek((value) => Math.max(0, value - 1))}
onNext={() => setWeek((value) => Math.min(weeks.length - 1, value + 1))}
/>
</div>
<div className="-mx-[5.5px] min-h-0 flex-1">
<svg
viewBox="0 0 362 232"
preserveAspectRatio="none"
className="block size-full overflow-visible"
role="img"
aria-label={`${data.total.toLocaleString()} total steps for ${data.label}`}
>
{bars.map(({ day, value }, index) => {
const slot = 362 / 7;
const x = slot * index + 4.5;
const height = 202 * (value / 100);
const y = 206 - height;
return (
<g key={day} className="group outline-none" tabIndex={0} aria-label={`${day}: ${Math.round(value)} percent of weekly maximum`}>
<rect x={x} y={4} width={43} height={202} rx={10} fill="var(--color-chart-track)" />
<rect
x={x - 3}
y={1}
width={49}
height={208}
rx={13}
fill="none"
stroke="var(--color-chart-cursor)"
strokeWidth={2}
className="opacity-0 transition-opacity duration-150 ease-out group-hover:opacity-100 group-focus:opacity-100"
/>
<rect
x={x}
y={y}
width={43}
height={height}
rx={10}
fill="var(--color-chart-1)"
className="transition-[height,y,opacity] duration-300 ease-out group-hover:opacity-80 motion-reduce:transition-none"
/>
<text
x={slot * index + slot / 2}
y={224}
textAnchor="middle"
fill="var(--color-text-secondary)"
className="text-caption-1-medium"
>
{day}
</text>
</g>
);
})}
</svg>
</div>
</section>
);
}