fix(growth): show distinct points for same-week measurements

Use fractional week values (float) for actual data points instead of
floor'd integers. Two measurements on different days within the same
week now appear at distinct x positions. XAxis set to type="number"
with continuous domain so the chart renders them correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:11:08 +02:00
parent e27f42942c
commit cd677d9244
+21 -19
View File
@@ -55,47 +55,49 @@ function filterLogsByRange(logs: GrowthLog[], range: TimeRange): GrowthLog[] {
return logs.filter((l) => new Date(l.date) >= cutoff); return logs.filter((l) => new Date(l.date) >= cutoff);
} }
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
function ageWeeksFloat(birthDate: string, measureDate: string): number {
return (new Date(measureDate).getTime() - new Date(birthDate).getTime()) / MS_PER_WEEK;
}
function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) { function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) {
const actualPoints = logs const actualPoints = logs
.filter((l) => l.weight) .filter((l) => l.weight)
.map((l) => ({ .map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), weight: l.weight! / 1000 }));
ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)),
weight: l.weight! / 1000,
}));
if (actualPoints.length === 0) return []; if (actualPoints.length === 0) return [];
const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks)); const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks));
// minWeek: either earliest in filtered set, or 0 if showing all const globalMin = allLogs.filter((l) => l.weight).reduce((min, l) => {
const allPoints = allLogs.filter((l) => l.weight).map((l) => const w = ageWeeksFloat(baby.birthDate, l.date);
getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)) return w < min ? w : min;
); }, Infinity);
const globalMin = allPoints.length ? Math.min(...allPoints) : 0;
const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks)); const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks));
// Only clip if the range is meaningfully narrower than global
const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0; const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0;
const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES) const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES)
.map(Number) .map(Number)
.filter((a) => a >= minWeek && a <= maxWeek + 2) .filter((a) => a >= Math.floor(minWeek) && a <= Math.ceil(maxWeek) + 2)
.sort((a, b) => a - b); .sort((a, b) => a - b);
const mergedMap: Record<number, Record<string, number | null>> = {}; // Use string keys so float actual-point ages don't collide with integer WHO ages
const rows = new Map<number, Record<string, number | null>>();
for (const ageWeeks of whoAges) { for (const ageWeeks of whoAges) {
const row: Record<string, number | null> = { ageWeeks }; const row: Record<string, number | null> = { ageWeeks };
PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); }); PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); });
mergedMap[ageWeeks] = row; rows.set(ageWeeks, row);
} }
for (const pt of actualPoints) { for (const pt of actualPoints) {
mergedMap[pt.ageWeeks] = { ...(mergedMap[pt.ageWeeks] ?? { ageWeeks: pt.ageWeeks }), actualWeight: pt.weight }; const existing = rows.get(pt.ageWeeks) ?? { ageWeeks: pt.ageWeeks };
rows.set(pt.ageWeeks, { ...existing, actualWeight: pt.weight });
} }
return Object.values(mergedMap).sort((a, b) => (a.ageWeeks as number) - (b.ageWeeks as number)); return Array.from(rows.values()).sort((a, b) => (a.ageWeeks as number) - (b.ageWeeks as number));
} }
function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height" | "headCirc") { function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height" | "headCirc") {
return logs return logs
.filter((l) => l[field]) .filter((l) => l[field])
.map((l) => ({ ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)), value: l[field]! })) .map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), value: l[field]! }))
.sort((a, b) => a.ageWeeks - b.ageWeeks); .sort((a, b) => a.ageWeeks - b.ageWeeks);
} }
@@ -359,7 +361,7 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} /> <Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} />
{WHO_CURVE_KEYS.map((key, i) => ( {WHO_CURVE_KEYS.map((key, i) => (
@@ -379,7 +381,7 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} /> <Tooltip formatter={(val) => [`${val} cm`, "Taille"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} /> <Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} />
@@ -395,7 +397,7 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} /> <Tooltip formatter={(val) => [`${val} cm`, "PC"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} /> <Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} />