From 72e64c816dd95a51714c2a981a81d0040d6df96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 11:56:59 +0200 Subject: [PATCH] feat(insights): extract tab strip + relocate regenerate button to top-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc directive 2026-05-14: the page-level regenerate affordance has to stay reachable while the user is reading the per-metric cards. Up to v1.4.24 it lived inside the hero band's action row, which scrolled away as soon as the user moved past the briefing. What changed: - New `src/components/insights/insights-tab-strip.tsx` carries the six metric pills + the regenerate icon button. The strip is `sticky top-0` with `bg-background/95 backdrop-blur` so it lives above every section as the user scrolls. - Right slot: a 44×44 icon-only `` button with `aria-label={t("insights.regenerateAnalysis")}` and a sonner success toast keyed to the falling edge of `regenerating` (rising-edge guard via a ref so the toast only fires when an in-flight regen finishes, not on every render). - The hero strip's `onRegenerate` / `regenerating` props are gone; its action row is now Weekly-report + Ask-the-Coach only. The JSDoc comment block points to the new tab strip for the regenerate affordance. - `src/app/insights/page.tsx` mounts `` and drops the inline `InsightsSectionNav` function (its logic moved into the new component verbatim). - Two new translation keys added under the `insights.*` namespace: `regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis") drives the aria-label + title; `regenerateSuccess` ("Analyse wurde neu erstellt" / "Analysis refreshed") drives the toast. The existing hero-strip test that pinned the regenerate slot was flipped — the slot must NOT render under any prop combination. The insights-polish test that asserted `` was rewritten to assert the wiring is on `` and that the `` JSX block carries no `onRegenerate` prop. --- messages/de.json | 2 + messages/en.json | 2 + src/app/__tests__/insights-polish.test.ts | 15 +- src/app/insights/page.tsx | 143 ++---------- .../insights/__tests__/hero-strip.test.tsx | 28 +-- src/components/insights/hero-strip.tsx | 55 ++--- .../insights/insights-tab-strip.tsx | 209 ++++++++++++++++++ 7 files changed, 259 insertions(+), 195 deletions(-) create mode 100644 src/components/insights/insights-tab-strip.tsx diff --git a/messages/de.json b/messages/de.json index e7ae51b8b..3bffcae98 100644 --- a/messages/de.json +++ b/messages/de.json @@ -761,6 +761,8 @@ "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", "heroRegenerate": "Neu generieren", "heroRegenerating": "Wird generiert", + "regenerateAnalysis": "Analyse neu starten", + "regenerateSuccess": "Analyse wurde neu erstellt", "heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.", "heroGreetingMorning": "Guten Morgen", "heroGreetingAfternoon": "Guten Tag", diff --git a/messages/en.json b/messages/en.json index 3a41c35e4..155ab44fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -761,6 +761,8 @@ "heroPersonalBaseline": "Based on your last 90 days", "heroRegenerate": "Regenerate", "heroRegenerating": "Regenerating", + "regenerateAnalysis": "Re-run analysis", + "regenerateSuccess": "Analysis refreshed", "heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.", "heroGreetingMorning": "Good morning", "heroGreetingAfternoon": "Good afternoon", diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 2a9c0c862..d097aadc0 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -107,13 +107,20 @@ describe("v1.4.19 A3 — /insights polish", () => { expect(advisorBlock?.[0]).not.toContain("onRegenerate"); }); - it("hero strip is the always-visible page-level refresh affordance", () => { + it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W3)", () => { const src = load(INSIGHTS_PATH); - // The hero owns the page-level regenerate button (mounted via - // `onRegenerate={advisor.regenerate}` on ``). + // v1.4.25 W3 — the regenerate handler moved from `` to + // `` (icon-only RefreshCw on the sticky tab + // strip). The hero strip no longer takes an `onRegenerate` prop. + // We isolate the `` block (lazy match ending at + // the first `/>`) and assert it does NOT carry an onRegenerate + // wiring. expect(src).toMatch( - //, ); + const heroBlock = src.match(//); + expect(heroBlock).not.toBeNull(); + expect(heroBlock?.[0]).not.toContain("onRegenerate"); }); it("does NOT render the duplicate tile strip on /insights", () => { diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 3f4b42cde..f153d1622 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -43,6 +42,7 @@ import { import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card"; import { HeroStrip } from "@/components/insights/hero-strip"; +import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { DailyBriefing } from "@/components/insights/daily-briefing"; import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; import { TrendsRow } from "@/components/insights/trends-row"; @@ -930,21 +930,21 @@ export default function InsightsPage() { return (
- {/* v1.4.22 A5 — section tabs lift above the hero strip so the - user sees the metric-tab nav before scrolling. The nav itself - remains a sticky scroll-anchored strip — clicking a tab - scrolls to the matching section, scrolling highlights the - active tab. Hero + Daily Briefing always render below the - nav; the metric tabs control which sub-sections are visible - in the user's viewport. */} - + {/* v1.4.25 W3 — section tabs + regenerate affordance live in the + dedicated `` component. The strip is sticky + so the user can re-run the analysis without scrolling back to + the hero band. The hero strip no longer carries its own + regenerate button — the icon-only RefreshCw on this strip is + the canonical page-level affordance. */} + { // The action-row button passes no prefill (drawer opens blank); // the Health Score panel passes a score-aware question. Both @@ -1659,117 +1659,8 @@ function MedicationComplianceCalendar({ } // ── Section Navigation ─────────────────────────────────────────────────────── - -// v1.4.25 W3 — "section-general" pill dropped alongside the -// `` it scrolled to. The general status was -// superseded by the always-on `` block above the -// per-metric sections, so the pill had nothing to scroll to once the -// status mount was removed. -const SECTION_IDS = [ - "section-bp", - "section-weight", - "section-pulse", - "section-mood", - "section-meds", - "section-bmi", -] as const; - -const SECTION_LABEL_KEYS: Record<(typeof SECTION_IDS)[number], string> = { - "section-bp": "insights.navBloodPressure", - "section-weight": "insights.navWeight", - "section-pulse": "insights.navPulse", - "section-mood": "insights.navMood", - "section-meds": "insights.navMedication", - "section-bmi": "insights.navBmi", -}; - -function InsightsSectionNav() { - const { t } = useTranslations(); - const [activeId, setActiveId] = useState(SECTION_IDS[0]); - const observerRef = useRef(null); - - useEffect(() => { - observerRef.current = new IntersectionObserver( - (entries) => { - // v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the - // highest intersectionRatio in the band rather than the - // observer-supplied last entry. Three sections briefly - // visible during a fast scroll otherwise made the active - // pill jump to whichever one came last in the batch. - const visible = entries - .filter((e) => e.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio); - const top = visible[0]; - if (top) { - setActiveId((current) => - current === top.target.id ? current : top.target.id, - ); - } - }, - { rootMargin: "-30% 0px -60% 0px" }, - ); - - for (const id of SECTION_IDS) { - const el = document.getElementById(id); - if (el) observerRef.current.observe(el); - } - - return () => observerRef.current?.disconnect(); - }, []); - - function scrollTo(id: string) { - const el = document.getElementById(id); - if (!el) return; - // v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind - // `prefers-reduced-motion`; honour the user's OS-level pref. - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - el.scrollIntoView({ - behavior: prefersReducedMotion ? "auto" : "smooth", - }); - } - - return ( - // v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility + - // sticky-strip polish. Notable changes: - // - aria-label so screen-reader landmark traversal hears the - // rail's purpose ("Skip to section" / "Zu Abschnitt springen"). - // - bg-background/95 instead of /80 to kill the hero-glow bleed - // during scroll. - // - Drop the `-mx-4 / md:-mx-6` negative margin; the parent - // container handles horizontal padding so 280px (Galaxy Fold) - // doesn't get a ghost scrollbar. - // - Hide the inner overflow's scrollbar so the sticky strip - // reads as a single bar. - - ); -} +// v1.4.25 W3 — the legacy inline `InsightsSectionNav` was promoted to a +// dedicated component at `src/components/insights/insights-tab-strip.tsx` +// so the regenerate affordance could share the sticky strip with the +// metric pills. The page just mounts `` near the top +// of the layout. diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index e2f9c4fcb..3dcbbb327 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -143,34 +143,14 @@ describe("", () => { expect(coachTag?.[0]).not.toContain('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", () => { + // v1.4.25 W3 — the regenerate button moved from the hero action row + // to the dedicated `` component. The hero strip no + // longer renders the rerun slot under any prop combination. + it("does NOT render a regenerate button in the hero action row", () => { 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(); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index d20d8539c..b903964ee 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -2,14 +2,7 @@ import Link from "next/link"; import { toast } from "sonner"; -import { - Download, - FileText, - Loader2, - RefreshCw, - Share2, - Sparkles, -} from "lucide-react"; +import { Download, FileText, Share2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; @@ -31,9 +24,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; * "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) + * - action row: "Generate weekly report" + "Ask the coach" + * (v1.4.25 W3 dropped the third "Re-run analysis" button — the + * regenerate affordance moved to `` so the user + * can re-run the analysis from any scroll position without + * returning to the hero band). * - chip strip below the action band * - Dracula gradient + soft purple glow via the new `.hero-gradient` * + `.glow-purple` utilities in `globals.css` @@ -42,8 +37,7 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; * 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. + * Pure presentational — the page owns the briefing data. */ interface HeroStripProps { @@ -57,10 +51,6 @@ interface HeroStripProps { * 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 (B2b) * routes a chip click into the drawer's input; the parent owns the @@ -141,8 +131,6 @@ export function HeroStrip({ briefing, updatedAt, userName, - onRegenerate, - regenerating = false, onPickPrompt, onAskCoach, weeklyReportReady, @@ -291,28 +279,13 @@ export function HeroStrip({
` that lived inside + * `src/app/insights/page.tsx` so the regenerate affordance can sit on + * the same row as the metric pills. Up to v1.4.24 the regenerate + * button lived inside the hero band's action row, which scrolled away + * the moment the user started reading the per-metric cards. Marc's + * directive (2026-05-14): the affordance has to stay reachable while + * the user is deep in a section. + * + * Behaviour: + * - Six pills (BP / Weight / Pulse / Mood / Meds / BMI), each + * scroll-anchored to the matching `
` + * in the page. IntersectionObserver tracks which section is in + * the active band so the matching pill highlights. + * - Right slot: a 44×44 icon-only `` button. Clicking it + * fires the `onRegenerate` handler the page hands down and pops a + * sonner toast with `t("insights.regenerateSuccess")` once the + * advisor query confirms. While the regenerate is in-flight the + * icon swaps to a spinner and the button is disabled. + * + * Accessibility: + * - `