mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
c8e76399ba
commit
4d7d0748fa
5 files changed
+296
-6
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user