fix(insights): align trend-card mood title, tighten assessment gap, lift briefing heading

Four small Insights layout fixes:

- Trends row: render the mood mini chart in the same flat shell the
  weight/blood-pressure mini charts use (`bg-card border rounded-md p-2`
  with a plain title div) instead of the Card/CardHeader chrome that
  stacked extra space above and below the heading. The three trend cards
  now share one title baseline and one height.
- Coach settings: place tone and response length side by side (two
  columns from `sm`, stacked on mobile) so the sheet no longer needs an
  extra scroll length for two short selects.
- Daily briefing: move the heading out of the card into an `<h2>` above
  it, matching the heading-above-card pattern the trends and wellness
  sections use, so the card opens straight on its content.
- Assessment cards: align the loading skeleton and the mood better-days
  card to the canonical `gap-1.5` + `pb-1` heading-to-body rhythm the
  populated state already uses, removing the loose ~16-24px gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:52:56 +02:00
1 parent ba17b08a01
commit 7db9608c4c
5 files changed
+610 -614

No files matched your search

+393 -423
View File
@@ -358,7 +358,8 @@ export function MoodChart({
const rawCount = useMemo<number>(() => {
if (!data?.entries?.length) return 0;
return data.entries.reduce(
(acc, entry) => acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
(acc, entry) =>
acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
0,
);
}, [data]);
@@ -566,58 +567,353 @@ export function MoodChart({
if (!isLoading && !data?.entries?.length) return null;
return (
<Card
data-slot={mini ? "chart-mini" : undefined}
// v1.4.28 R3c-Insights — collapse the Card envelope in mini
// mode (FB-K1). The default `<Card>` paints `py-6 gap-6` —
// ~48 px of vertical chrome that pulled the mood mini chart
// band ~52 px lower than the `<HealthChart mini>` siblings
// in the trends row. The mini override gives mood the same
// `~p-2` shell HealthChart uses so the chart series anchor
// at the same top edge across BP / weight / mood tiles.
//
// D-H5 follow-up — the Card primitive carries `rounded-xl
// border bg-card shadow-sm` by default. HealthChart mini
// paints `rounded-md border bg-card` (no shadow). Without an
// explicit `rounded-md` override the mood tile painted a
// visibly heavier corner radius than BP / weight in the same
// row. Align to the `rounded-md` shell so the three trend
// tiles share one corner radius.
//
// v1.4.37 W4a item 5 — collapse the Card's inter-row gap to
// 0 so the only space between the title and the chart strip
// is the CardHeader's own `pb-1` (4 px), matching the
// `mb-1` HealthChart paints below its plain `<div>` title.
// Pre-fix Mood's `gap-1` (4 px) on top of the header `pb-1`
// stacked an extra 4 px below the title, which read as a
// ~12-16 px taller card than the BP/weight siblings inside
// the trends row.
className={
mini ? "gap-0 rounded-md py-2 shadow-none" : undefined
}
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
// chart body render inside the exact same flat shell `<HealthChart
// mini>` uses (`bg-card border-border rounded-md border p-2` with a
// plain `<div>` title), instead of the `<Card>/<CardHeader>/
// <CardTitle>` chrome that stacked extra vertical space above and
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
// the padding values — makes the three trend cards share one title
// baseline and one height. Full (non-mini) mode keeps the Card path.
const chartBody = isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
) : chartData.length < 3 ? (
// v1.4.16 B1a — sparse-data placeholder consistent with the
// BP/weight/pulse charts. Height tracks the chart strip's
// shared CHART_HEIGHT_PX so the empty state preserves the
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
// other dashboard chart card).
//
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
// count. A user with 50 mood entries across 2 calendar days
// sees `chartData.length = 2` (daily-aggregated) but the raw
// count (sum of `samples`) is 50. Surface "need more days"
// in that scenario so the call-to-action matches reality.
rawCount >= 3 ? (
<ChartEmptyState
title={t("charts.needMoreDistinctDaysTitle")}
description={t("charts.needMoreDistinctDaysDescription")}
height={CHART_HEIGHT_PX}
/>
) : (
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
height={CHART_HEIGHT_PX}
/>
)
) : (
<div
// v1.12.7 — the mood line plots a 1–5 score band; the former
// 240/280 px full-mode height left a tall dead strip below the
// line on the mood subpage. Tighten the default to 200/220 px so
// the chart fits its content without the empty gap. A per-mount
// `--chart-height` override (trends-row mini, etc.) still wins, so
// only the unoverridden full-mode mount shrinks.
className={`${
mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
>
<CardHeader
className={
mini ? "px-2 pb-1 [&]:gap-0" : "pb-2"
}
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
five mood-score ticks ([1,2,3,4,5]), which Recharts
syncs with CartesianGrid by default. That left the
mini variant painting five horizontal lines while
the BP / Weight / Pulse minis painted six (their
auto-generated YAxis ticks land on six bands in
typical health ranges). The explicit coordinates
generator below produces six evenly-spaced lines
so the trends row reads as a single rhythm. */}
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
horizontalCoordinatesGenerator={({ offset }) => {
// `offset` carries the chart's plot-area metrics.
// Six evenly-spaced y-coordinates inside the plot
// area (top + 4 intermediate + bottom) match the
// density of the BP/Weight/Pulse minis.
const top = offset.top ?? 0;
const height = offset.height ?? 0;
if (height <= 0) return [];
const lines = 6;
return Array.from({ length: lines }, (_, i) =>
Math.round(top + (height * i) / (lines - 1)),
);
}}
/>
{showBands &&
VALUE_BANDS.map((band) => (
<ReferenceArea
key={`band-${band.min}-${band.max}`}
y1={band.min}
y2={band.max}
fill={band.color}
fillOpacity={band.opacity}
strokeOpacity={0}
ifOverflow="discard"
/>
))}
{/* v1.4.18 — personal-baseline gated behind the
Trend toggle (maintainer: "only when a trend is being
displayed"). Default OFF. */}
{showTrend && personalBaseline != null && (
<ReferenceLine
y={personalBaseline}
stroke={COLOR_MAIN}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={{
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}}
/>
)}
<XAxis
type="number"
dataKey="pointIndex"
domain={[0, maxPointIndex]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
tzFmt.date(
new Date(chartData[Math.round(value)]?.timestamp ?? Date.now()),
)
}
// v1.4.29 — Recharts ignores `interval` on numeric
// axes; pass explicit tick positions instead.
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={65}
tickMargin={10}
tickFormatter={formatMoodTick}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const {
active,
payload,
label: rechartsLabel,
} = props as unknown as {
active?: boolean;
payload?: Array<{
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: ChartDataPoint;
}>;
label?: number;
};
if (!active || !payload?.length) return null;
const ts =
payload[0]?.payload?.timestamp ??
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
const hoverPoint = payload[0]?.payload as
| ChartDataPoint
| undefined;
for (const item of payload) {
if (typeof item.value !== "number") continue;
const dataKey = String(item.dataKey ?? "");
// Skip auxiliary lines (`ma`, `trend`,
// `scoreCompare`). Comparison value rendered as
// delta on the primary row + own row below.
if (
dataKey === "ma" ||
dataKey === "trend" ||
dataKey === "scoreCompare"
)
continue;
let delta: string | undefined;
const compareValue =
effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare
: undefined;
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
const diff = item.value - compareValue;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
delta = t(
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
}
} else if (personalBaseline != null) {
const diff = item.value - personalBaseline;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
delta = t("charts.deltaVsBaseline").replace(
"{delta}",
formatted,
);
}
}
rows.push({
name: t("charts.moodScore"),
value: formatTooltipValue(item.value),
color: item.color ?? COLOR_MAIN,
delta,
});
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
rows.push({
name: `${t("charts.moodScore")} · ${t(
"comparison.tooltipPrior",
)}`,
value: formatTooltipValue(compareValue),
color: item.color ?? COLOR_MAIN,
});
}
}
if (rows.length === 0) return null;
return <RichChartTooltip active label={dateLabel} rows={rows} />;
}}
/>
<Line
type="monotone"
dataKey="score"
name="score"
stroke={COLOR_MAIN}
strokeWidth={2}
dot={{ r: 3, fill: COLOR_MAIN }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
{showMA && (
<Line
type="monotone"
dataKey="ma"
name="ma"
stroke={COLOR_MA}
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
connectNulls
/>
)}
{showTrend && (
<Line
type="linear"
dataKey="trend"
name="trend"
stroke={COLOR_TREND}
strokeWidth={1}
strokeDasharray="8 4"
dot={false}
connectNulls
/>
)}
{/* v1.4.16 B8 — comparison overlay (mood). Same
dimmed dashed treatment the BP/weight/pulse chart
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
data via hasComparisonData. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line
type="monotone"
dataKey="scoreCompare"
name="scoreCompare"
stroke={COLOR_MAIN}
strokeWidth={1.25}
strokeDasharray="4 3"
strokeOpacity={0.45}
dot={false}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
legendType="none"
data-slot="chart-compare-line-mood"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
// v1.13.1 — mini mode: flat shell that exactly mirrors `<HealthChart
// mini>` (bg-card border rounded-md p-2 + plain `<div>` title), so the
// three trend cards share one title baseline and one height.
if (mini) {
return (
<div
data-slot="chart-mini"
className="bg-card border-border rounded-md border p-2"
>
<div className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase">
{displayTitle}
</div>
{chartBody}
</div>
);
}
return (
<Card>
<CardHeader className="pb-2">
{/* v1.4.19 A2 — mobile-first header: stack title row above
controls row on small viewports so the bucket / comparison
chips never push the range tabs into a 2nd line. ≥sm goes
back to side-by-side. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<CardTitle
className={
mini
? "text-muted-foreground text-[10px] font-medium tracking-wider uppercase"
: "text-base font-medium"
}
>
<CardTitle className="text-base font-medium">
{displayTitle}
</CardTitle>
{activeBucket !== "day" && !mini && (
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
@@ -628,389 +924,63 @@ export function MoodChart({
)}
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini &&
effectiveCompareBaseline !== "none" &&
hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{!mini &&
effectiveCompareBaseline !== "none" &&
!hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
</div>
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
preview, recommendation card) stay clean. */}
{chartKey ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
{!mini && (
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
preview, recommendation card) stay clean. */}
{chartKey ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
)}
</div>
</CardHeader>
<CardContent className={mini ? "px-2" : undefined}>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
) : chartData.length < 3 ? (
// v1.4.16 B1a — sparse-data placeholder consistent with the
// BP/weight/pulse charts. Height tracks the chart strip's
// shared CHART_HEIGHT_PX so the empty state preserves the
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
// other dashboard chart card).
//
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
// count. A user with 50 mood entries across 2 calendar days
// sees `chartData.length = 2` (daily-aggregated) but the raw
// count (sum of `samples`) is 50. Surface "need more days"
// in that scenario so the call-to-action matches reality.
rawCount >= 3 ? (
<ChartEmptyState
title={t("charts.needMoreDistinctDaysTitle")}
description={t("charts.needMoreDistinctDaysDescription")}
height={CHART_HEIGHT_PX}
/>
) : (
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
height={CHART_HEIGHT_PX}
/>
)
) : (
<div
// v1.12.7 — the mood line plots a 1–5 score band; the former
// 240/280 px full-mode height left a tall dead strip below the
// line on the mood subpage. Tighten the default to 200/220 px so
// the chart fits its content without the empty gap. A per-mount
// `--chart-height` override (trends-row mini, etc.) still wins, so
// only the unoverridden full-mode mount shrinks.
className={`${
mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
five mood-score ticks ([1,2,3,4,5]), which Recharts
syncs with CartesianGrid by default. That left the
mini variant painting five horizontal lines while
the BP / Weight / Pulse minis painted six (their
auto-generated YAxis ticks land on six bands in
typical health ranges). The explicit coordinates
generator below produces six evenly-spaced lines
so the trends row reads as a single rhythm. */}
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
horizontalCoordinatesGenerator={({ offset }) => {
// `offset` carries the chart's plot-area metrics.
// Six evenly-spaced y-coordinates inside the plot
// area (top + 4 intermediate + bottom) match the
// density of the BP/Weight/Pulse minis.
const top = offset.top ?? 0;
const height = offset.height ?? 0;
if (height <= 0) return [];
const lines = 6;
return Array.from({ length: lines }, (_, i) =>
Math.round(top + (height * i) / (lines - 1)),
);
}}
/>
{showBands &&
VALUE_BANDS.map((band) => (
<ReferenceArea
key={`band-${band.min}-${band.max}`}
y1={band.min}
y2={band.max}
fill={band.color}
fillOpacity={band.opacity}
strokeOpacity={0}
ifOverflow="discard"
/>
))}
{/* v1.4.18 — personal-baseline gated behind the
Trend toggle (maintainer: "only when a trend is being
displayed"). Default OFF. */}
{showTrend && personalBaseline != null && (
<ReferenceLine
y={personalBaseline}
stroke={COLOR_MAIN}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={{
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}}
/>
)}
<XAxis
type="number"
dataKey="pointIndex"
domain={[0, maxPointIndex]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
tzFmt.date(
new Date(
chartData[Math.round(value)]?.timestamp ?? Date.now(),
),
)
}
// v1.4.29 — Recharts ignores `interval` on numeric
// axes; pass explicit tick positions instead.
ticks={computeTickPositions(
chartData ?? [],
viewportWidth,
)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={65}
tickMargin={10}
tickFormatter={formatMoodTick}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
const {
active,
payload,
label: rechartsLabel,
} = props as unknown as {
active?: boolean;
payload?: Array<{
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: ChartDataPoint;
}>;
label?: number;
};
if (!active || !payload?.length) return null;
const ts =
payload[0]?.payload?.timestamp ??
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
const hoverPoint = payload[0]?.payload as
| ChartDataPoint
| undefined;
for (const item of payload) {
if (typeof item.value !== "number") continue;
const dataKey = String(item.dataKey ?? "");
// Skip auxiliary lines (`ma`, `trend`,
// `scoreCompare`). Comparison value rendered as
// delta on the primary row + own row below.
if (
dataKey === "ma" ||
dataKey === "trend" ||
dataKey === "scoreCompare"
)
continue;
let delta: string | undefined;
const compareValue =
effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare
: undefined;
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
const diff = item.value - compareValue;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(
1,
)}`;
delta = t(
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
}
} else if (personalBaseline != null) {
const diff = item.value - personalBaseline;
if (Math.abs(diff) < 0.05) {
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(
1,
)}`;
delta = t("charts.deltaVsBaseline").replace(
"{delta}",
formatted,
);
}
}
rows.push({
name: t("charts.moodScore"),
value: formatTooltipValue(item.value),
color: item.color ?? COLOR_MAIN,
delta,
});
if (
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
rows.push({
name: `${t("charts.moodScore")} · ${t(
"comparison.tooltipPrior",
)}`,
value: formatTooltipValue(compareValue),
color: item.color ?? COLOR_MAIN,
});
}
}
if (rows.length === 0) return null;
return (
<RichChartTooltip active label={dateLabel} rows={rows} />
);
}}
/>
<Line
type="monotone"
dataKey="score"
name="score"
stroke={COLOR_MAIN}
strokeWidth={2}
dot={{ r: 3, fill: COLOR_MAIN }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
{showMA && (
<Line
type="monotone"
dataKey="ma"
name="ma"
stroke={COLOR_MA}
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
connectNulls
/>
)}
{showTrend && (
<Line
type="linear"
dataKey="trend"
name="trend"
stroke={COLOR_TREND}
strokeWidth={1}
strokeDasharray="8 4"
dot={false}
connectNulls
/>
)}
{/* v1.4.16 B8 — comparison overlay (mood). Same
dimmed dashed treatment the BP/weight/pulse chart
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
data via hasComparisonData. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line
type="monotone"
dataKey="scoreCompare"
name="scoreCompare"
stroke={COLOR_MAIN}
strokeWidth={1.25}
strokeDasharray="4 3"
strokeOpacity={0.45}
dot={false}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
legendType="none"
data-slot="chart-compare-line-mood"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
<CardContent>{chartBody}</CardContent>
</Card>
);
}
@@ -240,72 +240,80 @@ export function CoachSettingsSheet({
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
{/* v1.13.1 — Tone + Verbosity side by side (2-col on ≥sm,
stacked on mobile) so the two short selects don't add a
full extra scroll length to the sheet. */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-tone"
className="text-xs font-medium"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
@@ -496,7 +504,10 @@ export function CoachSettingsSheet({
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("insights.coach.settingsSave")}
</Button>
+117 -102
View File
@@ -20,7 +20,7 @@ import {
Zap,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
@@ -38,7 +38,10 @@ import type {
* dedicated routed sub-page yet (hrv, resting_hr, steps, …) fall
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
const METRIC_HREF: Record<
DailyBriefingKeyFinding["sourceMetric"],
string | null
> = {
bp: "/insights/blood-pressure",
weight: "/insights/weight",
pulse: "/insights/pulse",
@@ -240,10 +243,10 @@ function BriefingSkeleton() {
aria-hidden="true"
>
<div className="space-y-2">
<div className="bg-muted/60 h-3 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded motion-reduce:animate-none" />
</div>
<div className="space-y-2">
{[0, 1, 2].map((i) => (
@@ -251,7 +254,7 @@ function BriefingSkeleton() {
key={i}
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
>
<div className="bg-muted/60 h-3 w-1/3 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none" />
</div>
))}
</div>
@@ -270,109 +273,121 @@ export function DailyBriefing({
const { t } = useTranslations();
return (
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardHeader className="pb-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"
/>
<CardTitle className="text-base font-semibold">
{t("insights.dailyBriefing.title")}
</CardTitle>
</div>
{metaSlot && (
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
// moves OUT of the card into an `<h2>` above it (mirroring the
// TrendsRow / wellness-strip section headers) so the card opens
// straight on its content and the surface reclaims the vertical
// space the in-card header used to occupy.
<section
data-slot="daily-briefing-section"
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>
</CardHeader>
<CardContent>
{loading ? (
<>
<span className="sr-only" aria-live="polite">
{t("insights.dailyBriefing.loadingLabel")}
</span>
<BriefingSkeleton />
</>
) : briefing ? (
<div className="space-y-4">
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
{metaSlot && (
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
</div>
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardContent>
{loading ? (
<>
<span className="sr-only" aria-live="polite">
{t("insights.dailyBriefing.loadingLabel")}
</span>
<BriefingSkeleton />
</>
) : briefing ? (
<div className="space-y-4">
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
card, so the user used to read the same string twice
within 200 px. The card now opens straight on the
structured key-findings list, which is the part the
hero subtitle cannot surface. */}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div data-slot="daily-briefing-findings" className="space-y-2">
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
finding={finding}
/>
))}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div
data-slot="daily-briefing-findings"
className="space-y-2"
>
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
finding={finding}
/>
))}
</div>
</div>
</div>
)}
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
</p>
)}
</div>
) : (
<EmptyState
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
title={t("insights.dailyBriefing.emptyTitle")}
description={t("insights.dailyBriefing.emptyDescription")}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
// `variant="outline"` to the default (filled) variant
// so the briefing card matches the dashboard
// empty-state CTA shape. Empty-state actions on a card
// surface read as the single primary affordance and
// earn the filled chip across surfaces in v1.4.28.
<Button
type="button"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
className="gap-1.5"
)}
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
) : null
}
/>
)}
</CardContent>
</Card>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
})}
</p>
)}
</div>
) : (
<EmptyState
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
title={t("insights.dailyBriefing.emptyTitle")}
description={t("insights.dailyBriefing.emptyDescription")}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
// `variant="outline"` to the default (filled) variant
// so the briefing card matches the dashboard
// empty-state CTA shape. Empty-state actions on a card
// surface read as the single primary affordance and
// earn the filled chip across surfaces in v1.4.28.
<Button
type="button"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-empty-cta"
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
) : null
}
/>
)}
</CardContent>
</Card>
</section>
);
}
@@ -118,9 +118,13 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-loading"
className="gap-2 py-4 md:gap-3 md:py-5"
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
// rhythm the populated / preparing / empty states use, so the
// heading and the first skeleton line sit on the same tight
// baseline across every assessment-card state.
className="gap-1.5 py-4 md:py-5"
>
<CardHeader className="pb-2">
<CardHeader className="pb-1">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
@@ -69,10 +69,7 @@ const MoodTimeOfDayChart = dynamic(
loading: () => <Skeleton className="h-[220px] w-full rounded-md" />,
},
);
import {
MoodTagBreakdown,
type MoodTagRow,
} from "./mood-tag-breakdown";
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
@@ -92,10 +89,7 @@ import {
MoodTagInfluence,
type MoodTagInfluenceRow,
} from "./mood-tag-influence";
import {
MoodBetterDays,
type MoodBetterDayFactor,
} from "./mood-better-days";
import { MoodBetterDays, type MoodBetterDayFactor } from "./mood-better-days";
import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
@@ -256,7 +250,11 @@ export function MoodInsightsSections({
if (region === "heatmap") {
return (
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmap cells={heatmapCells} days={data.heatmap.windowDays} stretch />
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
stretch
/>
</SectionCard>
);
}
@@ -273,9 +271,12 @@ export function MoodInsightsSections({
<Card
aria-live="polite"
data-slot="insight-assessment"
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
// rhythm the other assessment cards use, so the heading sits tight
// above its body instead of floating ~16-24 px above it.
className="animate-insight-in gap-1.5 py-4 md:py-5"
>
<CardHeader className="pb-2">
<CardHeader className="pb-1">
<TileHeader
icon={Sparkles}
title={t("insights.mood.betterDays.title")}
@@ -309,9 +310,7 @@ export function MoodInsightsSections({
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && (
<MoodStabilityTile stability={data.stability} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
@@ -343,10 +342,7 @@ export function MoodInsightsSections({
)}
{hasStructuredTags && (
<SectionCard
title={t("insights.mood.structuredTagsTitle")}
icon={Tags}
>
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
@@ -358,7 +354,10 @@ export function MoodInsightsSections({
)}
{hasInfluence && (
<SectionCard title={t("insights.mood.influence.title")} icon={TrendingUp}>
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
@@ -369,10 +368,7 @@ export function MoodInsightsSections({
</SectionCard>
)}
<SectionCard
title={t("insights.mood.correlationsTitle")}
icon={Link2}
>
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>