From 9afb31bd15375c2844d57f3006b4246841317973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:35:05 +0200 Subject: [PATCH] chore(dashboard): retire the standalone InsightsCardPreview surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard preview duplicated the much-richer /insights advisor surface that already lives one click away (Daily Briefing, hero strip, correlation row, full advisor, weekly-report banner). The preview also competed visually with the GLP-1 tile for the same vertical real estate above the chart row. Drop the import + render + isChartVisible gate in src/app/page.tsx, drop the insightsPreview widget entry from src/lib/dashboard-layout.ts, drop the matching row in src/components/settings/dashboard-layout-section.tsx, and delete src/components/insights/insights-card.tsx and its test file. The advisor query hook stays on /insights — the dashboard no longer mounts it. Update the dashboard-layout contract test to assert the widget id is absent. F7 weekly-report scan: ran grep -rinE 'Wochenreport|weekly report|/insights/report' across src/ and messages/. All weekly-report click affordances on /insights are correctly wired (HeroStrip Generate button → currentWeekHref, WeeklyReportBanner → /insights/report/[week], InsightAdvisorCard banner → /insights/report/[week]). The /insights/report/[week] route mounts WeeklyReportView and renders the full surface. No dead click affordance was located inside the 30-minute scan budget. The retired InsightsCardPreview was the only dashboard-anchored insight card with a CTA — its removal in this commit is the most likely reason the maintainer perceived a dead click. F7 deferred to v1.4.28 with a maintainer-screenshot ask; recorded in .planning/v1428-backlog.md. i18n keys still referenced after this change (B6 sweep will reconcile): none from this commit. Keys 'dashboard.insightsPreview' and 'insights.aiInsights' are now unreferenced in code and become candidates for B6's dead-key sweep. --- src/app/page.tsx | 48 ++---- .../insights/__tests__/insights-card.test.tsx | 159 ------------------ src/components/insights/insights-card.tsx | 118 ------------- .../insights/use-insights-advisor.ts | 6 +- .../settings/dashboard-layout-section.tsx | 4 - src/lib/__tests__/dashboard-layout.test.ts | 12 +- src/lib/dashboard-layout.ts | 18 +- src/lib/query-keys.ts | 10 +- 8 files changed, 26 insertions(+), 349 deletions(-) delete mode 100644 src/components/insights/__tests__/insights-card.test.tsx delete mode 100644 src/components/insights/insights-card.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 82d15cec8..40a453e9d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -46,8 +46,6 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; -import { InsightsCardPreview } from "@/components/insights/insights-card"; -import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { Glp1Tile } from "@/components/dashboard/glp1-tile"; const HealthChart = dynamic( @@ -234,16 +232,13 @@ export default function DashboardPage() { enabled: isAuthenticated, }); - // v1.4.16 phase D reconcile (CRITICAL C2) — pull the rich advisor - // payload so the dashboard can surface a compact preview of the top - // severity-ordered AI recommendations + ring confidence meter + - // "View all" CTA. Shares the cache with /insights via the - // queryKeys.insightsAdvisor() key so a regenerate on either surface - // refreshes the other without a second LLM round-trip. Returns null - // when the user has no provider configured (route 422 → null), so - // the preview self-hides without burning rate-limit tokens on - // unconfigured accounts. - const advisor = useInsightsAdvisorQuery(isAuthenticated); + // v1.4.27 B1 — the dashboard's `` retired (it + // duplicated the much-richer `/insights` advisor surface). The advisor + // query lives on `/insights` directly; the dashboard no longer needs + // its own hook subscription, so the local `useInsightsAdvisorQuery` + // call dropped with the preview. + + // TODO(B1+B4 reconcile): wire per-tile availability gate using hasMetricData const w = data?.summaries?.WEIGHT; const sys = data?.summaries?.BLOOD_PRESSURE_SYS; @@ -340,12 +335,6 @@ export default function DashboardPage() { // the layout-toggle gate here. No data-floor check (the empty card is // intentional — the maintainer wants the user to discover the feature). const showAchievementsCard = isChartVisible("achievements"); - // v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard - // insights preview by the layout toggle. The component itself - // returns null when the advisor payload is missing or has no - // recommendations, so the gate is sufficient — no extra data-floor - // check needed. - const showInsightsPreview = isChartVisible("insightsPreview"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -1247,29 +1236,14 @@ export default function DashboardPage() { ))} )} - {/* v1.4.16 phase D reconcile (CRITICAL C2) — dashboard - preview of the polished AI recommendations surface. - Pinned above the chart row (out-of-band from the sorted - charts[] array) so it stays at the top regardless of - widget reorder operations. Self-hides when the user has - no provider configured OR no recommendations to surface - (`` returns null), so the preview - doesn't paint an empty card on first visit. */} - {showInsightsPreview && ( - - )} {/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on `Medication.treatmentClass === "GLP1"` (route returns `data: null` when the user has no active GLP-1 med), so we always mount it and let the tile suppress itself. - Slot is below the InsightsCardPreview and above the - chart row — high enough that a Mounjaro / Ozempic user - sees their dose-response chart without scrolling past - BP / pulse, but below the AI preview so the latter - stays the "scroll-stop hero" Marc anchored the dashboard - around. */} + v1.4.27 — the standalone insights preview retired (it + duplicated the much-richer `/insights` advisor surface); + the GLP-1 tile now anchors the top of the chart-row + stack. */} {charts.map((entry) => (
diff --git a/src/components/insights/__tests__/insights-card.test.tsx b/src/components/insights/__tests__/insights-card.test.tsx deleted file mode 100644 index d2d3eb893..000000000 --- a/src/components/insights/__tests__/insights-card.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index c593bb755..000000000 --- a/src/components/insights/insights-card.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { Sparkles, ArrowRight } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; -import type { InsightResult } from "@/lib/ai/types"; -import { sortRecommendationsBySeverity } from "./recommendations-grid"; -import { ConfidenceMeter } from "./confidence-meter"; - -/** - * 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; -} - -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", -}; - -const SEVERITY_BORDER_FALLBACK = "border-l-border"; - -export function InsightsCardPreview({ - insight, - topN = 2, -}: InsightsCardPreviewProps) { - const { t } = useTranslations(); - if (!insight || insight.recommendations.length === 0) return null; - - const ordered = sortRecommendationsBySeverity(insight.recommendations).slice( - 0, - topN, - ); - - return ( - - -
-
-
- -
-
- -
    - {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 ( -
  • -

    {stripChartTokens(text)}

    - {typeof confidence === "number" && ( - - - - )} -
  • - ); - })} -
-
-
- ); -} diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 105fab680..beb37b6ae 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -23,9 +23,9 @@ import { queryKeys } from "@/lib/query-keys"; * C1+C2 specifically because it assumed that duplication was required, * but the route already supports the cache-aware path. * - * Both `/insights` (``) and `/` (``) - * mount the query under the same key, so a regenerate from either surface - * refreshes the other without a second LLM call. + * `/insights` (``) and any other surface that mounts + * the query under the same key share the cache, so a regenerate from + * one surface refreshes the others without a second LLM call. */ export interface InsightAdvisorPayload { insights: InsightResult; diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 849cae60d..72d0c3c08 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -44,10 +44,6 @@ const WIDGET_LABEL_KEYS: Record = { bpInTarget: "dashboard.bpInTarget", oxygenSaturation: "measurements.typeOxygenSaturation", achievements: "achievements.title", - // v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview tile - // for the polished `` (top severity-ordered AI - // recommendations + ring confidence meter + "View all" CTA). - insightsPreview: "dashboard.insightsPreview", // v1.4.25 W8d — VO2 max secondary-metric tile (opt-in). vo2Max: "dashboard.vo2Max", }; diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 49814afa2..4a0ddc4a9 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -141,13 +141,13 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { expect(ids).toContain("achievements"); }); - it("includes the insightsPreview widget (v1.4.16 phase D reconcile C2)", () => { - // CRITICAL C2: dashboard preview of the polished AI recommendations - // surface. Same Zod-enum-drift trap as A5 — if the widget id isn't - // listed here, the API PUT silently 422s and the layout toggle - // never persists. Pin the contract to catch a regression early. + it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => { + // v1.4.27 B1 retired the standalone dashboard preview because it + // duplicated the much-richer `/insights` advisor surface. The + // widget id must stay out of the default layout so a stale client + // cannot reintroduce it via a layout PUT. const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); - expect(ids).toContain("insightsPreview"); + expect(ids).not.toContain("insightsPreview"); }); it("survives a round-trip through serialize → resolve unchanged", () => { diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 583505644..7c9b184b4 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -29,11 +29,6 @@ export const DASHBOARD_WIDGET_IDS = [ "bpInTarget", "oxygenSaturation", "achievements", - // v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview of the - // top severity-ordered AI recommendations + "View all" CTA. Has only - // a `visible` (chart-row) surface; tileVisible is forced false because - // there's no tile-strip representation for it. - "insightsPreview", // v1.4.25 W8d — VO2 max trend tile (opt-in, secondary-metric pattern). // Self-gates on having any VO2_MAX sample; default-invisible so // existing dashboards stay unchanged until the user enables it in @@ -216,22 +211,11 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // `tileVisible` is forced false because there is no tile surface // for this widget; only the chart-row card. { id: "achievements", visible: true, tileVisible: false, order: 13 }, - // v1.4.16 phase D reconcile (CRITICAL C2) — AI insights preview - // anchored at the top of the chart row. Default visible so users - // discover the feature without hunting through Settings → - // Dashboard; self-gates on having a generated payload AND at least - // one recommendation (`` returns null - // otherwise). `tileVisible` forced false because the preview lives - // in the chart row, not the tile strip. The dashboard page - // renders this entry independently of the sorted `charts[]` array - // so it stays pinned above charts even if the user reorders other - // widgets; only the boolean visibility flag is consumed. - { id: "insightsPreview", visible: true, tileVisible: false, order: 14 }, // v1.4.25 W8d — VO2 max trend tile. Default-invisible on both // surfaces; opt-in via Settings → Dashboard. Only the tile-strip // representation today (`visible` stays false so the chart-row // gate never fires; the chart card lands in v1.5). - { id: "vo2Max", visible: false, tileVisible: false, order: 15 }, + { id: "vo2Max", visible: false, tileVisible: false, order: 14 }, ], }; diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 9bd30329b..8d7ebd0bd 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -23,11 +23,11 @@ export const queryKeys = { insightsComprehensive: () => ["insights", "comprehensive"] as const, insightsTargets: () => ["insights", "targets"] as const, /** - * v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared cache key for - * the rich `/api/insights/generate` advisor payload. Both `/insights` - * (full ``) and `/` (``) read - * this key so a regenerate on one surface refreshes the other without - * a second LLM round-trip. + * Shared cache key for the rich `/api/insights/generate` advisor + * payload. `/insights` (full ``) and any other + * surface that subscribes under this key share the same cache so a + * regenerate on one surface refreshes the others without a second + * LLM round-trip. */ insightsAdvisor: () => ["insights", "advisor"] as const, insightsBpStatus: (locale: string) =>