mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
ba17b08a01
commit
7db9608c4c
5 files changed
+610
-614
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user