From 7db9608c4cc2e4fe12b03213b6acfcec35ccb4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 23:52:21 +0200 Subject: [PATCH] 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 `

` 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) --- src/components/charts/mood-chart.tsx | 816 +++++++++--------- .../coach-panel/coach-settings-sheet.tsx | 139 +-- src/components/insights/daily-briefing.tsx | 219 ++--- .../insights/insight-status-card.tsx | 8 +- .../insights/mood/mood-insights-sections.tsx | 42 +- 5 files changed, 610 insertions(+), 614 deletions(-) diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 904b2d607..50605a899 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -358,7 +358,8 @@ export function MoodChart({ const rawCount = useMemo(() => { 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 ( - ` paints `py-6 gap-6` — - // ~48 px of vertical chrome that pulled the mood mini chart - // band ~52 px lower than the `` 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 `
` 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 `` uses (`bg-card border-border rounded-md border p-2` with a + // plain `
` title), instead of the `// + // ` 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 ? ( +
+ +
+ ) : !chartData?.length ? ( +
+ {t("charts.noData")} +
+ ) : 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 ? ( + + ) : ( + + ) + ) : ( +
- + + {/* 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. */} + { + // `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) => ( + + ))} + {/* v1.4.18 — personal-baseline gated behind the + Trend toggle (maintainer: "only when a trend is being + displayed"). Default OFF. */} + {showTrend && personalBaseline != null && ( + + )} + + 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} + /> + + { + 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 ; + }} + /> + + {showMA && ( + + )} + {showTrend && ( + + )} + {/* 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 && ( + + )} + + +
+ ); + + // v1.13.1 — mini mode: flat shell that exactly mirrors `` (bg-card border rounded-md p-2 + plain `
` title), so the + // three trend cards share one title baseline and one height. + if (mini) { + return ( +
+
+ {displayTitle} +
+ {chartBody} +
+ ); + } + + return ( + + {/* 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. */}
- + {displayTitle} - {activeBucket !== "day" && !mini && ( + {activeBucket !== "day" && ( {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 && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.captionLastMonth" - : "comparison.captionLastYear", - )} - - )} - {!mini && - effectiveCompareBaseline !== "none" && - !hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.unavailable.lastMonth" - : "comparison.unavailable.lastYear", - )} - - )} + {effectiveCompareBaseline !== "none" && hasComparisonData && ( + + {t( + effectiveCompareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )} + + )} + {effectiveCompareBaseline !== "none" && !hasComparisonData && ( + + {t( + effectiveCompareBaseline === "lastMonth" + ? "comparison.unavailable.lastMonth" + : "comparison.unavailable.lastYear", + )} + + )} +
+
+ {TIME_RANGES_KEYS.map((r) => ( + + ))} + {/* 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 ? ( + + ) : null}
- {!mini && ( -
- {TIME_RANGES_KEYS.map((r) => ( - - ))} - {/* 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 ? ( - - ) : null} -
- )}
- - {isLoading ? ( -
- -
- ) : !chartData?.length ? ( -
- {t("charts.noData")} -
- ) : 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 ? ( - - ) : ( - - ) - ) : ( -
- - - {/* 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. */} - { - // `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) => ( - - ))} - {/* v1.4.18 — personal-baseline gated behind the - Trend toggle (maintainer: "only when a trend is being - displayed"). Default OFF. */} - {showTrend && personalBaseline != null && ( - - )} - - 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} - /> - - { - 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 ( - - ); - }} - /> - - {showMA && ( - - )} - {showTrend && ( - - )} - {/* 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 && ( - - )} - - -
- )} -
+ {chartBody}
); } diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index 909304064..180612100 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -240,72 +240,80 @@ export function CoachSettingsSheet({
) : (
- {/* Tone */} -
- - -
+ {t("insights.coach.settingsToneLabel")} + + +
- {/* Verbosity */} -
- - + {t("insights.coach.settingsVerbosityLabel")} + + +
{/* v1.4.25 W5 — default analysis window. Sets the snapshot @@ -496,7 +504,10 @@ export function CoachSettingsSheet({ data-slot="coach-prefs-save" > {save.isPending ? ( -