mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route)
The polished InsightsCardPreview component shipped in B1b commit 5
(d2cdf9d) was an orphan with zero non-test imports. The dashboard read
no advisor payload, so the top severity-ordered AI recommendation +
ring confidence meter + "View all" CTA was unreachable from any live
route. Wave-D design review flagged this as CRITICAL C2.
Wires the preview into the root dashboard page above the chart row,
sharing the advisor cache key with /insights so a regenerate on either
surface refreshes the other without a second LLM call. The preview
self-hides when the user has no provider configured (route 422 → null)
or the advisor returns zero recommendations.
Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle
persists through Settings → Dashboard. Pins the widget into
DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users
discover the feature without hunting through settings, and adds a
contract test mirroring the A5 widget-enum-drift guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
aae968a6d5
commit
8a5b6de27e
7 files changed
+233
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase D reconcile (CRITICAL C2) — proves the polished
|
||||
* `<InsightsCardPreview>` is mounted on the root dashboard `/`.
|
||||
*
|
||||
* Before this fix the component shipped in B1b commit 5 (`d2cdf9d`)
|
||||
* was an orphan with zero non-test imports. The dashboard read no
|
||||
* advisor payload, so the top severity-ordered AI recommendation +
|
||||
* confidence ring + "View all" CTA was unreachable from any live
|
||||
* route. This spec asserts the card now renders when the layout
|
||||
* toggle is active and the advisor returns at least one rec.
|
||||
*/
|
||||
test.describe("insights card preview on dashboard (C2)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Populated analytics so the dashboard clears its empty-state and
|
||||
// renders charts + tile strip.
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
latest: 78.5,
|
||||
avg7: 78.2,
|
||||
avg30: 77.9,
|
||||
slope30: { slope: -0.05, direction: "down" },
|
||||
count: 30,
|
||||
},
|
||||
},
|
||||
bpInTargetPct: null,
|
||||
glucoseByContext: {},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Layout enables the insights preview widget so the gate is open.
|
||||
await page.route("**/api/dashboard/widgets", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{
|
||||
id: "insightsPreview",
|
||||
visible: true,
|
||||
tileVisible: false,
|
||||
order: 14,
|
||||
},
|
||||
],
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/measurements*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements: [], meta: { total: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Advisor payload — populated with one urgent rec carrying a
|
||||
// confidence score so the preview renders the rec text + the
|
||||
// ring confidence meter inline.
|
||||
await page.route("**/api/insights/generate", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
insights: {
|
||||
insightType: "general",
|
||||
summary: "Weekly summary.",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
primaryRecommendation: "",
|
||||
recommendations: [
|
||||
{
|
||||
id: "rec-urgent-1",
|
||||
text: "Schedule a follow-up reading this week.",
|
||||
severity: "urgent",
|
||||
confidence: 82,
|
||||
rationale: {
|
||||
dataWindow: "last7days",
|
||||
comparedTo: "your 90-day median",
|
||||
deviation: "Sys BP averaged 138 over the past week",
|
||||
},
|
||||
metricSource: {
|
||||
type: "bloodPressureSys",
|
||||
timeRange: "last7days",
|
||||
summary: "Sys BP avg 138",
|
||||
},
|
||||
},
|
||||
],
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Not medical advice.",
|
||||
},
|
||||
cached: true,
|
||||
cachedAt: new Date().toISOString(),
|
||||
legacyPayload: false,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard renders InsightsCardPreview with top recommendation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Preview is the C2 deliverable — `data-slot="insights-card-preview"`
|
||||
// is the stable hook the component exposes.
|
||||
await expect(
|
||||
page.locator('[data-slot="insights-card-preview"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The urgent rec text from the mock payload renders inside the
|
||||
// preview's compact tile.
|
||||
await expect(
|
||||
page.getByText("Schedule a follow-up reading this week."),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// "View all" CTA points at /insights so the user can drill in.
|
||||
await expect(
|
||||
page.locator('[data-slot="insights-card-view-all"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The ring-variant ConfidenceMeter renders inline next to the rec
|
||||
// text — its slot is the same one used on /insights, so a single
|
||||
// selector confirms wiring.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="insights-card-preview"] [data-slot="confidence-meter"]',
|
||||
),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -229,6 +229,7 @@
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
"insightsPreview": "KI-Insights-Vorschau",
|
||||
"lastMeasurement": "Letzter Messwert",
|
||||
"noMeasurements": "Noch keine Messwerte",
|
||||
"trend": "Trend",
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
"insightsPreview": "AI Insights preview",
|
||||
"lastMeasurement": "Last measurement",
|
||||
"noMeasurements": "No measurements yet",
|
||||
"trend": "Trend",
|
||||
|
||||
@@ -46,6 +46,8 @@ import { GettingStartedChecklist } from "@/components/onboarding/getting-started
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
||||
import { InsightsCardPreview } from "@/components/insights/insights-card";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
@@ -208,6 +210,17 @@ 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);
|
||||
|
||||
const w = data?.summaries?.WEIGHT;
|
||||
const sys = data?.summaries?.BLOOD_PRESSURE_SYS;
|
||||
const dia = data?.summaries?.BLOOD_PRESSURE_DIA;
|
||||
@@ -297,6 +310,12 @@ export default function DashboardPage() {
|
||||
// the layout-toggle gate here. No data-floor check (the empty card is
|
||||
// intentional — Marc 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
|
||||
@@ -1094,6 +1113,19 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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
|
||||
(`<InsightsCardPreview>` returns null), so the preview
|
||||
doesn't paint an empty card on first visit. */}
|
||||
{showInsightsPreview && (
|
||||
<InsightsCardPreview
|
||||
insight={advisor.payload?.insights ?? null}
|
||||
/>
|
||||
)}
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
|
||||
@@ -43,6 +43,10 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
oxygenSaturation: "measurements.typeOxygenSaturation",
|
||||
achievements: "achievements.title",
|
||||
// v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview tile
|
||||
// for the polished `<InsightsCardPreview>` (top severity-ordered AI
|
||||
// recommendations + ring confidence meter + "View all" CTA).
|
||||
insightsPreview: "dashboard.insightsPreview",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -141,6 +141,15 @@ 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.
|
||||
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
|
||||
expect(ids).toContain("insightsPreview");
|
||||
});
|
||||
|
||||
it("survives a round-trip through serialize → resolve unchanged", () => {
|
||||
// The API persists `serializeDashboardLayout(parsed.data)` and
|
||||
// reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`.
|
||||
|
||||
@@ -29,6 +29,11 @@ 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",
|
||||
] as const;
|
||||
|
||||
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
|
||||
@@ -124,6 +129,17 @@ 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 (`<InsightsCardPreview>` 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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user