mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(insights): wire <HeroStrip> + <DailyBriefing> into /insights
Replaces `<InsightsPageHero>` with the new `<HeroStrip>` band and mounts a full-width `<DailyBriefing>` directly below it, ahead of the existing advisor + status cards which remain untouched. `useInsightsAdvisorQuery` now lifts the optional `dailyBriefing` block off the cached payload and validates it client-side via `dailyBriefingSchema.safeParse`. A malformed cached briefing falls back to null so the briefing card paints its empty-state CTA instead of a half-rendered card. Legacy v1.4.19 cached payloads (no `dailyBriefing` field) round-trip unchanged. The CompareToggle migrates from the hero meta band into the DailyBriefing's metaSlot — keeps the page-level comparison control adjacent to the briefing it scopes. The hero greeting reads the user's username (HealthLog has no separate display-name field) and stops at the first whitespace so "alice" / "alice.smith" both surface "Good morning, alice". `insights-polish.test.ts` v1.4.19 A3 ratchet updated: the original "exactly one onRegenerate prop on the page" assertion was a string-count check that mis-fires now that the briefing card's empty-state CTA legitimately wires the same handler. The load-bearing constraint stays — the advisor card must NOT carry onRegenerate — and is enforced via a tighter JSX-block check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
59e63a0641
commit
7d29596998
3 files changed
+81
-9
No files matched your search
@@ -65,13 +65,31 @@ describe("v1.4.19 A3 — dashboard polish", () => {
|
||||
});
|
||||
|
||||
describe("v1.4.19 A3 — /insights polish", () => {
|
||||
it("renders exactly ONE on-surface refresh affordance at the page level", () => {
|
||||
it("does NOT mount the legacy advisor-card regenerate handler", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The advisor card no longer carries its own onRegenerate handler
|
||||
// that duplicated the hero one. v1.4.20 phase B1 added a second
|
||||
// wiring (the <DailyBriefing> empty-state CTA), which only renders
|
||||
// while the briefing payload is null — its purpose is "generate
|
||||
// today's briefing" rather than "refresh", and it disappears the
|
||||
// moment a briefing exists. The original v1.4.19 ratchet was the
|
||||
// advisor-card duplication; that one stays banned.
|
||||
//
|
||||
// We extract the InsightAdvisorCard JSX block (everything up to
|
||||
// the closing `/>`) and assert no `onRegenerate` prop appears
|
||||
// inside it.
|
||||
const advisorBlock = src.match(/<InsightAdvisorCard\b[^>]*\/>/);
|
||||
expect(advisorBlock).not.toBeNull();
|
||||
expect(advisorBlock?.[0]).not.toContain("onRegenerate");
|
||||
});
|
||||
|
||||
it("hero strip is the always-visible page-level refresh affordance", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The hero owns the page-level regenerate button (mounted via
|
||||
// `onRegenerate={advisor.regenerate}`). The advisor card no
|
||||
// longer carries its own duplicate handler.
|
||||
const regenerateProps = src.match(/onRegenerate=\{advisor\.regenerate\}/g);
|
||||
expect(regenerateProps?.length ?? 0).toBe(1);
|
||||
// `onRegenerate={advisor.regenerate}` on `<HeroStrip>`).
|
||||
expect(src).toMatch(
|
||||
/<HeroStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
} 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 { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
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
|
||||
@@ -840,10 +841,32 @@ export default function InsightsPage() {
|
||||
medicationComplianceStatus?.updatedAt,
|
||||
]);
|
||||
|
||||
// v1.4.20 phase B1 — derive the user's display name for the hero
|
||||
// greeting. We use the username (HealthLog has no separate display-
|
||||
// name field) and stop at the first whitespace so a "first.last"
|
||||
// handle reads cleanly. Falls back to no-name greeting when the user
|
||||
// has no username so the hero never paints "Good morning, undefined".
|
||||
const heroGreetingName =
|
||||
user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? heroUpdatedAt;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InsightsPageHero
|
||||
updatedAt={advisor.payload?.cachedAt ?? heroUpdatedAt}
|
||||
<HeroStrip
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
metaSlot={<CompareToggle />}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import {
|
||||
dailyBriefingSchema,
|
||||
type DailyBriefing as DailyBriefingPayload,
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
@@ -26,6 +30,15 @@ export interface InsightAdvisorPayload {
|
||||
cached: boolean;
|
||||
cachedAt?: string | null;
|
||||
legacyPayload?: boolean;
|
||||
/**
|
||||
* v1.4.20 phase B1 — Daily Briefing block surfaced for the new hero
|
||||
* strip + briefing card. Lives on the cached payload alongside the
|
||||
* legacy `insights` shape (see `aiInsightResponseSchema` — the
|
||||
* `.passthrough()` lets the field round-trip through any provider).
|
||||
* Validated client-side via the schema's `safeParse` to keep a
|
||||
* malformed payload from poisoning the briefing card.
|
||||
*/
|
||||
dailyBriefing?: DailyBriefingPayload | null;
|
||||
}
|
||||
|
||||
async function fetchAdvisor(
|
||||
@@ -46,7 +59,25 @@ async function fetchAdvisor(
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data as InsightAdvisorPayload;
|
||||
const payload = json.data as InsightAdvisorPayload;
|
||||
// The cached `insights` blob may carry a `dailyBriefing` from a fresh
|
||||
// PROMPT_VERSION 4.20.x generation. Lift it onto the payload so
|
||||
// consumers don't have to know the legacy shape.
|
||||
const briefingCandidate = (payload?.insights as Record<string, unknown>)
|
||||
?.dailyBriefing;
|
||||
if (briefingCandidate != null) {
|
||||
const parsed = dailyBriefingSchema.safeParse(briefingCandidate);
|
||||
if (parsed.success) {
|
||||
payload.dailyBriefing = parsed.data;
|
||||
} else {
|
||||
// Malformed cached briefing — keep null so the UI shows the
|
||||
// empty-state CTA instead of a half-rendered card.
|
||||
payload.dailyBriefing = null;
|
||||
}
|
||||
} else {
|
||||
payload.dailyBriefing = null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface UseInsightsAdvisorResult {
|
||||
|
||||
Reference in new issue
Block a user