feat(insights): page hero with gradient header + generation timestamp + personal-baseline indicator

Wraps the /insights heading in a Dracula-gradient hero band with the
"Generated <relative-time>" caption, "Based on your last 90 days"
personal-baseline indicator, and a regenerate slot. Pure shell so the
page only passes timestamps in. Animation uses the existing
animate-insight-in keyframes which already gates on
prefers-reduced-motion.

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 01:54:35 +02:00
1 parent c8e76399ba
commit 4d7d0748fa
5 files changed
+296 -6

No files matched your search

+8
View File
@@ -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",
+8
View File
@@ -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",
+39 -6
View File
@@ -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 | undefined>,
): 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 (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">{t("insights.title")}</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{t("insights.overviewSubtitle")}
</p>
</div>
<InsightsPageHero updatedAt={heroUpdatedAt} />
<InsightsSectionNav />
@@ -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 <relative-time>" 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<InsightsPageHero>", () => {
it("renders the heading + overview subtitle", () => {
const html = render(<InsightsPageHero />);
expect(html).toContain("Insights");
expect(html).toContain("Trends, risks, progress at a glance");
});
it("paints a gradient background using Dracula tokens", () => {
const html = render(<InsightsPageHero />);
// 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(<InsightsPageHero />);
expect(html).toContain("Based on your last 90 days");
});
it("renders the German personal-baseline caption", () => {
const html = render(<InsightsPageHero />, "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(<InsightsPageHero updatedAt={fiveMinutesAgo} />);
// 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(<InsightsPageHero />);
expect(html).not.toContain('data-slot="insights-page-hero-generated"');
});
it("renders a regenerate button when onRegenerate is supplied", () => {
// The button is a real <button>, so SSR includes the markup verbatim.
const html = render(<InsightsPageHero onRegenerate={() => {}} />);
expect(html).toMatch(/data-slot="insights-page-hero-regenerate"/);
});
it("does NOT render the regenerate button when onRegenerate is missing", () => {
const html = render(<InsightsPageHero />);
expect(html).not.toContain('data-slot="insights-page-hero-regenerate"');
});
it("disables the regenerate button while regenerating", () => {
const html = render(
<InsightsPageHero onRegenerate={() => {}} regenerating />,
);
expect(html).toMatch(/data-slot="insights-page-hero-regenerate"[^>]*disabled/);
});
it("German locale — heading + subtitle translate", () => {
const html = render(<InsightsPageHero />, "de");
// Reuses existing insights.title + insights.overviewSubtitle keys.
// The "Insights" string is identical in DE so we assert the subtitle.
expect(html).toContain("Trends, Risiken, Fortschritt auf einen Blick");
});
});
@@ -0,0 +1,149 @@
"use client";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.16 phase B1b — Apple-Health-style page hero for `/insights`.
*
* Polished header band that frames the page below. Pure presentational —
* the page passes timestamps + the regenerate handler in. Stays a thin
* shell around the existing translation keys so a future redesign only
* touches this file.
*
* Visual language:
* - subtle Dracula gradient background (purple → cyan, low opacity so
* dark-mode contrast is preserved against `var(--background)`)
* - sparkles glyph + heading + overview subtitle on the left
* - meta row underneath with personal-baseline indicator + the
* "Generated <relative-time>" caption when an updatedAt is supplied
* - regenerate button on the right (top-aligned on desktop, stacked
* under the meta row on mobile)
* - smooth fade-in via the existing `animate-insight-in` keyframes;
* `prefers-reduced-motion: reduce` already disables that animation
* globally (see globals.css §Insight card animations).
*
* Layout slots:
* - `data-slot="insights-page-hero"` — outer wrapper
* - `data-slot="insights-page-hero-generated"` — "Generated <time>"
* - `data-slot="insights-page-hero-baseline"` — "Based on …"
* - `data-slot="insights-page-hero-regenerate"` — regenerate button
*/
interface InsightsPageHeroProps {
/** ISO-8601 timestamp of the last AI generation; controls the "Generated …" caption. */
updatedAt?: string | null;
/** Click handler for the regenerate button; omit to hide the control. */
onRegenerate?: () => void;
/** Disables the regenerate button + flips the icon to a spinner. */
regenerating?: boolean;
}
/**
* Format a relative time using the i18n translation keys. Avoids the
* `Intl.RelativeTimeFormat` polyfill cost on the client AND keeps every
* surface that renders relative times locked to the project's
* established translation vocabulary.
*
* Buckets: <1min → just now; <60min → N min ago; <24h → N h ago;
* else N days ago.
*/
function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = Date.now() - target;
if (diffMs < 60_000) return t("insights.relativeJustNow");
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
const days = Math.floor(hours / 24);
return t("insights.relativeDaysAgo", { count: days });
}
export function InsightsPageHero({
updatedAt,
onRegenerate,
regenerating = false,
}: InsightsPageHeroProps) {
const { t } = useTranslations();
const generatedLine = updatedAt
? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) })
: null;
return (
<div
data-slot="insights-page-hero"
className="border-dracula-purple/20 from-dracula-purple/10 via-dracula-cyan/5 animate-insight-in relative overflow-hidden rounded-xl border bg-gradient-to-br to-transparent px-4 py-5 sm:px-6 sm:py-6"
>
{/* Decorative sparkle glow — purely visual, kept inert for screen readers. */}
<div
aria-hidden="true"
className="bg-dracula-purple/10 pointer-events-none absolute -top-12 -right-12 h-32 w-32 rounded-full blur-3xl"
/>
<div
aria-hidden="true"
className="bg-dracula-cyan/10 pointer-events-none absolute -bottom-16 -left-10 h-36 w-36 rounded-full blur-3xl"
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1 className="text-2xl font-bold tracking-tight">
{t("insights.title")}
</h1>
</div>
<p className="text-muted-foreground text-sm leading-snug">
{t("insights.overviewSubtitle")}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 pt-1 text-xs">
<span data-slot="insights-page-hero-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-page-hero-generated">
{generatedLine}
</span>
</>
)}
</div>
</div>
{onRegenerate && (
<div className="flex shrink-0 items-start">
<Button
type="button"
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-page-hero-regenerate"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroRegenerate")}
</span>
</Button>
</div>
)}
</div>
</div>
);
}