feat(insights): surface the diversity hint as a heading lightbulb

The measurement-diversity nudge used to render as an inline note block on
each category page; read in context it looked like a data finding rather
than a tip and confused. Move it onto the heading as a small Lightbulb
glyph that only mounts when the spread is genuinely lopsided; the hint
text it used to show inline now lives in a hover / focus Tooltip with an
accessible name. The sub-page shell carries the glyph in the heading row,
drops the removed explainer trigger, and normalises the foot-of-page
"show all readings" control to the standard secondary-button height.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:50:59 +02:00
1 parent 0e9fa7bf80
commit 4935a9b000
9 files changed
+133 -43

No files matched your search

+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — alle Werte",
"valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.",
"diversity": {
"trigger": "Messhinweis",
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
},
+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — all readings",
"valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.",
"diversity": {
"trigger": "Measurement tip",
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
},
+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — todas las mediciones",
"valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.",
"diversity": {
"trigger": "Consejo de medición",
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
},
+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — toutes les mesures",
"valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.",
"diversity": {
"trigger": "Conseil de mesure",
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
},
+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — tutte le misurazioni",
"valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.",
"diversity": {
"trigger": "Suggerimento di misurazione",
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
},
+1
View File
@@ -1969,6 +1969,7 @@
"valuesPageTitle": "{metric} — wszystkie pomiary",
"valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.",
"diversity": {
"trigger": "Wskazówka pomiarowa",
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
},
@@ -64,13 +64,42 @@ describe("<SubPageShell>", () => {
);
});
it("keeps the explainer trigger glyph when explainerMetric is set", () => {
it("no longer paints the round `?` explainer trigger next to the heading", () => {
const html = render(
<SubPageShell title="Blood pressure" explainerMetric="bloodPressure">
<span />
</SubPageShell>,
);
// The `?` glyph stays available alongside the inline caption.
expect(html).toMatch(/data-slot="metric-explainer-trigger"/);
// v1.8.6 — the `?` popover affordance was dropped; only the inline
// definition caption remains.
expect(html).not.toMatch(/data-slot="metric-explainer-trigger"/);
});
it("renders the diversity nudge node inside the heading row", () => {
const html = render(
<SubPageShell
title="Weight"
diversityNudge={<span data-slot="diversity-probe" />}
>
<span />
</SubPageShell>,
);
// v1.8.6 — the nudge moved up to the heading row (a `Lightbulb`
// glyph), no longer an inline block beneath the stat strip.
const headerEnd = html.indexOf("</header>");
const probeIdx = html.indexOf('data-slot="diversity-probe"');
expect(probeIdx).toBeGreaterThan(-1);
expect(probeIdx).toBeLessThan(headerEnd);
});
it("mounts no coach launch surface when coachLaunch is omitted", () => {
const html = render(
<SubPageShell title="Pulse">
<span />
</SubPageShell>,
);
// The coach icon only renders under the launch provider + flags; the
// shell renders nothing for it on the default path.
expect(html).not.toContain('data-slot="coach-launch-icon"');
});
});
@@ -3,6 +3,12 @@
import { useQuery } from "@tanstack/react-query";
import { Lightbulb } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -18,6 +24,14 @@ import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity
* when the spread is genuinely lopsided — surfaces a gentle hint to
* measure on other days / times.
*
* v1.8.6 — the hint moved off the page body and onto the heading. The
* inline `role="note"` block confused on the category pages (it read
* like a data finding rather than a tip), so the nudge now renders as a
* small `Lightbulb` glyph beside the page title. The hint text the block
* used to show inline lives in a hover / focus Tooltip; the glyph mounts
* only when the spread is actually lopsided, so a healthy metric shows
* no extra affordance at all.
*
* v1.8.3 anti-freeze posture: the read is a bounded `limit`-capped query
* with a 5-minute `staleTime`, fires only after the page has mounted
* (no SSR blocking), and renders nothing while in flight or when the
@@ -75,18 +89,29 @@ export function MeasurementDiversityNudge({
const message = t(`insights.subPage.diversity.${signal.kind}`, {
metric: metricLabel,
});
const triggerLabel = t("insights.subPage.diversity.trigger");
return (
<div
data-slot="measurement-diversity-nudge"
role="note"
className="bg-muted/40 text-muted-foreground flex items-start gap-2.5 rounded-lg px-3 py-2.5 text-sm"
>
<Lightbulb
className="text-dracula-yellow mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="leading-relaxed">{message}</p>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -my-3 -mx-2 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="measurement-diversity-nudge-body"
align="start"
className="max-w-xs leading-relaxed"
>
{message}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+59 -29
View File
@@ -6,7 +6,7 @@ import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -40,18 +40,16 @@ export interface SubPageShellProps {
*/
description?: string;
/**
* v1.8.0 — when set, a `?` glyph next to the heading opens a static
* "What is X?" explainer (popover on desktop, bottom-sheet on phones).
* The value is the metric key feeding
* `insights.subPage.explainer.<metric>{Title,Body}`. Omitted on the
* mother page; every routed metric sub-page passes its key.
* v1.8.0 — when set, the metric's static "What is X?" definition
* renders as a muted caption directly beneath the heading. The value
* is the metric key feeding `insights.subPage.explainer.<metric>Body`.
* Omitted on the mother page; every routed metric sub-page passes its
* key.
*
* v1.8.4 — the same explainer body also renders inline as a muted
* caption directly beneath the heading, so the one- to two-sentence
* definition is visible without opening the popover. The `?` glyph
* stays as the always-available pointer for the same copy. Both the
* inline caption and the popover read the identical
* `insights.subPage.explainer.<metric>Body` string — no duplicated copy.
* v1.8.6 — the round `?` popover affordance next to the heading is
* gone (it read as restless next to the title). The inline definition
* caption stays — it is the single always-visible source of the
* one- to two-sentence definition, no tap required.
*/
explainerMetric?: string;
/**
@@ -77,11 +75,27 @@ export interface SubPageShellProps {
*/
statStrip?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge mounted just under the stat
* strip. A gentle hint when readings cluster on one weekday / time.
* Self-gating: renders nothing when the spread is healthy.
* v1.8.5 W4b — measurement-diversity nudge.
*
* v1.8.6 — relocated from an inline block under the stat strip to a
* `Lightbulb` glyph beside the heading (a hover / focus Tooltip carries
* the hint copy). The node is `<MeasurementDiversityNudge>`, which is
* self-gating: it renders the glyph only when readings cluster on one
* weekday / time, and nothing when the spread is healthy.
*/
diversityNudge?: ReactNode;
/**
* v1.8.6 — opt-in Coach launch in the page header.
*
* When true, the shell mounts an icon-only `<CoachLaunchButton>` at
* the top-right of the header, vertically aligned with the heading, so
* "Coach fragen" reads as a header action rather than a foot-of-page
* button. Every routed category page passes it; the mother page and
* empty-state branches omit it. The button self-gates on the Coach
* feature flag + per-user opt-out, so a disabled-Coach tenant shows
* nothing.
*/
coachLaunch?: boolean;
/**
* v1.8.5 W4b — a "show all readings" entry rendered at the foot of the
* page, after the chart + cards. Sub-pages pass the metric's
@@ -100,6 +114,7 @@ export function SubPageShell({
focusOnMount = false,
statStrip,
diversityNudge,
coachLaunch = false,
showAllValuesType,
children,
}: SubPageShellProps) {
@@ -144,18 +159,26 @@ export function SubPageShell({
>
{title}
</h1>
{explainerMetric ? (
<MetricExplainer metric={explainerMetric} />
) : null}
{/* v1.8.6 — the diversity nudge now rides the heading as a
`Lightbulb` glyph (the node self-gates to nothing when the
spread is healthy), replacing the old inline block. */}
{diversityNudge}
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
</Badge>
) : null}
{/* v1.8.6 — Coach launch sits top-right at heading height as an
icon. `ml-auto` pushes it to the row's trailing edge so it
aligns with the title regardless of badge / glyph presence.
The button self-gates on the Coach flag + per-user opt-out. */}
{coachLaunch ? (
<CoachLaunchButton variant="icon" className="ml-auto" />
) : null}
</div>
{explainerMetric ? (
// v1.8.4 — surface the explainer definition inline, reusing the
// exact body string the `?` popover reads.
// v1.8.4 — surface the metric's static definition inline,
// directly beneath the heading.
//
// v1.8.5 W4a — render the explainer body as the *single* caption
// under the heading. Pre-v1.8.5 both this paragraph and the
@@ -165,6 +188,10 @@ export function SubPageShell({
// explainer body is the definition, so it wins; the description
// only renders when no explainer is set (the mother page and any
// future explainer-less sub-page).
//
// v1.8.6 — this caption is now the only surface for the
// definition; the round `?` popover that used to read the same
// string was removed.
<p
data-slot="metric-explainer-inline"
className="text-muted-foreground text-sm leading-relaxed"
@@ -176,23 +203,26 @@ export function SubPageShell({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.8.5 W4b — numbers-first stat strip + diversity nudge sit
between the header and the chart so the page leads with data,
mirroring the Apple Health / Withings detail layout. Both are
self-gating (render nothing without data), so the spacing
rhythm holds on the empty-state and brand-new-metric paths. */}
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
and the chart so the page leads with data, mirroring the Apple
Health / Withings detail layout. Self-gating (renders nothing
without data), so the spacing rhythm holds on the empty-state
and brand-new-metric paths.
v1.8.6 — the diversity nudge moved up to the heading row. */}
{statStrip}
{diversityNudge}
{children}
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
the dedicated per-metric values subpage. */}
the dedicated per-metric values subpage.
v1.8.6 — normalised to the `h-10` secondary-button height so it
reads as a consistent control now that the foot-of-page Coach
button (which set the old visual baseline) has moved to the
header. */}
{showAllValuesType ? (
<Button
asChild
variant="outline"
size="sm"
data-slot="metric-show-all-values"
className="w-full sm:w-auto"
className="h-10 w-full sm:w-auto"
>
<Link href={`/insights/values/${showAllValuesType}`}>
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />