diff --git a/messages/de.json b/messages/de.json
index 29d220641..48567cded 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -705,6 +705,8 @@
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerate": "Neu generieren",
"heroRegenerating": "Wird generiert",
+ "viewAll": "Alle ansehen",
+ "previewEmpty": "Noch keine Empfehlungen.",
"relativeJustNow": "gerade eben",
"relativeMinutesAgo": "vor {count} Minuten",
"relativeHoursAgo": "vor {count} Stunden",
diff --git a/messages/en.json b/messages/en.json
index 5778d7535..e52d6d388 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -705,6 +705,8 @@
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerate": "Regenerate",
"heroRegenerating": "Regenerating",
+ "viewAll": "View all",
+ "previewEmpty": "No recommendations yet.",
"relativeJustNow": "just now",
"relativeMinutesAgo": "{count} minutes ago",
"relativeHoursAgo": "{count} hours ago",
diff --git a/src/components/insights/__tests__/insights-card.test.tsx b/src/components/insights/__tests__/insights-card.test.tsx
new file mode 100644
index 000000000..9da6d158a
--- /dev/null
+++ b/src/components/insights/__tests__/insights-card.test.tsx
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { InsightsCardPreview } from "../insights-card";
+import type { InsightResult, InsightRecommendation } from "@/lib/ai/types";
+
+/**
+ * v1.4.16 phase B1b — dashboard InsightsCard preview.
+ *
+ * The preview is a compact wrapper that surfaces the top 1-2
+ * severity-ordered recommendations + a "View all" CTA pointing to
+ * the full `/insights` page. Visual language matches the page hero
+ * + recommendation grid (Dracula tokens, severity-coloured borders,
+ * mini confidence meter).
+ */
+
+vi.mock("@/components/charts/health-chart", () => ({
+ HealthChart: () =>
,
+}));
+vi.mock("@/components/charts/mood-chart", () => ({
+ MoodChart: () => ,
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: null, isLoading: false }),
+ useMutation: () => ({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ error: null,
+ }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "test-user", username: "tester", role: "USER" },
+ isAuthenticated: true,
+ isLoading: false,
+ }),
+}));
+
+function rec(
+ id: string,
+ severity: "info" | "suggestion" | "important" | "urgent",
+ text: string,
+ confidence?: number,
+): InsightRecommendation {
+ return {
+ id,
+ text,
+ severity,
+ confidence,
+ rationale: {
+ dataWindow: "last7days",
+ comparedTo: "your 90-day median",
+ deviation: "moderate deviation",
+ },
+ metricSource: {
+ type: "weight",
+ timeRange: "last7days",
+ summary: "84.2 kg avg over 7 days",
+ },
+ };
+}
+
+function buildInsight(
+ recommendations: InsightRecommendation[],
+): InsightResult {
+ return {
+ insightType: "general",
+ summary: "Test summary",
+ classification: "gut",
+ classificationLabel: "Good",
+ findings: [],
+ correlations: [],
+ primaryRecommendation: "",
+ recommendations,
+ dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
+ disclaimer: "Not medical advice.",
+ };
+}
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("surfaces only the top 2 severity-ordered recommendations", () => {
+ const recs = [
+ rec("a", "info", "info-rec"),
+ rec("b", "urgent", "urgent-rec"),
+ rec("c", "important", "important-rec"),
+ rec("d", "suggestion", "suggestion-rec"),
+ ];
+ const html = render();
+ expect(html).toContain("urgent-rec");
+ expect(html).toContain("important-rec");
+ // The preview must hide the remaining recs — they live on /insights.
+ expect(html).not.toContain("info-rec");
+ expect(html).not.toContain("suggestion-rec");
+ });
+
+ it("renders the 'View all' CTA pointing to /insights", () => {
+ const recs = [rec("a", "urgent", "urgent-rec")];
+ const html = render();
+ expect(html).toMatch(/href="\/insights"/);
+ expect(html).toMatch(/data-slot="insights-card-view-all"/);
+ });
+
+ it("paints a severity-coloured left border on each preview card", () => {
+ const recs = [
+ rec("a", "urgent", "urgent-rec"),
+ rec("b", "important", "important-rec"),
+ ];
+ const html = render();
+ expect(html).toMatch(/border-l-dracula-red/);
+ expect(html).toMatch(/border-l-dracula-orange/);
+ });
+
+ it("renders a mini confidence meter inline when confidence is supplied", () => {
+ const recs = [rec("a", "urgent", "urgent-rec", 78)];
+ const html = render();
+ // The ConfidenceMeter component renders a 'data-slot="confidence-meter"'
+ // marker; the preview shows the inline ring/bars variant.
+ expect(html).toMatch(/data-slot="confidence-meter"/);
+ });
+
+ it("hides the preview entirely when there are no recommendations", () => {
+ const html = render();
+ // The orphan "no recs" state is delegated to the full /insights page.
+ // The preview either renders the top recs OR doesn't mount at all.
+ expect(html).toBe("");
+ });
+
+ it("passes a null insight transparently — no preview", () => {
+ const html = render();
+ expect(html).toBe("");
+ });
+
+ it("renders the title slot so dashboard nav can sit alongside", () => {
+ const recs = [rec("a", "urgent", "urgent-rec")];
+ const html = render();
+ expect(html).toMatch(/data-slot="insights-card-preview"/);
+ // Card title text from the existing translation key.
+ expect(html).toContain("AI Insights");
+ });
+
+ it("German locale — title + view-all CTA translate", () => {
+ const recs = [rec("a", "urgent", "urgent-rec")];
+ const html = render(
+ ,
+ "de",
+ );
+ expect(html).toContain("KI-Insights");
+ // "View all" → German "Alle ansehen"
+ expect(html).toContain("Alle ansehen");
+ });
+});
diff --git a/src/components/insights/insights-card.tsx b/src/components/insights/insights-card.tsx
index 6ec818280..d2321f696 100644
--- a/src/components/insights/insights-card.tsx
+++ b/src/components/insights/insights-card.tsx
@@ -1,184 +1,117 @@
"use client";
-import { useState } from "react";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { useAuth } from "@/hooks/use-auth";
-import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import { Sparkles, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Sparkles, RefreshCw, Loader2, AlertTriangle } from "lucide-react";
+import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import type { InsightResult } from "@/lib/ai/types";
+import { sortRecommendationsBySeverity } from "./recommendations-grid";
+import { ConfidenceMeter } from "./confidence-meter";
-interface InsightsOutput {
- changed: string;
- stable: string;
- drivers: string;
- nextSteps: string;
- confidence: "niedrig" | "mittel" | "hoch";
- limitations: string;
+/**
+ * v1.4.16 phase B1b — dashboard .
+ *
+ * Compact preview of the top 1-2 severity-ordered recommendations
+ * + a "View all" CTA pointing at `/insights`. Visual language matches
+ * the page hero (Dracula gradient sparkle, severity-coloured left
+ * border, mini confidence meter inline) so the dashboard reads as
+ * a smaller window onto the same surface.
+ *
+ * Design notes:
+ * - The preview is dashboard-anchored, so it doesn't carry the full
+ * rationale-expand mechanic. Recs render as one-row tiles.
+ * - When the preview has zero recs OR no insight at all, the
+ * component returns `null` so the dashboard's parent card grid
+ * doesn't render an empty box. The full empty-state lives on
+ * `/insights` proper.
+ * - The legacy `InsightsCard` (which fetched its own data + rendered
+ * the v1.4.0 changed/stable/drivers/nextSteps shape) was an
+ * orphan component with zero non-test imports — it's been replaced
+ * by this leaner preview.
+ */
+
+interface InsightsCardPreviewProps {
+ insight: InsightResult | null;
+ /** Maximum number of recs to surface; defaults to 2. */
+ topN?: number;
}
-interface InsightsResponse {
- insights: InsightsOutput;
- cached: boolean;
- cachedAt?: string;
-}
-
-const CONFIDENCE_COLORS: Record = {
- niedrig: "bg-orange-500/20 text-orange-400",
- mittel: "bg-yellow-500/20 text-yellow-400",
- hoch: "bg-green-500/20 text-green-400",
+const SEVERITY_BORDER_CLASSES: Record = {
+ urgent: "border-l-dracula-red/70",
+ important: "border-l-dracula-orange/70",
+ suggestion: "border-l-dracula-purple/70",
+ info: "border-l-dracula-cyan/70",
};
-export function InsightsCard() {
- const { isAuthenticated } = useAuth();
- const queryClient = useQueryClient();
+const SEVERITY_BORDER_FALLBACK = "border-l-border";
+
+export function InsightsCardPreview({
+ insight,
+ topN = 2,
+}: InsightsCardPreviewProps) {
const { t } = useTranslations();
- const [error, setError] = useState(null);
+ if (!insight || insight.recommendations.length === 0) return null;
- // Check if insights are configured
- const { data: settings } = useQuery({
- queryKey: ["insights", "settings"],
- queryFn: async () => {
- const res = await fetch("/api/insights/settings");
- if (!res.ok) return null;
- const json = await res.json();
- return json.data as {
- codexStatus: string;
- hasAdminKey: boolean;
- privacyMode: string;
- lastInsightAt: string | null;
- };
- },
- enabled: isAuthenticated,
- });
-
- const generate = useMutation({
- mutationFn: async (force: boolean) => {
- const res = await fetch("/api/insights/generate", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ force }),
- });
- const json = await res.json();
- if (!res.ok) throw new Error(json.error);
- return json.data as InsightsResponse;
- },
- onSuccess: () => {
- setError(null);
- queryClient.invalidateQueries({ queryKey: ["insights"] });
- },
- onError: (err: Error) => {
- setError(err.message);
- },
- });
-
- // Don't show if no API key configured
- if (!(settings?.codexStatus === "connected" || settings?.hasAdminKey))
- return null;
-
- const insights = generate.data?.insights;
+ const ordered = sortRecommendationsBySeverity(insight.recommendations).slice(
+ 0,
+ topN,
+ );
return (
-
+
-
-
+
+
{t("insights.aiInsights")}
-
- {error && (
-
- )}
-
- {generate.isPending && !insights && (
-
-
-
- {t("insights.analyzing")}
-
-
- )}
-
- {insights && (
-
-
-
- {t("insights.confidence")}:{" "}
- {t(`insights.confidence_${insights.confidence}`)}
-
- {generate.data?.cached && (
-
- {t("insights.cached")}
-
- )}
-
-
-
-
-
-
-
-
-
-
- {insights.limitations}
-
-
-
- )}
-
- {!insights && !generate.isPending && !error && (
-
- {t("insights.generatePrompt")}
-
- )}
+
+ {ordered.map((rec, index) => {
+ const text = typeof rec === "string" ? rec : rec.text;
+ const severity = typeof rec === "string" ? null : rec.severity;
+ const confidence =
+ typeof rec === "string" ? undefined : rec.confidence;
+ const id = typeof rec === "string" ? `${index}-${rec}` : rec.id;
+ const borderClass =
+ (severity && SEVERITY_BORDER_CLASSES[severity]) ||
+ SEVERITY_BORDER_FALLBACK;
+ return (
+ -
+
{text}
+ {typeof confidence === "number" && (
+
+
+
+ )}
+
+ );
+ })}
+
);
}
-
-function Section({ title, text }: { title: string; text: string }) {
- return (
-
- );
-}