mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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", () => {
|
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);
|
const src = load(INSIGHTS_PATH);
|
||||||
// The hero owns the page-level regenerate button (mounted via
|
// The hero owns the page-level regenerate button (mounted via
|
||||||
// `onRegenerate={advisor.regenerate}`). The advisor card no
|
// `onRegenerate={advisor.regenerate}` on `<HeroStrip>`).
|
||||||
// longer carries its own duplicate handler.
|
expect(src).toMatch(
|
||||||
const regenerateProps = src.match(/onRegenerate=\{advisor\.regenerate\}/g);
|
/<HeroStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}/,
|
||||||
expect(regenerateProps?.length ?? 0).toBe(1);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
|
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||||
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-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 { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||||
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
import { CompareToggle } from "@/components/comparison/compare-toggle";
|
||||||
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
|
||||||
@@ -840,10 +841,32 @@ export default function InsightsPage() {
|
|||||||
medicationComplianceStatus?.updatedAt,
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<InsightsPageHero
|
<HeroStrip
|
||||||
updatedAt={advisor.payload?.cachedAt ?? heroUpdatedAt}
|
briefing={briefingPayload}
|
||||||
|
updatedAt={heroStripUpdatedAt}
|
||||||
|
userName={heroGreetingName}
|
||||||
|
onRegenerate={advisor.regenerate}
|
||||||
|
regenerating={advisor.isRegenerating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DailyBriefing
|
||||||
|
briefing={briefingPayload}
|
||||||
|
updatedAt={heroStripUpdatedAt}
|
||||||
|
loading={advisor.isLoading}
|
||||||
onRegenerate={advisor.regenerate}
|
onRegenerate={advisor.regenerate}
|
||||||
regenerating={advisor.isRegenerating}
|
regenerating={advisor.isRegenerating}
|
||||||
metaSlot={<CompareToggle />}
|
metaSlot={<CompareToggle />}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { InsightResult } from "@/lib/ai/types";
|
import type { InsightResult } from "@/lib/ai/types";
|
||||||
|
import {
|
||||||
|
dailyBriefingSchema,
|
||||||
|
type DailyBriefing as DailyBriefingPayload,
|
||||||
|
} from "@/lib/ai/schema";
|
||||||
import { queryKeys } from "@/lib/query-keys";
|
import { queryKeys } from "@/lib/query-keys";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +30,15 @@ export interface InsightAdvisorPayload {
|
|||||||
cached: boolean;
|
cached: boolean;
|
||||||
cachedAt?: string | null;
|
cachedAt?: string | null;
|
||||||
legacyPayload?: boolean;
|
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(
|
async function fetchAdvisor(
|
||||||
@@ -46,7 +59,25 @@ async function fetchAdvisor(
|
|||||||
throw new Error(`HTTP ${res.status}`);
|
throw new Error(`HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
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 {
|
export interface UseInsightsAdvisorResult {
|
||||||
|
|||||||
Reference in new issue
Block a user