diff --git a/messages/de.json b/messages/de.json index 1f007d772..29d220641 100644 --- a/messages/de.json +++ b/messages/de.json @@ -701,6 +701,14 @@ "title": "Insights", "subtitle": "KI-basierte Analyse deiner Gesundheitsdaten", "overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick", + "heroGenerated": "Erstellt {time}", + "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", + "heroRegenerate": "Neu generieren", + "heroRegenerating": "Wird generiert", + "relativeJustNow": "gerade eben", + "relativeMinutesAgo": "vor {count} Minuten", + "relativeHoursAgo": "vor {count} Stunden", + "relativeDaysAgo": "vor {count} Tagen", "navGeneral": "Allgemein", "navBloodPressure": "Blutdruck", "navWeight": "Gewicht", diff --git a/messages/en.json b/messages/en.json index b801122ea..5778d7535 100644 --- a/messages/en.json +++ b/messages/en.json @@ -701,6 +701,14 @@ "title": "Insights", "subtitle": "AI-powered analysis of your health data", "overviewSubtitle": "Trends, risks, progress at a glance", + "heroGenerated": "Generated {time}", + "heroPersonalBaseline": "Based on your last 90 days", + "heroRegenerate": "Regenerate", + "heroRegenerating": "Regenerating", + "relativeJustNow": "just now", + "relativeMinutesAgo": "{count} minutes ago", + "relativeHoursAgo": "{count} hours ago", + "relativeDaysAgo": "{count} days ago", "navGeneral": "General", "navBloodPressure": "Blood Pressure", "navWeight": "Weight", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8aa2833f8..7c102b258 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -42,6 +42,7 @@ import { TrendingUp, } from "lucide-react"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { InsightsPageHero } from "@/components/insights/insights-page-hero"; // 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. // Every scatter card sits inside a `length >= 5` gate and below the fold, @@ -270,6 +271,29 @@ function getRangeHint( ); } +/** + * v1.4.16 phase B1b — pick the freshest ISO timestamp from a list so the + * page hero's "Generated …" caption surfaces a single representative + * value across the per-section caches. Returns null when nothing's + * been generated yet so the caption hides cleanly. + */ +function freshestUpdatedAt( + candidates: Array, +): string | null { + let freshest: number | null = null; + let freshestIso: string | null = null; + for (const iso of candidates) { + if (!iso) continue; + const ms = new Date(iso).getTime(); + if (Number.isNaN(ms)) continue; + if (freshest === null || ms > freshest) { + freshest = ms; + freshestIso = iso; + } + } + return freshestIso; +} + type HealthBandState = "green" | "orange" | "red"; function classifyRangeValue( @@ -831,14 +855,23 @@ export default function InsightsPage() { ); } + // Wire the hero's "Generated …" caption from the freshest of the per- + // section caches so the user gets a single timestamp without having to + // expand every card. Falls back to null when no section has shipped a + // payload yet (the hero just hides the caption). + const heroUpdatedAt = freshestUpdatedAt([ + generalStatus?.updatedAt, + bloodPressureStatus?.updatedAt, + weightStatus?.updatedAt, + pulseStatus?.updatedAt, + bmiStatus?.updatedAt, + moodStatus?.updatedAt, + medicationComplianceStatus?.updatedAt, + ]); + return (
-
-

{t("insights.title")}

-

- {t("insights.overviewSubtitle")} -

-
+ diff --git a/src/components/insights/__tests__/insights-page-hero.test.tsx b/src/components/insights/__tests__/insights-page-hero.test.tsx new file mode 100644 index 000000000..55b31afba --- /dev/null +++ b/src/components/insights/__tests__/insights-page-hero.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { InsightsPageHero } from "../insights-page-hero"; + +/** + * v1.4.16 phase B1b — Apple-Health-style page hero. + * + * The /insights page entry-point gets a gradient hero shell: + * - h1 + subtitle (kept from the old layout, just polished) + * - "Generated " caption when an updatedAt is supplied + * - regenerate button when an onRegenerate handler is supplied + * - "Based on your last 90 days" personal-baseline indicator + * - smooth fade-in (CSS animation, gated by prefers-reduced-motion) + * + * The hero is a pure presentational shell; the page passes the values + * in. This test pins the slot positions so a future polish pass can't + * silently drop one (e.g. dropping the personal-baseline caption). + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the heading + overview subtitle", () => { + const html = render(); + expect(html).toContain("Insights"); + expect(html).toContain("Trends, risks, progress at a glance"); + }); + + it("paints a gradient background using Dracula tokens", () => { + const html = render(); + // The hero wrapper should carry a gradient + Dracula tokens so the + // page header reads as the Apple-Health-style band. + expect(html).toMatch(/data-slot="insights-page-hero"/); + expect(html).toMatch(/bg-gradient-to/); + }); + + it("renders the personal-baseline caption", () => { + const html = render(); + expect(html).toContain("Based on your last 90 days"); + }); + + it("renders the German personal-baseline caption", () => { + const html = render(, "de"); + expect(html).toContain("Basierend auf deinen letzten 90 Tagen"); + }); + + it("renders the generated-time caption when updatedAt is supplied", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + const html = render(); + // Either "just now" / "5 minutes ago" / "vor 5 Minuten" — we just + // assert the "Generated" label preceeds the relative-time string so + // a future Intl.RelativeTimeFormat tweak can't dodge the slot. + expect(html).toMatch(/data-slot="insights-page-hero-generated"/); + expect(html).toContain("Generated"); + }); + + it("does NOT render the generated caption when updatedAt is missing", () => { + const html = render(); + expect(html).not.toContain('data-slot="insights-page-hero-generated"'); + }); + + it("renders a regenerate button when onRegenerate is supplied", () => { + // The button is a real +
+ )} + + + ); +}