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:
1 parent
f1c798a4b0
commit
b9f786c87f
1 file changed
+276
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user