fix(insights): mount InsightAdvisorCard on /insights page (wire B5c/d/e + B1b polish to live route)

The /insights page rendered only the text-only InsightStatusCard per
section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence
meter), B5e (thumbs feedback) and the broader B1b polish from users.
Wave-D design review flagged this as CRITICAL C1.

Wires the InsightAdvisorCard component into the live route, consuming
the existing /api/insights/generate POST. The route already returns the
24h-cached payload without burning a rate-limit token, so a single
POST-without-force is functionally a GET-or-generate — no new API
surface needed (the reconcile report deferred C1 because it assumed
otherwise).

The cache is shared with the dashboard preview via the new
queryKeys.insightsAdvisor() so a regenerate on either surface refreshes
the other without a second LLM call.

Per-section InsightStatusCards stay below as supplemental detail; the
hero now also wires its regenerate button to the same handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 03:28:52 +02:00
1 parent 54bbc68eff
commit aae968a6d5
6 files changed
+388 -1

No files matched your search

+36 -1
View File
@@ -42,7 +42,9 @@ import {
TrendingUp,
} from "lucide-react";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
import { InsightsPageHero } from "@/components/insights/insights-page-hero";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { CompareToggle } from "@/components/comparison/compare-toggle";
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
// wrapper so the bundle only lands once a correlation card actually renders.
@@ -579,6 +581,15 @@ export default function InsightsPage() {
const compareBaseline =
resolveDashboardLayout(layoutData).comparisonBaseline ?? "none";
// v1.4.16 phase D reconcile (CRITICAL C1) — pull the rich advisor
// payload (severity-ordered recommendations + rationale + confidence
// + medical-citation footnotes + thumbs feedback) so this page
// surfaces the polished `<InsightAdvisorCard>` from B5c/d/e/B1b
// instead of the v1.4.15 text-only `<InsightStatusCard>` summary.
// Cache-aware: a regenerate on the dashboard preview (C2) hot-swaps
// this query under the same key.
const advisor = useInsightsAdvisorQuery(isAuthenticated);
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
@@ -893,7 +904,11 @@ export default function InsightsPage() {
return (
<div className="space-y-8">
<InsightsPageHero updatedAt={heroUpdatedAt} />
<InsightsPageHero
updatedAt={advisor.payload?.cachedAt ?? heroUpdatedAt}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
{/* v1.4.16 phase D reconcile (CRITICAL C3) — on-surface comparison
toggle. Mirrors the dashboard placement so flipping Vormonat /
@@ -903,6 +918,26 @@ export default function InsightsPage() {
<CompareToggle />
</div>
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
`<InsightAdvisorCard>` (severity-ordered recommendations grid +
per-rec rationale expand + confidence meter + thumbs feedback +
medical-citation footnotes) into the live route. Before this
fix the page rendered only the text-only `<InsightStatusCard>`
per section, hiding all of B5c/d/e/B1b's polish from users.
The per-section status cards stay below as supplemental detail. */}
<InsightAdvisorCard
title={t("insights.aiOverviewTitle")}
insight={advisor.payload?.insights ?? null}
loading={advisor.isLoading}
error={
advisor.regenerateError?.message ?? advisor.error?.message ?? null
}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
cachedAt={advisor.payload?.cachedAt ?? null}
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
<InsightsSectionNav />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
@@ -0,0 +1,101 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { InsightResult } from "@/lib/ai/types";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared TanStack Query
* helper that reads the rich advisor payload from `/api/insights/generate`.
*
* Why a POST under a `useQuery` rather than a dedicated GET endpoint:
* the route already cache-returns the most recent generation (24h TTL on
* `User.insightsCachedAt` / `User.insightsCachedText`) without burning a
* rate-limit token, so a single POST-without-`force` is functionally a
* GET-or-generate. Adding a separate GET would duplicate the cache-read
* branch and split the audit-log surface; the reconcile report deferred
* C1+C2 specifically because it assumed that duplication was required,
* but the route already supports the cache-aware path.
*
* Both `/insights` (`<InsightAdvisorCard>`) and `/` (`<InsightsCardPreview>`)
* mount the query under the same key, so a regenerate from either surface
* refreshes the other without a second LLM call.
*/
export interface InsightAdvisorPayload {
insights: InsightResult;
cached: boolean;
cachedAt?: string | null;
legacyPayload?: boolean;
}
async function fetchAdvisor(
options: { force?: boolean } = {},
): Promise<InsightAdvisorPayload | null> {
const res = await fetch("/api/insights/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options.force ? { force: true } : {}),
});
if (!res.ok) {
// 422 (no provider configured) and 429 (rate-limited) are expected
// surfaces — return null so the consuming UI shows the empty / error
// state without the query slipping into an `isError` retry loop.
if (res.status === 422 || res.status === 429 || res.status === 503) {
return null;
}
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
return json.data as InsightAdvisorPayload;
}
export interface UseInsightsAdvisorResult {
payload: InsightAdvisorPayload | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
regenerate: () => void;
isRegenerating: boolean;
regenerateError: Error | null;
}
/**
* Read-only consumer for the advisor payload. Use this on surfaces that
* just want to render the cached insight (e.g. dashboard preview).
*/
export function useInsightsAdvisorQuery(enabled: boolean): UseInsightsAdvisorResult {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.insightsAdvisor(),
queryFn: () => fetchAdvisor(),
enabled,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
});
const mutation = useMutation({
mutationFn: () => fetchAdvisor({ force: true }),
onSuccess: (next) => {
if (next) {
queryClient.setQueryData(queryKeys.insightsAdvisor(), next);
} else {
queryClient.invalidateQueries({ queryKey: queryKeys.insightsAdvisor() });
}
// Per-status caches are evicted server-side on regenerate; refresh
// their query subtree so the per-section text below the advisor card
// re-fetches.
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
},
});
return {
payload: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
error: (query.error as Error | null) ?? null,
regenerate: () => mutation.mutate(),
isRegenerating: mutation.isPending,
regenerateError: (mutation.error as Error | null) ?? null,
};
}
+8
View File
@@ -22,6 +22,14 @@ export const queryKeys = {
insightsRoot: () => ["insights"] as const,
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 `<InsightAdvisorCard>`) and `/` (`<InsightsCardPreview>`) read
* this key so a regenerate on one surface refreshes the other without
* a second LLM round-trip.
*/
insightsAdvisor: () => ["insights", "advisor"] as const,
insightsGeneralStatus: (locale: string) =>
["insights", "general-status", locale] as const,
insightsBpStatus: (locale: string) =>