diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx index 6e7198e..fa0b07d 100644 --- a/src/components/health/HealthDashboard.tsx +++ b/src/components/health/HealthDashboard.tsx @@ -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(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 ( +
+ {/* Header */} +
+ + Verlauf + + + {trendLabel} + +
+ + + + + + + + {/* Clip to chart area */} + + + + + + {/* Horizontal grid lines + Y labels */} + {yTickVals.map((v, i) => { + const y = yOf(v); + return ( + + + + {v.toFixed(v < 1 ? 2 : 1)} + + + ); + })} + + {/* Area fill (clipped) */} + + + {/* Line */} + + + {/* Invisible hit areas + dots */} + {pts.map((p, i) => { + const isHovered = hoveredIdx === i; + return ( + + {/* Large invisible hit target */} + setHoveredIdx(i)} + onMouseLeave={() => setHoveredIdx(null)} + style={{ cursor: "pointer" }} + /> + {/* Outer glow ring on hover */} + {isHovered && ( + + )} + {/* Dot */} + + + ); + })} + + {/* 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 ( + + {label} + + ); + })} + + {/* 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 ( + + {/* Connector line */} + + {/* Box */} + + {/* Orange accent left bar */} + + {/* Weight value */} + + {weight} + + {/* Date */} + + {dateStr} + + + ); + })()} + +
+ ); +} + // ── Weight Tab ──────────────────────────────────────────────────────────────── function WeightTab({ petId }: { petId: string }) { @@ -134,6 +407,9 @@ function WeightTab({ petId }: { petId: string }) {

No weight entries yet

)} + {/* Chart — shown when 2+ entries exist */} + {!isLoading && data.length >= 2 && } + {data.length > 0 && (
{data.map((entry) => (