Unify Insights overview section headings onto one icon system

Every overview section now leads with a single SectionHeading above its
card: a Lucide icon in the foreground colour and an h2 title at one size
and weight. Previously the overview mixed four heading dialects — a
purple Sparkles glyph on the briefing, no icon on Trends, a white-icon
tile header on the score and vitals strips, and in-card titles buried
inside the period-in-review, cycle-summary and signals cards.

- Add SectionHeading (icon + title + optional trailing action).
- Briefing: purple icon to white; heading moves through SectionHeading.
- Trends: add a TrendingUp icon (was icon-less); subtitle becomes the
  trailing action.
- Health scores + vitals: migrate the section headers off TileHeader.
- Period in review, signals of the day, device notifications: lift the
  in-card title out to a heading above the card.
- Signals card min-height drops to match its slimmer body now that the
  title row left the card.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 14:15:10 +02:00
1 parent 67d0f8f238
commit 86f416fb2a
9 files changed
+445 -284

No files matched your search

@@ -48,7 +48,12 @@ function ok(
metric: "COINCIDENT_DEVIATION",
status: "ok",
value,
coverage: { requiredInputs: 5, presentInputs: 5, historyDays: 30, missing: [] },
coverage: {
requiredInputs: 5,
presentInputs: 5,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band },
provenance: {
inputs: ["RESTING_HEART_RATE", "RESPIRATORY_RATE"],
@@ -65,7 +70,12 @@ function insufficient(): Resp {
metric: "COINCIDENT_DEVIATION",
status: "insufficient",
value: null,
coverage: { requiredInputs: 2, presentInputs: 1, historyDays: 0, missing: [] },
coverage: {
requiredInputs: 2,
presentInputs: 1,
historyDays: 0,
missing: [],
},
confidence: null,
provenance: {
inputs: ["RESTING_HEART_RATE"],
@@ -90,7 +100,9 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-slot="coincident-deviation-card-skeleton"');
expect(html).not.toContain('data-slot="coincident-deviation-card"');
// The skeleton reserves the resolved card's footprint (no downward shift).
expect(html).toContain("min-h-48");
// The footprint shrank to `min-h-32` in v1.15.10: the in-card title row
// moved out to the section heading above the card.
expect(html).toContain("min-h-32");
});
it("the skeleton and a resolved card share the same min-height footprint", () => {
@@ -111,8 +123,8 @@ describe("<CoincidentDeviationCard>", () => {
}),
);
const fired = render(<CoincidentDeviationCard />);
expect(skeleton).toContain("min-h-48");
expect(fired).toContain("min-h-48");
expect(skeleton).toContain("min-h-32");
expect(fired).toContain("min-h-32");
});
it("renders nothing in the insufficient (building baselines) state", () => {
@@ -127,7 +139,10 @@ describe("<CoincidentDeviationCard>", () => {
ok({
fired: false,
day: "2026-06-03",
vitals: [deviation("RESTING_HEART_RATE", false), deviation("WEIGHT", false)],
vitals: [
deviation("RESTING_HEART_RATE", false),
deviation("WEIGHT", false),
],
contributing: [],
}),
);
@@ -141,7 +156,10 @@ describe("<CoincidentDeviationCard>", () => {
ok({
fired: false,
day: "2026-06-03",
vitals: [deviation("RESTING_HEART_RATE", true), deviation("WEIGHT", false)],
vitals: [
deviation("RESTING_HEART_RATE", true),
deviation("WEIGHT", false),
],
contributing: [deviation("RESTING_HEART_RATE", true)],
}),
);
@@ -4,6 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
@@ -78,48 +79,61 @@ function CoincidentProvenance({
}
/**
* The card shell — uppercase label + the provenance affordance, with the
* state-specific body as children. The tall `fired` state keeps a `min-h` floor;
* the calmer everyday states size to their (short) content so the card carries
* no empty tail.
* The card shell — the "Signals of the day" heading lives ABOVE the card now
* (via `SectionHeading`, mirroring every other overview section), with the
* provenance ⓘ as the heading's trailing action. The card body carries the
* state-specific content. The tall `fired` state keeps a `min-h` floor; the
* calmer everyday states size to their (short) content so the card carries no
* empty tail.
*/
function CardShell({
state,
provenance,
className,
children,
}: {
state: SignalState;
provenance?: DerivedProvenance;
className?: string;
children: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<div
data-slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` floor only on the tallest state (fired: 44px provenance header
// + the named-vitals body + the mandatory factors line). The calmer
// everyday states (all-clear / watch / insufficient) carry a single
// short body, so flooring them to the fired height left a large empty
// tail; they now size to their content. The in-flight `CardSkeleton`
// keeps the `min-h-48` footprint so the initial paint still reserves the
// row and does not shift as the read resolves.
state === "fired" && "min-h-48",
// At most amber — never destructive/red. The fired state borders
// warning; every calmer state keeps the neutral card border.
state === "fired" ? "border-warning/40" : "border-border",
)}
<section
data-slot="coincident-deviation-section"
aria-label={t("insights.derived.coincident.cardTitle")}
className={cn("space-y-3", className)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
{t("insights.derived.coincident.cardTitle")}
</span>
{provenance ? <CoincidentProvenance provenance={provenance} /> : null}
<SectionHeading
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
action={
provenance ? (
<CoincidentProvenance provenance={provenance} />
) : undefined
}
/>
<div
data-slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` floor only on the tallest state (fired: the named-vitals
// body + the mandatory factors line). The calmer everyday states
// (all-clear / watch / insufficient) carry a single short body, so
// flooring them to the fired height left a large empty tail; they now
// size to their content. The in-flight `CardSkeleton` keeps the
// `min-h-48` footprint so the initial paint still reserves the row and
// does not shift as the read resolves.
state === "fired" && "min-h-32",
// At most amber — never destructive/red. The fired state borders
// warning; every calmer state keeps the neutral card border.
state === "fired" ? "border-warning/40" : "border-border",
)}
>
{children}
</div>
{children}
</div>
</section>
);
}
@@ -129,20 +143,27 @@ function CardShell({
* mirroring the provenance trigger the resolved header carries — so the card
* does not shift when the real content drops in.
*/
function CardSkeleton() {
function CardSkeleton({ className }: { className?: string }) {
const { t } = useTranslations();
return (
<div
data-slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
<section
data-slot="coincident-deviation-section"
aria-label={t("insights.derived.coincident.cardTitle")}
className={cn("space-y-3", className)}
>
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-28 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
<SectionHeading
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
/>
<div
data-slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-32 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
</section>
);
}
@@ -158,11 +179,7 @@ export function CoincidentDeviationCard({
// CLS-safe placeholder while the single read is in flight.
if (!data) {
return (
<div className={className}>
<CardSkeleton />
</div>
);
return <CardSkeleton className={className} />;
}
// Building the baselines — fewer than two banded vitals. There is no signal
@@ -202,28 +219,30 @@ export function CoincidentDeviationCard({
if (state === "watch") {
return (
<div className={className}>
<CardShell state="watch" provenance={data.provenance}>
<div className="flex items-start gap-2">
<Activity
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.watch")}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.derived.coincident.watchVital", { vital: names })}
</p>
</div>
<CardShell
state="watch"
provenance={data.provenance}
className={className}
>
<div className="flex items-start gap-2">
<Activity
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.watch")}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.derived.coincident.watchVital", { vital: names })}
</p>
</div>
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
</CardShell>
</div>
</div>
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
</CardShell>
);
}
@@ -232,37 +251,35 @@ export function CoincidentDeviationCard({
// so a screen-reader user gets the day-to-day state change in the calm tone
// the card keeps visually — the one state whose meaning shifts vs all-clear.
return (
<div className={className}>
<CardShell state="fired" provenance={data.provenance}>
<div className="flex items-start gap-2" role="status">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.firedHeadline", {
count: contributing.length,
})}
</p>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-vitals"
>
{t("insights.derived.coincident.vitals", { list: names })}
</p>
</div>
<CardShell state="fired" provenance={data.provenance} className={className}>
<div className="flex items-start gap-2" role="status">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.firedHeadline", {
count: contributing.length,
})}
</p>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-vitals"
>
{t("insights.derived.coincident.vitals", { list: names })}
</p>
</div>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-factors"
>
{t("insights.derived.coincident.factors")}
</p>
</CardShell>
</div>
</div>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-factors"
>
{t("insights.derived.coincident.factors")}
</p>
</CardShell>
);
}
+10 -19
View File
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
@@ -283,25 +284,15 @@ export function DailyBriefing({
aria-label={t("insights.dailyBriefing.title")}
className="space-y-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles
className="text-dose-accent h-4 w-4 shrink-0"
aria-hidden="true"
/>
<h2 className="text-lg font-semibold">
{t("insights.dailyBriefing.title")}
</h2>
</div>
{metaSlot && (
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
</div>
<SectionHeading
icon={Sparkles}
title={t("insights.dailyBriefing.title")}
action={
metaSlot ? (
<div data-slot="daily-briefing-meta-slot">{metaSlot}</div>
) : undefined
}
/>
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardContent>
{loading ? (
@@ -12,6 +12,7 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS,
} from "@/components/measurements/measurement-list-meta";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { CoverageMeter } from "./coverage-meter";
import { ProvenanceExplainer } from "./provenance-explainer";
@@ -114,7 +115,9 @@ function MetricProvenance({
const method = (
<>
{meta.caveatKey ? (
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
<span className="text-warning block font-medium">
{t(meta.caveatKey)}
</span>
) : null}
{t(meta.methodKey)}
</>
@@ -161,7 +164,10 @@ function BaselineTile({
if (isLoading || !data) return null;
// Absent → don't render the tile at all.
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
if (
data.status === "insufficient" &&
data.reason === "no_readings_in_window"
) {
return null;
}
@@ -184,7 +190,10 @@ function BaselineTile({
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
<p
className="text-muted-foreground text-sm"
data-slot="vitals-tile-building"
>
{t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
@@ -211,7 +220,9 @@ function BaselineTile({
series={v.series}
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={<MetricProvenance metric={metric} provenance={data.provenance} />}
provenance={
<MetricProvenance metric={metric} provenance={data.provenance} />
}
/>
</div>
);
@@ -236,7 +247,11 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
})
: t("insights.derived.vitals.sixMinuteNoBand");
return (
<div data-slot="vitals-tile" data-metric="SIX_MINUTE_WALK_BAND" data-state="ok">
<div
data-slot="vitals-tile"
data-metric="SIX_MINUTE_WALK_BAND"
data-state="ok"
>
<SparklineDeltaTile
label={t("measurements.typeSixMinuteWalkDistance")}
value={v.distanceM}
@@ -248,7 +263,10 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
framing={framing}
precision={0}
provenance={
<MetricProvenance metric="SIX_MINUTE_WALK_BAND" provenance={data.provenance} />
<MetricProvenance
metric="SIX_MINUTE_WALK_BAND"
provenance={data.provenance}
/>
}
/>
</div>
@@ -311,7 +329,11 @@ function VascularAgeTile({ read, isLoading }: TileProps) {
: t("insights.derived.vitals.vascularMatch")
: t("insights.derived.vitals.vascularNoAge");
return (
<div data-slot="vitals-tile" data-metric="VASCULAR_AGE_DELTA" data-state="ok">
<div
data-slot="vitals-tile"
data-metric="VASCULAR_AGE_DELTA"
data-state="ok"
>
<SparklineDeltaTile
label={t("measurements.typeVascularAge")}
value={v.vascularAge}
@@ -337,7 +359,10 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
const { t } = useTranslations();
const data = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
if (isLoading || !data) return null;
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
if (
data.status === "insufficient" &&
data.reason === "no_readings_in_window"
) {
return null;
}
if (data.status === "insufficient") {
@@ -444,7 +469,9 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
const bmi = read<BmiValue>({ metric: "BMI" });
@@ -492,7 +519,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean {
const band = read<VitalsBaselineValue>({ metric });
if (
band &&
!(band.status === "insufficient" && band.reason === "no_readings_in_window")
!(
band.status === "insufficient" &&
band.reason === "no_readings_in_window"
)
) {
return true;
}
@@ -526,7 +556,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
@@ -561,14 +591,17 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
const showMobility = !isLoading && hasRenderableMobility(read);
return (
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
<div
data-slot="vitals-dashboard-wrap"
className={cn("space-y-6", className)}
>
{showSection && (
<section
data-slot="vitals-dashboard"
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
@@ -612,7 +645,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
aria-label={t("insights.derived.vitals.mobilitySectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={Footprints}
title={t("insights.derived.vitals.mobilitySectionTitle")}
/>
@@ -2,11 +2,18 @@
import { useState, type ComponentType } from "react";
import Link from "next/link";
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
import {
Activity,
Flame,
Gauge,
HeartPulse,
Moon,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
@@ -201,7 +208,7 @@ export function WellnessScores({
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<TileHeader
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
@@ -341,7 +348,11 @@ export function WellnessScores({
// "missing tile" frame.
if (extraTile != null) {
tiles.push(
<div key="EXTRA" data-slot="wellness-extra-tile-slot" className="contents">
<div
key="EXTRA"
data-slot="wellness-extra-tile-slot"
className="contents"
>
{extraTile}
</div>,
);
@@ -380,7 +391,7 @@ export function WellnessScores({
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<TileHeader
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
@@ -2,11 +2,12 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { CalendarRange, Info } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import {
Popover,
PopoverContent,
@@ -122,22 +123,30 @@ export function PeriodNarrativeCard({
q.state.data?.revalidating && !q.state.data.narrative ? 5000 : false,
});
// Loading with nothing to show yet → matched skeleton (CLS-safe).
// Loading with nothing to show yet → matched skeleton (CLS-safe). The
// heading renders above the skeleton so the section reserves its full
// height (heading + card) before the prose lands.
if (query.isLoading) {
return (
<div
data-slot="period-narrative-card-skeleton"
aria-hidden="true"
className={cn(SKELETON_SHELL, className)}
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<div className="flex h-7 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 size-5 rounded-full" />
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<div
data-slot="period-narrative-card-skeleton"
aria-hidden="true"
className={SKELETON_SHELL}
>
<div className="bg-muted/40 h-4 w-full rounded" />
<div className="bg-muted/40 h-4 w-5/6 rounded" />
<div className="bg-muted/40 h-4 w-2/3 rounded" />
</div>
<div className="bg-muted/40 h-4 w-full rounded" />
<div className="bg-muted/40 h-4 w-5/6 rounded" />
<div className="bg-muted/40 h-4 w-2/3 rounded" />
</div>
</section>
);
}
@@ -153,67 +162,73 @@ export function PeriodNarrativeCard({
const preparing = !narrative && data?.revalidating === true;
return (
<div
data-slot="period-narrative-card"
data-period={period}
className={cn(SHELL, className)}
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="text-foreground truncate text-base font-semibold">
{t("insights.narrativeTitle")}
</h2>
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
action={
narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : undefined
}
/>
<div
data-slot="period-narrative-card"
data-period={period}
className={SHELL}
>
{/* Week / month toggle. Calm segmented control, no chart. */}
<div className="flex gap-1 self-start">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
{preparing ? (
<p className="text-muted-foreground text-sm">
{t("insights.narrativePreparing")}
</p>
) : (
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
{narrative?.text}
</p>
)}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
time: new Date(narrative.updatedAt).toLocaleDateString(
locale === "de" ? "de-DE" : undefined,
),
})}
</p>
) : null}
</div>
{/* Week / month toggle. Calm segmented control, no chart. */}
<div className="flex gap-1 self-start">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
{preparing ? (
<p className="text-muted-foreground text-sm">
{t("insights.narrativePreparing")}
</p>
) : (
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
{narrative?.text}
</p>
)}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
time: new Date(narrative.updatedAt).toLocaleDateString(
locale === "de" ? "de-DE" : undefined,
),
})}
</p>
) : null}
</div>
</section>
);
}
+59 -62
View File
@@ -1,19 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
HeartPulse,
Footprints,
Wind,
Info,
} from "lucide-react";
import { Activity, HeartPulse, Footprints, Wind, Info } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -62,7 +57,8 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate",
LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate",
WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness",
BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance",
BREATHING_DISTURBANCE_EVENT:
"insights.rhythmEvents.event.breathingDisturbance",
};
/**
@@ -107,69 +103,70 @@ export function RhythmEventsCard({
return (
<section
data-slot="rhythm-events-card"
data-slot="rhythm-events-section"
aria-label={t("insights.rhythmEvents.sectionTitle")}
className={cn(
"bg-card border-border space-y-4 rounded-xl border p-4 md:p-6",
className,
)}
className={cn("space-y-3", className)}
>
<div className="space-y-1">
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.rhythmEvents.sectionTitle")}
</h2>
<SectionHeading
icon={Activity}
title={t("insights.rhythmEvents.sectionTitle")}
/>
<div
data-slot="rhythm-events-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
<p className="text-muted-foreground text-sm">
{t("insights.rhythmEvents.sectionIntro")}
</p>
</div>
<ol data-slot="rhythm-events-timeline" className="space-y-3">
{data.events.map((event) => {
const Icon = EVENT_ICONS[event.type] ?? Activity;
const labelKey = EVENT_LABEL_KEYS[event.type];
const label = labelKey ? t(labelKey) : event.type;
const verdictKey = event.classification
? CLASSIFICATION_LABEL_KEYS[event.classification]
: undefined;
const verdict = verdictKey ? t(verdictKey) : null;
return (
<li
key={event.id}
data-slot="rhythm-event-row"
data-event-type={event.type}
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">{label}</p>
{verdict && (
<p
data-slot="rhythm-event-verdict"
className="text-muted-foreground text-sm"
>
{verdict}
<ol data-slot="rhythm-events-timeline" className="space-y-3">
{data.events.map((event) => {
const Icon = EVENT_ICONS[event.type] ?? Activity;
const labelKey = EVENT_LABEL_KEYS[event.type];
const label = labelKey ? t(labelKey) : event.type;
const verdictKey = event.classification
? CLASSIFICATION_LABEL_KEYS[event.classification]
: undefined;
const verdict = verdictKey ? t(verdictKey) : null;
return (
<li
key={event.id}
data-slot="rhythm-event-row"
data-event-type={event.type}
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">{label}</p>
{verdict && (
<p
data-slot="rhythm-event-verdict"
className="text-muted-foreground text-sm"
>
{verdict}
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
</p>
</div>
</li>
);
})}
</ol>
</div>
</li>
);
})}
</ol>
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
Plain React text children (no markdown library — XSS rule). */}
<div
data-slot="rhythm-events-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.rhythmEvents.disclaimer")}</p>
<div
data-slot="rhythm-events-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.rhythmEvents.disclaimer")}</p>
</div>
</div>
</section>
);
@@ -0,0 +1,70 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* v1.15.10 — the one section heading for the Insights overview.
*
* Every top-level overview section (health scores, daily briefing, vitals,
* trends, period-in-review, cycle summary, signals of the day) leads with
* this identical heading ABOVE its card: a leading Lucide icon and an `<h2>`
* title, both in the foreground colour (white in dark mode — never a per-
* section accent hue), at one size + weight. An optional `action` slot pins a
* trailing affordance (a subtitle, a meta control) to the right edge without
* breaking the row's alignment.
*
* The overview used to mix three heading dialects: a `text-lg` `<h2>` with a
* purple Sparkles glyph (briefing), a `text-lg` `<h2>` with NO icon (trends),
* a `text-base` `TileHeader` with a white icon (scores / vitals), and in-card
* titles buried inside the period-narrative, cycle-summary and signals cards.
* Side by side they read as four different surfaces. `SectionHeading` pins the
* single contract so the system can't drift: icon `h-5 w-5 shrink-0
* text-foreground`, title `<h2>` `text-base font-semibold`, `gap-2` between
* icon and title. The caller owns the `space-y-3` gap to the card below.
*
* RSC-safe: no hooks, no browser API.
*/
interface SectionHeadingProps {
/**
* Leading glyph. The caller passes the Lucide component itself
* (`icon={Sparkles}`), not a pre-sized node — `SectionHeading` owns the
* size and colour so every overview heading matches.
*/
icon: ComponentType<{ className?: string }>;
/** Heading text rendered inside the `<h2>`. */
title: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
action?: ReactNode;
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
id?: string;
className?: string;
}
export function SectionHeading({
icon: Icon,
title,
action,
id,
className,
}: SectionHeadingProps) {
return (
<div
data-slot="section-heading"
className={cn(
"flex flex-wrap items-center justify-between gap-2",
className,
)}
>
<div className="flex min-w-0 items-center gap-2">
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
</div>
{action ? (
<div className="flex shrink-0 items-center gap-2">{action}</div>
) : null}
</div>
);
}
+18 -9
View File
@@ -1,9 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import { TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
@@ -99,7 +101,13 @@ interface TrendsRowProps {
loading?: boolean;
}
function MetricChart({ config, title }: { config: TrendChartConfig; title: string }) {
function MetricChart({
config,
title,
}: {
config: TrendChartConfig;
title: string;
}) {
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
@@ -188,14 +196,15 @@ export function TrendsRow({
aria-label={t("insights.trendsRow.title")}
className="space-y-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold">
{t("insights.trendsRow.title")}
</h2>
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
</div>
<SectionHeading
icon={TrendingUp}
title={t("insights.trendsRow.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
}
/>
{/* v1.4.22 A4 — equal-height cards. The annotation prose
below each chart varies in length, which used to leave the
three cards on visibly different baselines. Each card is now