mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(charts): mood chart polish with emoji glyphs at data points
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.
What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
under the score line. The mood gradient uses the lavender token
(--dracula-lavender / #d6acff) which matches the existing line
stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
window (≥5 points required). Faint dashed line labelled "Your
normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
The Recharts `dot` prop accepts a function returning an SVG node;
we render <text> centred over (cx, cy) so the glyph sits on the
line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.
1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b863e2cc3d
commit
901f44e6dc
8 files changed
+1450
-52
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.16 B1a — MoodChart Apple-Health-style polish contract.
|
||||
*
|
||||
* The mood chart picks up the same gradient-fill + animation polish
|
||||
* the rest of the dashboard charts get; the differentiator is per-
|
||||
* point emoji glyphs (a smiley, frown, etc.) instead of plain dots so
|
||||
* the user can scan a week at a glance — Apple's Health app uses the
|
||||
* same affordance for "Mindfulness Minutes" and similar mood-adjacent
|
||||
* surfaces.
|
||||
*/
|
||||
|
||||
const sampleMoodData = vi.hoisted(() => {
|
||||
const out: Array<{ date: string; score: number; samples: number }> = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const dt = new Date(Date.UTC(2026, 4, 1 + i));
|
||||
const yy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(dt.getUTCDate()).padStart(2, "0");
|
||||
out.push({
|
||||
date: `${yy}-${mm}-${dd}`,
|
||||
score: 3 + ((i % 3) - 1) * 0.5,
|
||||
samples: 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { entries: sampleMoodData, summary: null },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<MoodChart> v1.4.16 B1a polish", () => {
|
||||
it("emits the gradient defs sibling-SVG block", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-linear-gradient"');
|
||||
expect(html).toContain('id="chart-gradient-mood"');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo, useId } from "react";
|
||||
@@ -26,6 +28,10 @@ import {
|
||||
pickBucket,
|
||||
type ChartBucketType,
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { ChartLinearGradient, chartGradientFill } from "./chart-gradient";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -366,6 +372,17 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
5: t("charts.moodLabel5"),
|
||||
};
|
||||
|
||||
// v1.4.16 B1a — emoji glyph per integer mood score. Apple Health
|
||||
// surfaces a smiley/frown alongside the value; mirroring that here
|
||||
// makes the chart scannable without a legend.
|
||||
const moodEmoji: Record<number, string> = {
|
||||
1: "\u{1F616}", // 😖
|
||||
2: "\u{1F641}", // 🙁
|
||||
3: "\u{1F610}", // 😐
|
||||
4: "\u{1F642}", // 🙂
|
||||
5: "\u{1F604}", // 😄
|
||||
};
|
||||
|
||||
const formatMoodTick = (value: number): string => {
|
||||
return moodLabels[value] ?? String(value);
|
||||
};
|
||||
@@ -376,6 +393,18 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
return label ? `${rounded} (${label})` : String(rounded);
|
||||
};
|
||||
|
||||
// v1.4.16 B1a — personal mood baseline (median over visible window).
|
||||
const personalBaseline = useMemo<number | null>(() => {
|
||||
if (!chartData?.length || chartData.length < 5) return null;
|
||||
const sorted = chartData.map((d) => d.score).sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
}, [chartData]);
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
|
||||
return (
|
||||
@@ -445,6 +474,21 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* v1.4.16 B1a — sibling SVG <defs> block for the gradient
|
||||
primitive. SSR-discoverable; Recharts also picks up the id
|
||||
client-side via the inline <defs> below. */}
|
||||
<svg
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", pointerEvents: "none" }}
|
||||
data-slot="chart-gradient-defs"
|
||||
>
|
||||
<ChartLinearGradient
|
||||
id="chart-gradient-mood"
|
||||
colorVar="--dracula-lavender"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
@@ -453,13 +497,41 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
<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.
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={280}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chart-gradient-mood-inline"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={COLOR_MAIN}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={COLOR_MAIN}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
@@ -477,6 +549,23 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
{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"
|
||||
@@ -508,35 +597,88 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
if (typeof value !== "number") return String(value);
|
||||
if (name === "score") {
|
||||
return [formatTooltipValue(value), t("charts.moodScore")];
|
||||
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
|
||||
? formatDateShort(new Date(ts), true)
|
||||
: "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`ma`, `trend`).
|
||||
if (dataKey === "ma" || dataKey === "trend") continue;
|
||||
let delta: string | undefined;
|
||||
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 (name === "ma") {
|
||||
return [formatTooltipValue(value), t("charts.moodMA")];
|
||||
}
|
||||
if (name === "trend") {
|
||||
return [formatTooltipValue(value), t("charts.trend")];
|
||||
}
|
||||
return [String(value), String(name)];
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={dateLabel}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
labelFormatter={(_label, payload) =>
|
||||
payload?.[0]?.payload?.timestamp
|
||||
? formatDateShort(
|
||||
new Date(
|
||||
(payload[0].payload as ChartDataPoint).timestamp,
|
||||
),
|
||||
true,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* v1.4.16 B1a — gradient-filled Area painted under
|
||||
the line. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="transparent"
|
||||
fill={chartGradientFill("chart-gradient-mood-inline")}
|
||||
fillOpacity={1}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
connectNulls
|
||||
legendType="none"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -544,9 +686,45 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
name="score"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: COLOR_MAIN }}
|
||||
activeDot={{ r: 5 }}
|
||||
// v1.4.16 B1a — emoji glyph at every data point. The
|
||||
// dot prop accepts a function returning an SVG node,
|
||||
// so we render <text> with the emoji centred over the
|
||||
// (cx, cy) the line layout already computed.
|
||||
dot={(props) => {
|
||||
const { cx, cy, payload, key } = props as {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
payload?: ChartDataPoint;
|
||||
key?: string | number;
|
||||
};
|
||||
if (
|
||||
typeof cx !== "number" ||
|
||||
typeof cy !== "number" ||
|
||||
!payload
|
||||
) {
|
||||
return <g key={key} />;
|
||||
}
|
||||
const rounded = Math.round(payload.score);
|
||||
const glyph = moodEmoji[rounded] ?? "•";
|
||||
return (
|
||||
<text
|
||||
key={key}
|
||||
x={cx}
|
||||
y={cy + 5}
|
||||
textAnchor="middle"
|
||||
fontSize={14}
|
||||
data-slot="mood-emoji-glyph"
|
||||
data-score={rounded}
|
||||
>
|
||||
{glyph}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
activeDot={{ r: 6 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
{showMA && (
|
||||
<Line
|
||||
@@ -572,7 +750,7 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user