mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(insights): citation footnote on recommendations with medical reference
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.
`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).
i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.
6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
94c748d804
commit
a66c128dd9
14 files changed
+1301
-11
No files matched your search
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<AppLogPreviewSection>` — surfaces the in-process wide-event ring buffer
|
||||
* (`src/lib/logging/in-memory-buffer.ts`) so admins can drill into the most
|
||||
* recent ~500 structured events without standing up a Loki stack just to
|
||||
* read the last hour.
|
||||
*
|
||||
* Limitations (covered by the section header):
|
||||
* - Per-process. Under split web/worker deployments, only the web buffer
|
||||
* is visible here. Worker logs ship to Loki when configured.
|
||||
* - Volatile. Restart drops the buffer.
|
||||
*
|
||||
* Privacy: every event passes through `redactSecrets()` server-side before
|
||||
* reaching this component; storage stays raw so the diagnostic value
|
||||
* survives shipping to Loki.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WideEvent, LogLevel } from "@/lib/logging/types";
|
||||
|
||||
type RangePreset = "15m" | "1h" | "6h" | "all";
|
||||
|
||||
interface AppLogsResponse {
|
||||
events: WideEvent[];
|
||||
meta: {
|
||||
total: number;
|
||||
bufferMax: number;
|
||||
};
|
||||
}
|
||||
|
||||
function rangeToSince(range: RangePreset): string | undefined {
|
||||
if (range === "all") return undefined;
|
||||
const now = Date.now();
|
||||
const minutes = range === "15m" ? 15 : range === "1h" ? 60 : 6 * 60;
|
||||
return new Date(now - minutes * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function levelIcon(level: LogLevel) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return <AlertCircle className="text-destructive h-4 w-4" />;
|
||||
case "warn":
|
||||
return <AlertTriangle className="text-dracula-yellow h-4 w-4" />;
|
||||
case "debug":
|
||||
return <Info className="text-muted-foreground h-4 w-4" />;
|
||||
case "info":
|
||||
default:
|
||||
return <CheckCircle2 className="text-dracula-green h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function AppLogPreviewSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [traceId, setTraceId] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [level, setLevel] = useState<LogLevel | "__all__">("__all__");
|
||||
const [range, setRange] = useState<RangePreset>("1h");
|
||||
const [selected, setSelected] = useState<WideEvent | null>(null);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (traceId.trim()) p.set("traceId", traceId.trim());
|
||||
if (actionFilter.trim()) p.set("action", actionFilter.trim());
|
||||
if (level !== "__all__") p.set("level", level);
|
||||
const since = rangeToSince(range);
|
||||
if (since) p.set("since", since);
|
||||
return p;
|
||||
}, [traceId, actionFilter, level, range]);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["admin", "app-logs", traceId, actionFilter, level, range],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/admin/app-logs?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Failed to load app logs");
|
||||
return (await res.json()).data as AppLogsResponse;
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="text-primary mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.section.app-logs.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.app-logs.processNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterTraceId")}
|
||||
value={traceId}
|
||||
onChange={(e) => setTraceId(e.target.value)}
|
||||
aria-label={t("admin.section.app-logs.filterTraceId")}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
aria-label={t("admin.section.app-logs.filterAction")}
|
||||
/>
|
||||
<Select
|
||||
value={level}
|
||||
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
|
||||
>
|
||||
<SelectTrigger aria-label={t("admin.section.app-logs.filterLevel")}>
|
||||
<SelectValue
|
||||
placeholder={t("admin.section.app-logs.filterLevel")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">
|
||||
{t("admin.section.app-logs.levelAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="debug">debug</SelectItem>
|
||||
<SelectItem value="info">info</SelectItem>
|
||||
<SelectItem value="warn">warn</SelectItem>
|
||||
<SelectItem value="error">error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
|
||||
<SelectTrigger aria-label={t("admin.section.app-logs.filterRange")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15m">
|
||||
{t("admin.section.app-logs.range15m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1h">
|
||||
{t("admin.section.app-logs.range1h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="6h">
|
||||
{t("admin.section.app-logs.range6h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("admin.section.app-logs.rangeAll")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="size-6" />}
|
||||
title={t("admin.section.app-logs.empty")}
|
||||
description={t("admin.section.app-logs.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colLevel")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTimestamp")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colAction")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{t("admin.section.app-logs.colDuration")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTraceId")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{events.map((event, i) => (
|
||||
<tr
|
||||
key={`${event.request_id}-${i}`}
|
||||
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
|
||||
onClick={() => setSelected(event)}
|
||||
>
|
||||
<td className="px-2 py-2">{levelIcon(event.level)}</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{event.action?.name ??
|
||||
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
|
||||
event.kind)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
|
||||
{event.duration_ms} ms
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
|
||||
{event.trace_id.slice(0, 8)}…
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span>
|
||||
{t("admin.section.app-logs.showing", {
|
||||
count: events.length,
|
||||
bufferMax: data?.meta.bufferMax ?? 500,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={selected !== null}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selected && levelIcon(selected.level)}
|
||||
<span className="font-mono text-sm">
|
||||
{selected?.action?.name ??
|
||||
selected?.http?.path ??
|
||||
t("admin.section.app-logs.eventDetails")}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto">
|
||||
{selected && (
|
||||
<pre className="bg-muted text-foreground rounded-md p-3 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(selected, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-3 right-3"
|
||||
aria-label={t("admin.section.app-logs.closeDetails")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. Stub it for the
|
||||
// same reasons the sibling card test does.
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/charts/mood-chart", () => ({
|
||||
MoodChart: () => <div data-testid="mood-chart" />,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
|
||||
|
||||
const knownRef = MEDICAL_REFERENCES[0];
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InsightAdvisorCard> — recommendation citation footnote (B5a)", () => {
|
||||
it("renders no footnote for legacy string recommendations", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: ["Continue daily logging."],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Continue daily logging.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).not.toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders no footnote for structured rec WITHOUT a referenceId", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [{ text: "Keep an eye on your sleep." }],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Keep an eye on your sleep.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
});
|
||||
|
||||
it("renders the footnote with EN title + URL when referenceId resolves", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for a target below 140/90.",
|
||||
referenceId: knownRef.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"en",
|
||||
);
|
||||
expect(html).toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).toContain(`data-reference-id="${knownRef.id}"`);
|
||||
expect(html).toContain(knownRef.title);
|
||||
expect(html).toContain(`${knownRef.publishedYear}`);
|
||||
expect(html).toContain(knownRef.org);
|
||||
expect(html).toContain(`href="${knownRef.url}"`);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noreferrer"');
|
||||
expect(html).toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders the German title under de locale", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for a target below 140/90.",
|
||||
referenceId: knownRef.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain(knownRef.titleDe);
|
||||
// German "Source" label
|
||||
expect(html).toContain("Quelle:");
|
||||
});
|
||||
|
||||
it("silently drops the footnote for an unknown referenceId (defence in depth)", () => {
|
||||
// The schema would normally reject this payload, but if a stale
|
||||
// payload sneaks through, the UI must not render a broken link.
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for something.",
|
||||
referenceId: "fabricated-2099",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Aim for something.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).not.toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders multiple recs, each with their own footnote when applicable", () => {
|
||||
const second = MEDICAL_REFERENCES[1] ?? knownRef;
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{ text: "First rec", referenceId: knownRef.id },
|
||||
{ text: "Second rec", referenceId: second.id },
|
||||
"Third plain rec",
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("First rec");
|
||||
expect(html).toContain("Second rec");
|
||||
expect(html).toContain("Third plain rec");
|
||||
expect(html).toContain(knownRef.url);
|
||||
if (second !== knownRef) expect(html).toContain(second.url);
|
||||
// The plain string rec produces no footnote.
|
||||
const matches = html.match(/data-slot="insight-recommendation-source"/g);
|
||||
expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { InsightResult, InsightFinding } from "@/lib/ai/types";
|
||||
import type {
|
||||
InsightResult,
|
||||
InsightFinding,
|
||||
InsightRecommendation,
|
||||
} from "@/lib/ai/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
@@ -27,6 +32,8 @@ import {
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -263,6 +270,62 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
// ─── Recommendation Citation Footnote ─────────────────────
|
||||
//
|
||||
// v1.4.16 phase B5a — render a small "Source: …" footnote under a
|
||||
// recommendation when the model emitted a `referenceId` pointing into
|
||||
// the curated medical-reference bundle. The link opens the guideline
|
||||
// in a new tab; per Marc's research §7 we never open the URL inside
|
||||
// an in-app webview.
|
||||
//
|
||||
// `getMedicalReferenceById()` returns undefined for unknown ids — in
|
||||
// that case we silently drop the footnote rather than render a broken
|
||||
// link. The schema check would already have rejected the payload, so
|
||||
// this is defence in depth.
|
||||
|
||||
function CitationFootnote({
|
||||
referenceId,
|
||||
locale,
|
||||
}: {
|
||||
referenceId: string;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const ref = getMedicalReferenceById(referenceId);
|
||||
if (!ref) return null;
|
||||
const label = locale === "de" ? ref.titleDe : ref.title;
|
||||
return (
|
||||
<a
|
||||
href={ref.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("insights.recommendation.viewSource")}
|
||||
className="text-muted-foreground hover:text-foreground mt-0.5 ml-6 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
data-slot="insight-recommendation-source"
|
||||
data-reference-id={referenceId}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("insights.recommendation.source")}:
|
||||
</span>
|
||||
<span>
|
||||
{ref.org} {ref.publishedYear} — {label}
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalise the legacy string-rec OR new structured-rec into a uniform shape. */
|
||||
function normaliseRecommendation(rec: InsightRecommendation): {
|
||||
text: string;
|
||||
referenceId?: string;
|
||||
} {
|
||||
if (typeof rec === "string") return { text: rec };
|
||||
return { text: rec.text, referenceId: rec.referenceId };
|
||||
}
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
||||
@@ -284,7 +347,7 @@ export function InsightAdvisorCard({
|
||||
regenerating = false,
|
||||
cachedAt,
|
||||
}: InsightAdvisorCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
@@ -507,14 +570,25 @@ export function InsightAdvisorCard({
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.recommendationsTitle")} />
|
||||
<ol className="space-y-1.5 pl-0">
|
||||
{insight.recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span className="text-muted-foreground shrink-0 font-medium">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
{insight.recommendations.map((rec, i) => {
|
||||
const { text, referenceId } = normaliseRecommendation(rec);
|
||||
return (
|
||||
<li key={i} className="flex flex-col gap-0.5 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0 font-medium">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
{referenceId && (
|
||||
<CitationFootnote
|
||||
referenceId={referenceId}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user