diff --git a/src/app/globals.css b/src/app/globals.css index a165ad869..94f6e38f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -243,3 +243,62 @@ animation: none; } } + +/* ── v1.4.20 phase B1 — Insights redesign hero strip ─────────────── */ + +/* + * `.hero-gradient` — purple → pink linear gradient at low opacity, for + * the new band on /insights. Sits above `var(--card)` so + * the band reads as a richer surface than the card it overlays. + * + * Matches the design handoff (`prototype/artboard-fullpage.jsx → + * BriefingHero` background) — purple/pink at ~12% opacity over the + * card colour, with a subtle border-tint to anchor it on the dark bg. + */ +.hero-gradient { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--dracula-purple) 18%, var(--card)) 0%, + color-mix(in srgb, var(--dracula-pink) 12%, var(--card)) 100% + ); + border: 1px solid color-mix(in srgb, var(--dracula-purple) 24%, transparent); +} + +/* + * `.glow-purple` — soft purple aura behind the hero card, painted via + * `box-shadow` so it stays GPU-cheap and respects the existing card + * border radius without an extra wrapper element. + */ +.glow-purple { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--dracula-purple) 16%, transparent), + 0 18px 48px -28px color-mix(in srgb, var(--dracula-purple) 50%, transparent); +} + +/* + * `pulseDot` keyframes — the "live, refreshed N min ago" green dot in + * the hero meta row. Used as `.pulse-dot { animation: pulseDot ... }`. + * Reduced-motion users get a static dot via the prefers-reduced-motion + * guard below. + */ +@keyframes pulseDot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.85); + } +} + +.pulse-dot { + animation: pulseDot 1.8s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .pulse-dot { + animation: none; + } +} diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx new file mode 100644 index 000000000..cb356c29c --- /dev/null +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nProvider } from "@/lib/i18n/context"; +import { HeroStrip } from "../hero-strip"; +import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; + +/** + * v1.4.20 phase B1 — Insights hero strip. + * + * Replaces v1.4.16 ``. Pinning the slots so future + * polish can't silently drop the greeting / subtitle / action row / + * suggested-prompt strip. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const sampleBriefing: DailyBriefingPayload = { + paragraph: + "You're trending well this week. BP is in target band on 9 of 10 readings.", + keyFindings: [], +}; + +// `getHours()` reads from the local representation. Force a known +// local hour by constructing from a local-time fields tuple. +const morningLocal = new Date(2026, 4, 10, 9, 0, 0); // May 10, 09:00 local +const afternoonLocal = new Date(2026, 4, 10, 14, 0, 0); // May 10, 14:00 local +const eveningLocal = new Date(2026, 4, 10, 20, 0, 0); // May 10, 20:00 local + +describe("", () => { + it("renders the slot wrapper + Dracula gradient utility", () => { + const html = render(); + expect(html).toMatch(/data-slot="insights-hero-strip"/); + expect(html).toContain("hero-gradient"); + expect(html).toContain("glow-purple"); + }); + + it("renders the morning greeting in English", () => { + const html = render(); + expect(html).toMatch(/data-slot="insights-hero-strip-greeting"/); + expect(html).toContain("Good morning"); + }); + + it("renders the morning greeting in German", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Guten Morgen"); + }); + + it("renders the afternoon greeting in English at 14:00 local", () => { + const html = render(); + expect(html).toContain("Good afternoon"); + }); + + it("renders the evening greeting in English at 20:00 local", () => { + const html = render(); + expect(html).toContain("Good evening"); + }); + + it("appends the user name to the greeting when supplied", () => { + const html = render( + , + ); + expect(html).toContain("Good morning, Marc"); + }); + + it("does NOT append a comma when userName is missing", () => { + const html = render(); + // Greeting renders without trailing comma + name. + expect(html).not.toMatch(/Good morning,\s* { + const html = render( + , + ); + expect(html).toMatch(/data-slot="insights-hero-strip-subtitle"/); + expect(html).toContain( + "You're trending well this week. BP is in target band on 9 of 10 readings.", + ); + }); + + it("falls back to the heroFallbackSubtitle when briefing is null", () => { + const html = render(); + expect(html).toContain( + "A daily read of your trends, drawn straight from the numbers you've logged.", + ); + }); + + it("renders the personal-baseline meta line", () => { + const html = render(); + expect(html).toMatch(/data-slot="insights-hero-strip-baseline"/); + expect(html).toContain("Based on your last 90 days"); + }); + + it("renders the generated-time caption when updatedAt is supplied", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + const html = render( + , + ); + expect(html).toMatch(/data-slot="insights-hero-strip-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-hero-strip-generated"'); + }); + + // ── Action row ───────────────────────────────────────────────────── + it("renders the weekly-report action button as disabled with a 'Coming soon' title", () => { + const html = render(); + expect(html).toMatch( + /data-slot="insights-hero-strip-action-weekly-report"[^>]*disabled[^>]*title="Coming soon"/, + ); + }); + + it("renders the ask-the-coach action button as disabled with a 'Coming soon' title", () => { + const html = render(); + expect(html).toMatch( + /data-slot="insights-hero-strip-action-coach"[^>]*disabled[^>]*title="Coming soon"/, + ); + }); + + it("renders the regenerate button when onRegenerate is supplied", () => { + const html = render( + {}} />, + ); + expect(html).toMatch(/data-slot="insights-hero-strip-action-rerun"/); + expect(html).toContain("Re-run analysis"); + }); + + it("hides the regenerate button when no handler is supplied", () => { + const html = render(); + expect(html).not.toContain('data-slot="insights-hero-strip-action-rerun"'); + }); + + it("disables the regenerate button while regenerating", () => { + const html = render( + {}} + regenerating + />, + ); + expect(html).toMatch( + /data-slot="insights-hero-strip-action-rerun"[^>]*disabled/, + ); + expect(html).toContain("Regenerating"); + }); + + // ── Suggested prompts strip ──────────────────────────────────────── + it("mounts the SuggestedPrompts strip below the action band", () => { + const html = render(); + expect(html).toMatch(/data-slot="insights-hero-strip-prompts"/); + expect(html).toMatch(/data-slot="insights-suggested-prompts"/); + expect(html).toContain("Try asking"); + expect(html).toContain("Why was BP higher on Monday?"); + }); + + it("forwards onPickPrompt down to the prompt strip without throwing", () => { + const html = render( + {}} + />, + ); + // The chips render verbatim; SSR doesn't run click handlers. + expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/); + }); + + // ── Misc ─────────────────────────────────────────────────────────── + it("supports the German locale fallback subtitle", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Täglicher Blick auf deine Trends"); + }); + + it("uses the now override deterministically (smoke for greeting-bucket logic)", () => { + // Five different hours map to four different greeting buckets: + // 09:00 → morning, 14:00 → afternoon, 20:00 → evening, 02:00 → night. + const earlyMorning = new Date(2026, 4, 10, 2, 0, 0); + const html = render(); + // Night maps to "Good evening" in EN per the resolver mapping. + expect(html).toContain("Good evening"); + // Sanity: midnight-bucket sample is NOT "Good morning". + expect(html).not.toContain("Good morning"); + }); + + it("uses the morning bucket at noon-1 (11:59 boundary)", () => { + const justBeforeNoon = new Date(2026, 4, 10, 11, 59, 0); + const html = render(); + expect(html).toContain("Good morning"); + }); +}); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx new file mode 100644 index 000000000..4a9a3f129 --- /dev/null +++ b/src/components/insights/hero-strip.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { + FileText, + Loader2, + RefreshCw, + Sparkles, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { SuggestedPrompts } from "./suggested-prompts"; +import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; + +/** + * v1.4.20 phase B1 — Insights redesign hero strip. + * + * Replaces the v1.4.16 `` with the wider band from + * the design handoff (`prototype/artboard-fullpage.jsx → BriefingHero`): + * + * - locale-aware time-of-day greeting line ("Good morning, …" / + * "Guten Morgen, …") + a narrative subtitle (the briefing + * paragraph when one is cached, else a fallback) + * - personal-baseline + "Generated " meta row + * - 3-button action row: "Generate weekly report" (disabled, B4), + * "Ask the coach" (disabled, B2), "Re-run analysis" (wired to + * the existing regenerate handler from v1.4.16 D-reconcile) + * - chip strip below the action band + * - Dracula gradient + soft purple glow via the new `.hero-gradient` + * + `.glow-purple` utilities in `globals.css` + * + * Mobile-first: the action row wraps + the suggested-prompt chips + * wrap. Right-side Health Score panel is *not* part of B1 — that + * lands in B5; for B1 the right side just keeps its meta band. + * + * Pure presentational — the page owns the briefing data + the + * regenerate handler. + */ + +interface HeroStripProps { + /** Briefing payload — its paragraph drives the hero subtitle. */ + briefing: DailyBriefingPayload | null; + /** ISO timestamp of the freshest cached payload. */ + updatedAt?: string | null; + /** + * Display name for the greeting; defaults to no-name greeting + * ("Good morning,") when omitted so the hero never paints "Good + * morning, undefined". + */ + userName?: string | null; + /** Click handler for the regenerate button — wired to the advisor query. */ + onRegenerate?: () => void; + /** Disables the regenerate button + flips its icon to a spinner. */ + regenerating?: boolean; + /** + * Click handler for the suggested prompts. The Coach drawer (B2) + * will own the routing; for B1 the parent passes a no-op. + */ + onPickPrompt?: (prompt: string) => void; + /** + * Now() override for tests so the greeting bucket is deterministic. + * Defaults to `new Date()`. Production callers omit this. + */ + now?: Date; +} + +/** + * Bucketed time-of-day greeting: + * 05:00–11:59 → morning + * 12:00–17:59 → afternoon + * 18:00–22:59 → evening + * 23:00–04:59 → night (mapped to "evening" for both locales — it's + * rare to see /insights at 03:00 and "Guten + * Morgen" pre-5am reads odd) + */ +function resolveGreetingKey(now: Date): string { + const hour = now.getHours(); + if (hour >= 5 && hour < 12) return "insights.heroGreetingMorning"; + if (hour >= 12 && hour < 18) return "insights.heroGreetingAfternoon"; + if (hour >= 18 && hour < 23) return "insights.heroGreetingEvening"; + return "insights.heroGreetingNight"; +} + +/** + * Bucketed relative-time using i18n translation keys so every surface + * shares vocabulary. Mirrors the helper in ``. + */ +function formatRelativeTime( + iso: string, + t: (key: string, params?: Record) => 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 HeroStrip({ + briefing, + updatedAt, + userName, + onRegenerate, + regenerating = false, + onPickPrompt, + now, +}: HeroStripProps) { + const { t } = useTranslations(); + const greetingKey = resolveGreetingKey(now ?? new Date()); + const greetingBase = t(greetingKey); + const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase; + const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle"); + const generatedLine = updatedAt + ? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) }) + : null; + const comingSoon = t("insights.heroComingSoonTooltip"); + + return ( +
+
+
+
+
+

+ {subtitle} +

+
+ + {t("insights.heroPersonalBaseline")} + + {generatedLine && ( + <> + + + {generatedLine} + + + )} +
+
+ +
+ {/* + * v1.4.20 phase B4 ships the weekly-report route. Disabled + * here so the affordance lives in the hero from B1 onwards + * without dead links — title= surfaces the "Coming soon" + * caption on hover/focus without dragging in a Radix + * tooltip provider for a single static label. + */} + + {/* B2 wires this into the Coach drawer. */} + + {onRegenerate && ( + + )} +
+ +
+ {/* + * TODO(B2): wire onPickPrompt into the Coach drawer so a + * chip click pre-fills the input + opens the drawer. For + * B1 the parent passes a no-op. + */} + undefined)} + /> +
+
+
+ ); +} diff --git a/src/components/insights/insights-page-hero.tsx b/src/components/insights/insights-page-hero.tsx index bcb104050..e4179cd23 100644 --- a/src/components/insights/insights-page-hero.tsx +++ b/src/components/insights/insights-page-hero.tsx @@ -7,6 +7,10 @@ import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.16 phase B1b — Apple-Health-style page hero for `/insights`. * + * @deprecated v1.4.20 phase B1 — superseded by `` in + * `./hero-strip.tsx`. Kept here for the dashboard preview's continued + * use; will be removed once that surface migrates. + * * 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