feat(health): add SVG weight trend chart

Renders a smooth bezier line chart above the weight log list when
2+ entries exist. No external library — pure SVG with:
- Orange-500 line + gradient area fill (PawFeed accent)
- Hover tooltip with dark bg, orange weight value, muted date
- Glow ring + dot state change on hover
- Bezier curve interpolation for smooth line
- Responsive via viewBox (maxHeight 190px)
- Y-axis: auto-scaled with 4 grid ticks
- X-axis: up to 5 date labels (DD.MM), always shows first + last
- Trend delta label (neutral, context-dependent for pets)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-15 05:49:29 +02:00
1 parent f1c798a4b0
commit b9f786c87f
1 file changed
+276
+276
View File
@@ -43,6 +43,279 @@ function isOverdue(date: Date | string): boolean {
return new Date(date) < new Date();
}
// ── Weight Chart ──────────────────────────────────────────────────────────────
type ChartEntry = { date: Date | string; weightGrams: number };
function WeightChart({ data }: { data: ChartEntry[] }) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
// Sort oldest → newest for left-to-right reading
const sorted = [...data]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const values = sorted.map((e) => e.weightGrams / 1000);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rawRange = maxV - minV;
const pad = rawRange === 0 ? 0.5 : rawRange * 0.18;
const yMin = minV - pad;
const yMax = maxV + pad;
const yRange = yMax - yMin;
// SVG canvas
const W = 500, H = 190;
const L = 46, R = 12, T = 14, B = 38;
const cW = W - L - R;
const cH = H - T - B;
const xOf = (i: number) =>
sorted.length === 1 ? L + cW / 2 : L + (i / (sorted.length - 1)) * cW;
const yOf = (v: number) => T + cH - ((v - yMin) / yRange) * cH;
const pts = sorted.map((e, i) => ({ x: xOf(i), y: yOf(e.weightGrams / 1000), e }));
// Smooth bezier path between points
const linePath = pts
.map((p, i) => {
if (i === 0) return `M ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
const areaPath =
`M ${pts[0].x} ${T + cH} ` +
pts
.map((p, i) => {
if (i === 0) return `L ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ") +
` L ${pts[pts.length - 1].x} ${T + cH} Z`;
// Y-axis ticks (4 evenly spaced)
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) =>
yMin + (yRange * i) / yTicks
);
// X-axis labels: at most 5, always show first + last
const maxLabels = 5;
const step = Math.max(1, Math.ceil(sorted.length / maxLabels));
const xLabelIndices = new Set(
sorted
.map((_, i) => i)
.filter((i) => i % step === 0 || i === sorted.length - 1)
);
// Trend label
const delta = values[values.length - 1] - values[0];
const trendSign = delta > 0 ? "+" : "";
const trendLabel = `${trendSign}${delta.toFixed(2)} kg`;
const trendColor = "#9ca3af"; // neutral gray — weight change is context-dependent
return (
<div className="rounded-xl border bg-muted/20 p-4">
{/* Header */}
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Verlauf
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{trendLabel}
</span>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ maxHeight: 190 }}
role="img"
aria-label="Weight history chart"
>
<defs>
<linearGradient id="pawfeed-wg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity="0.28" />
<stop offset="100%" stopColor="#f97316" stopOpacity="0" />
</linearGradient>
{/* Clip to chart area */}
<clipPath id="pawfeed-clip">
<rect x={L} y={T} width={cW} height={cH} />
</clipPath>
</defs>
{/* Horizontal grid lines + Y labels */}
{yTickVals.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={L} y1={y} x2={W - R} y2={y}
stroke="currentColor"
strokeOpacity={i === 0 ? 0.12 : 0.06}
strokeWidth="1"
strokeDasharray={i === 0 ? "0" : "3 3"}
/>
<text
x={L - 5}
y={y}
textAnchor="end"
dominantBaseline="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{v.toFixed(v < 1 ? 2 : 1)}
</text>
</g>
);
})}
{/* Area fill (clipped) */}
<path
d={areaPath}
fill="url(#pawfeed-wg)"
clipPath="url(#pawfeed-clip)"
/>
{/* Line */}
<path
d={linePath}
fill="none"
stroke="#f97316"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
clipPath="url(#pawfeed-clip)"
/>
{/* Invisible hit areas + dots */}
{pts.map((p, i) => {
const isHovered = hoveredIdx === i;
return (
<g key={i}>
{/* Large invisible hit target */}
<circle
cx={p.x}
cy={p.y}
r={16}
fill="transparent"
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
style={{ cursor: "pointer" }}
/>
{/* Outer glow ring on hover */}
{isHovered && (
<circle cx={p.x} cy={p.y} r={8} fill="#f97316" fillOpacity="0.15" />
)}
{/* Dot */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 5 : 3.5}
fill={isHovered ? "#f97316" : "white"}
stroke="#f97316"
strokeWidth={isHovered ? 0 : 2}
style={{ transition: "r 0.12s ease, fill 0.12s ease" }}
pointerEvents="none"
/>
</g>
);
})}
{/* X-axis labels */}
{sorted.map((e, i) => {
if (!xLabelIndices.has(i)) return null;
const d = new Date(e.date);
const label = `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
return (
<text
key={i}
x={xOf(i)}
y={H - 8}
textAnchor="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{label}
</text>
);
})}
{/* Tooltip */}
{hoveredIdx !== null && (() => {
const p = pts[hoveredIdx];
const weight = formatWeight(p.e.weightGrams);
const dateStr = formatDate(p.e.date);
const tw = 96, th = 40;
let tx = p.x - tw / 2;
tx = Math.max(L, Math.min(tx, W - R - tw));
let ty = p.y - th - 12;
if (ty < T) ty = p.y + 14;
return (
<g pointerEvents="none">
{/* Connector line */}
<line
x1={p.x}
y1={ty + th}
x2={p.x}
y2={p.y - 6}
stroke="#f97316"
strokeOpacity="0.3"
strokeWidth="1"
strokeDasharray="2 2"
/>
{/* Box */}
<rect
x={tx} y={ty}
width={tw} height={th}
rx="7"
fill="#18181b"
fillOpacity="0.88"
/>
{/* Orange accent left bar */}
<rect
x={tx} y={ty}
width={3} height={th}
rx="7"
fill="#f97316"
/>
{/* Weight value */}
<text
x={tx + tw / 2 + 1}
y={ty + 15}
textAnchor="middle"
fontSize="12"
fontWeight="700"
fill="#f97316"
>
{weight}
</text>
{/* Date */}
<text
x={tx + tw / 2 + 1}
y={ty + 29}
textAnchor="middle"
fontSize="9.5"
fill="white"
fillOpacity="0.55"
>
{dateStr}
</text>
</g>
);
})()}
</svg>
</div>
);
}
// ── Weight Tab ────────────────────────────────────────────────────────────────
function WeightTab({ petId }: { petId: string }) {
@@ -134,6 +407,9 @@ function WeightTab({ petId }: { petId: string }) {
<p className="text-sm text-muted-foreground text-center py-8">No weight entries yet</p>
)}
{/* Chart — shown when 2+ entries exist */}
{!isLoading && data.length >= 2 && <WeightChart data={data} />}
{data.length > 0 && (
<div className="space-y-1">
{data.map((entry) => (