fix(chat,analysis,sidebar): compact cards, sparse citations, range + active state

- Chat patient cards are now compact previews (header + key stat + "Click for
  more"); cards size to content (items-start) instead of stretching to the tallest;
  Edit record moved into the summary detail dialog
- AI citations render once per source per message (collapses per-word spam) and the
  prompt now asks the model to cite sparingly (once per paragraph, not per list item)
- Sidebar sub-items use exact-match active state so Meetings no longer lights Inbox
- Analysis defaults to All Time, drops the 12m option, and clamps the chart window
  to >=2 points so 30d/Today render instead of collapsing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 01:57:31 +03:00
parent c88b674196
commit e841cb56e1
6 changed files with 77 additions and 101 deletions
@@ -28,18 +28,21 @@ import { cn } from "@/lib/utils";
// Time-range filter for the Overview header. Month-based series are sliced to
// the trailing window; sub-month ranges (30d/today) fall back to the latest
// point since the backend has no finer-grained series yet.
const RANGES = ["all", "12m", "3m", "30d", "today"] as const;
const RANGES = ["all", "3m", "30d", "today"] as const;
type Range = (typeof RANGES)[number];
const RANGE_MONTHS: Record<Range, number | null> = {
all: null,
"12m": 12,
"3m": 3,
"30d": 1,
today: 1,
};
// Charts use monthly aggregates, so a sub-month window would collapse to a
// single point (which an area chart can't draw). Keep at least two trailing
// points so every range renders a valid mini-trend.
function sliceMonths<T>(arr: T[], range: Range): T[] {
const months = RANGE_MONTHS[range];
return months == null ? arr : arr.slice(-months);
if (months == null) return arr;
return arr.slice(-Math.max(months, 2));
}
// The Overview sections the Customize popover can show/hide.
@@ -128,7 +131,7 @@ export function AnalysisView() {
const { t } = useTranslation();
const [data, setData] = useState<Analytics | null>(null);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [range, setRange] = useState<Range>("30d");
const [range, setRange] = useState<Range>("all");
const [visible, setVisible] = useState<Record<SectionKey, boolean>>({
visits: true,
patients: true,
@@ -26,13 +26,18 @@ export function hasCitationMarkers(text: string): boolean {
}
// Rewrites [[src:id]] → [n](#cite-id) for known sources; strips unknown markers.
// Each source is cited at most once (its first occurrence) so an over-eager model
// that tags every word with the same source collapses to a single chip.
function withCitationLinks(
text: string,
numberById: Map<string, number>,
): string {
const seen = new Set<string>();
return text.replace(CITATION_RE, (_match, id: string) => {
const num = numberById.get(id);
return num ? `[${num}](${CITE_HREF_PREFIX}${id})` : "";
if (!num || seen.has(id)) return "";
seen.add(id);
return `[${num}](${CITE_HREF_PREFIX}${id})`;
});
}
+48 -87
View File
@@ -1,6 +1,6 @@
"use client";
import { Pencil } from "lucide-react";
import { ArrowRight, Pencil } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -61,9 +61,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
};
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
// plus a subtle clickable affordance (they open a detail dialog).
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
// size to their own (short) content — see `items-start` in PatientResult.
const rowCard =
"w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
@@ -100,30 +101,6 @@ function Empty({ children }: { children: ReactNode }) {
return <p className="text-muted-foreground">{children}</p>;
}
function TrendBlock({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>
{t("patientCard.trend.last", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<span className="text-foreground">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
</span>
</div>
<Sparkline points={trend.points} unit={trend.unit} />
</div>
);
}
function TrendDetail({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
@@ -177,7 +154,8 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
);
}
// A card that previews `children` and opens a roomier dialog of `detail` on click.
// A compact card that previews `children` and opens a roomier dialog of `detail`
// on click. A muted "Click for more" footer signals the card is expandable.
function ExpandableCard({
title,
description,
@@ -189,6 +167,7 @@ function ExpandableCard({
detail: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<Dialog>
<DialogTrigger
@@ -196,6 +175,10 @@ function ExpandableCard({
render={<Card className={cn(rowCard, compactCard)} />}
>
{children}
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
{t("patientCard.clickForMore")}
<ArrowRight className="size-3" />
</div>
</DialogTrigger>
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
<DialogHeader>
@@ -243,6 +226,16 @@ function SummaryCard({
/>
</div>
<AlertBadges alerts={patient.alerts} />
{onEdit ? (
<button
className="flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 font-medium text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={onEdit}
type="button"
>
<Pencil className="size-4" />
{t("patientCard.summary.editRecord")}
</button>
) : null}
</div>
}
title={patient.name}
@@ -259,34 +252,15 @@ function SummaryCard({
<Badge variant={statusVariant[patient.status]}>{statusLabel}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</div>
<AlertBadges alerts={patient.alerts} />
<button
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
onEdit?.();
}}
type="button"
>
<Pencil className="size-4" />
{t("patientCard.summary.editRecord")}
</button>
<CardContent className="grid grid-cols-2 gap-x-3 gap-y-2 pt-0">
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</CardContent>
</ExpandableCard>
);
@@ -327,10 +301,9 @@ function VitalsCard({ patient }: { patient: Patient }) {
{t("patientCard.vitals.taken", { at: vitals.takenAt })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
<Separator />
<TrendBlock trend={patient.vitalsTrend} />
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-2 pt-0">
<Stat label={t("patientCard.vitals.bp")} value={vitals.bp} />
<Stat label={t("patientCard.vitals.hr")} value={vitals.hr} />
</CardContent>
</ExpandableCard>
);
@@ -387,25 +360,6 @@ function LabsCard({ patient }: { patient: Patient }) {
{t("patientCard.labs.asOf", { at: patient.labs[0]?.takenAt ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.labs.length === 0 ? (
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<>
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={<LabValue flag={lab.flag} value={lab.value} />}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
</>
)}
</CardContent>
</ExpandableCard>
);
}
@@ -442,7 +396,6 @@ function MedicationsCard({ patient }: { patient: Patient }) {
})}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
@@ -477,7 +430,6 @@ function ProblemsCard({ patient }: { patient: Patient }) {
{t("patientCard.problems.active", { count: patient.problems.length })}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
);
}
@@ -528,10 +480,19 @@ function AllergiesCard({ patient }: { patient: Patient }) {
>
<CardHeader>
<CardTitle>{t("patientCard.allergies.title")}</CardTitle>
<CardDescription>
{patient.allergies.length === 0
? t("patientCard.allergies.none")
: t("patientCard.allergies.count", {
count: patient.allergies.length,
})}
</CardDescription>
</CardHeader>
<CardContent>
<AllergiesList patient={patient} />
</CardContent>
{patient.alerts.length > 0 ? (
<CardContent className="pt-0">
<AlertBadges alerts={patient.alerts} />
</CardContent>
) : null}
</ExpandableCard>
);
}
@@ -576,10 +537,10 @@ function VisitsCard({ patient }: { patient: Patient }) {
>
<CardHeader>
<CardTitle>{t("patientCard.visits.title")}</CardTitle>
<CardDescription>
{t("patientCard.visits.recent", { count: patient.encounters.length })}
</CardDescription>
</CardHeader>
<CardContent>
<VisitsList patient={patient} />
</CardContent>
</ExpandableCard>
);
}
+7 -3
View File
@@ -31,10 +31,14 @@ export type Route = {
// True when `link` matches the current path. "/" only matches exactly; any other
// link matches itself and its nested routes (so a parent stays lit on subpages).
// Pass `exact` for sub-items so a parent link (e.g. /messages) doesn't also light
// the Inbox sub when a sibling sub (/messages/meetings) is the active page.
function useIsActive() {
const pathname = usePathname();
return (link: string) =>
link === "/" ? pathname === "/" : pathname === link || pathname.startsWith(`${link}/`);
return (link: string, exact = false) => {
if (link === "/" || exact) return pathname === link;
return pathname === link || pathname.startsWith(`${link}/`);
};
}
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
@@ -90,7 +94,7 @@ export default function DashboardNavigation({ routes }: { routes: Route[] }) {
<SidebarMenuSubItem key={`${route.id}-${subRoute.link}`}>
<SidebarMenuSubButton
className="text-muted-foreground hover:bg-transparent hover:text-foreground active:bg-transparent data-[active=true]:bg-transparent data-[active=true]:font-medium data-[active=true]:text-foreground"
isActive={isActive(subRoute.link)}
isActive={isActive(subRoute.link, true)}
render={<Link href={subRoute.link} prefetch={true} />}
>
<span className="truncate">{subRoute.title}</span>
@@ -1206,6 +1206,7 @@
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
"clickForMore": "Click for more",
"sex": {
"F": "Female",
"M": "Male"
@@ -1251,7 +1252,8 @@
"allergies": {
"title": "Allergies & alerts",
"sectionLabel": "Allergies",
"none": "No known allergies."
"none": "No known allergies.",
"count": "{{count}} recorded"
},
"visits": {
"title": "Recent visits",