mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 00:47:41 +00:00
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:
@@ -165,11 +165,12 @@ function systemPrompt(
|
||||
"tool, so keep your prose a brief summary rather than re-listing every field.",
|
||||
"",
|
||||
"Citations: every retrieval tool result includes a `sourceId` (e.g. \"s1\").",
|
||||
"When you state a fact drawn from a tool result, append an inline citation",
|
||||
"marker immediately after that statement, in the exact form [[src:ID]] using",
|
||||
"the matching sourceId — e.g. \"BP is well controlled [[src:s1]].\" Cite only",
|
||||
"facts grounded in tool results, place the marker right after the relevant",
|
||||
"sentence, and never invent or guess a sourceId.",
|
||||
"Cite **sparingly** — add at most ONE marker per paragraph, on the single most",
|
||||
"important record-derived claim, in the exact form [[src:ID]] using the matching",
|
||||
"sourceId (e.g. \"BP is well controlled this quarter [[src:s1]].\"). Do NOT cite",
|
||||
"every sentence, do NOT cite individual list items (e.g. each allergy or",
|
||||
"medication), and never repeat the same source more than once. Cite only facts",
|
||||
"grounded in tool results, and never invent or guess a sourceId.",
|
||||
veilActive
|
||||
? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.`
|
||||
: "",
|
||||
|
||||
@@ -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})`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user