feat(insights): extract tab strip + relocate regenerate button to top-right

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 `<RefreshCw>` 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 `<InsightsTabStrip
    onRegenerate={advisor.regenerate} regenerating={...} />` 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 `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 11:56:59 +02:00
1 parent 3c608520f6
commit 72e64c816d
7 files changed
+259 -195

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+11 -4
View File
@@ -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 `<HeroStrip>`).
// v1.4.25 W3 — the regenerate handler moved from `<HeroStrip>` to
// `<InsightsTabStrip>` (icon-only RefreshCw on the sticky tab
// strip). The hero strip no longer takes an `onRegenerate` prop.
// We isolate the `<HeroStrip ... />` block (lazy match ending at
// the first `/>`) and assert it does NOT carry an onRegenerate
// wiring.
expect(src).toMatch(
/<HeroStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}/,
/<InsightsTabStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}[\s\S]*?\/>/,
);
const heroBlock = src.match(/<HeroStrip[\s\S]*?\/>/);
expect(heroBlock).not.toBeNull();
expect(heroBlock?.[0]).not.toContain("onRegenerate");
});
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
+17 -126
View File
@@ -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 (
<div className="space-y-8">
{/* 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. */}
<InsightsSectionNav />
{/* v1.4.25 W3 — section tabs + regenerate affordance live in the
dedicated `<InsightsTabStrip>` 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. */}
<InsightsTabStrip
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
<HeroStrip
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
onAskCoach={(prefill?: string) => {
// 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
// `<InsightStatusCard>` it scrolled to. The general status was
// superseded by the always-on `<InsightAdvisorCard>` 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<string>(SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(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.
<nav
aria-label={t("insights.navAriaLabel")}
className={cn(
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
<div className="flex gap-2">
{SECTION_IDS.map((id) => {
const isActive = activeId === id;
return (
<button
key={id}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
);
})}
</div>
</nav>
);
}
// 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 `<InsightsTabStrip>` near the top
// of the layout.
@@ -143,34 +143,14 @@ describe("<HeroStrip>", () => {
expect(coachTag?.[0]).not.toContain('title="Coming soon"');
});
it("renders the regenerate button when onRegenerate is supplied", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} onRegenerate={() => {}} />,
);
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 `<InsightsTabStrip>` 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(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain('data-slot="insights-hero-strip-action-rerun"');
});
it("disables the regenerate button while regenerating", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onRegenerate={() => {}}
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(<HeroStrip briefing={null} now={morningLocal} />);
+14 -41
View File
@@ -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 <relative-time>" 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 `<InsightsTabStrip>` so the user
* can re-run the analysis from any scroll position without
* returning to the hero band).
* - <SuggestedPrompts> 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({
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
{onRegenerate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
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.heroActionRerun")}
</span>
</Button>
)}
{/*
* v1.4.25 W3 — the regenerate button moved to the new
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
* to the pill nav) so the user can re-run the analysis
* without scrolling back to the hero band. The hero's
* action row is now Weekly-report + Ask-the-Coach only.
*/}
</div>
<div
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W3 — dedicated tab strip for `/insights`.
*
* Extracted from the inline `<InsightsSectionNav>` 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 `<section id="section-{slug}">`
* in the page. IntersectionObserver tracks which section is in
* the active band so the matching pill highlights.
* - Right slot: a 44×44 icon-only `<RefreshCw>` 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:
* - `<nav aria-label>` mirrors the legacy nav's label so screen-reader
* landmark traversal stays familiar ("Skip to section").
* - Pill `aria-current="location"` flips with the scroll-anchor.
* - The regenerate button has `aria-label={t("insights.regenerateAnalysis")}`
* so the icon-only affordance is announced. Tooltip via `title`
* mirrors the aria-label for sighted users.
* - `prefers-reduced-motion` gates the smooth-scroll on pill clicks.
*
* Layout: sticky `top-0` strip with `backdrop-blur` and `bg-background/95`
* — same surface treatment as the original `<InsightsSectionNav>` so
* the visual rhythm of the page is unchanged.
*/
export interface InsightsTabStripProps {
/**
* Handler the page passes from `useInsightsAdvisorQuery().regenerate`.
* When supplied, the right-side icon button is enabled; when omitted,
* the button hides so the strip stays clean on surfaces that don't
* own the advisor query (e.g. a future preview).
*/
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
regenerating?: boolean;
}
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",
};
export function InsightsTabStrip({
onRegenerate,
regenerating = false,
}: InsightsTabStripProps) {
const { t } = useTranslations();
const [activeId, setActiveId] = useState<string>(SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
// Track which regenerate call we've already toasted so the success
// toast only fires on the rising edge (true → false). Without this
// the toast pops every render where `regenerating` is false.
const lastRegeneratingRef = useRef<boolean>(regenerating);
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();
}, []);
useEffect(() => {
// Fire the success toast on the falling edge of `regenerating`
// (rising-edge guard via the ref). Skips the toast on first
// mount when the parent passes a "false" regenerating value.
if (lastRegeneratingRef.current && !regenerating) {
toast.success(t("insights.regenerateSuccess"));
}
lastRegeneratingRef.current = regenerating;
}, [regenerating, t]);
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",
});
}
const regenerateLabel = t("insights.regenerateAnalysis");
return (
<nav
data-slot="insights-tab-strip"
aria-label={t("insights.navAriaLabel")}
className={cn(
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 gap-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{SECTION_IDS.map((id) => {
const isActive = activeId === id;
return (
<button
key={id}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
);
})}
</div>
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
aria-label={regenerateLabel}
title={regenerateLabel}
data-slot="insights-tab-strip-regenerate"
className={cn(
// 44×44 touch target — Marc directive 2026-05-14. The
// pill row above sits 28px tall; the button breaks the
// band height intentionally so the touch target meets
// the WCAG 2.5.5 minimum without forcing the pills to
// grow.
"inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{regenerating ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<RefreshCw className="h-4 w-4" aria-hidden="true" />
)}
</button>
)}
</div>
</nav>
);
}