From d2cdf9d664bfe49aebc2b1c4eb95d6490731158c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 02:05:10 +0200 Subject: [PATCH] feat(dashboard): InsightsCardPreview matches insights page visual language Replaces the orphan v1.4.0 InsightsCard with a leaner that surfaces only the top 1-2 severity-ordered recommendations + a "View all" CTA pointing to /insights. Same visual language as the page (Dracula sparkle glyph, severity-coloured left border, ring-variant confidence meter inline). The legacy InsightsCard handled its own data fetching against the deprecated changed/stable/drivers/nextSteps shape; preview now takes an `insight: InsightResult | null` prop so the dashboard wires its existing `/api/insights/generate` data without redundant fetches. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 2 + messages/en.json | 2 + .../insights/__tests__/insights-card.test.tsx | 161 ++++++++++++ src/components/insights/insights-card.tsx | 239 +++++++----------- 4 files changed, 251 insertions(+), 153 deletions(-) create mode 100644 src/components/insights/__tests__/insights-card.test.tsx 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 ( - +
- - +
- - {error && ( -
- - {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 ( -
-

{title}

-

{text}

-
- ); -}