Draw a trend line through the food bars

The daily grams bars bounce around enough to hide a steady climb, so they
cannot answer the question you actually have about a growing puppy: is he
eating more than he was? A least-squares fit through them can.

Two kinds of day stay out of the fit. Today is half-eaten, and including it
would pull the line down every morning and let it drift back up as meals go in
— a line that tracks the clock rather than the dog. A day marked "not counted"
has a hatch rather than a figure, and fitting a zero there would invent a dip.
The line is drawn only across the days it was fitted on, so it never implies it
knows about the ones it skipped.

The caption is the part that needed the care. A straight line through seven
noisy points will always have a slope, and announcing it as a fact is the same
mistake the walking goal made. So a direction is named only when the fitted
climb is larger than the scatter of the days around it, and only when it clears
5 g a week and a twentieth of a typical day; otherwise it says the variation is
larger than any trend, which over a short window is usually the truth.

It names its window too — "over the last 14 days" — because the 7/14/30 picker
already drove this (weeklyData builds the array the fit runs on) but nothing on
screen said so, and the line moves too little between windows to show it. When
there are fewer than four complete days it now says why there is no line rather
than leaving bars with nothing through them.

Meals can be logged without an amount, so the note counts them: a day can read
low because he ate little or because nobody typed the number, and the chart
should not let those look the same.

The checks cover the refusals rather than the arithmetic — a see-saw is not
reported as a trend, a slope under the scatter is not either, three days will
not fit, a marked day does not shift the line, and each window length reaches
the fit intact.
This commit is contained in:
Alexander Heldt
2026-09-21 20:16:10 +00:00
parent 14cad44d9b
commit 668f1f039e
5 changed files with 208 additions and 1 deletions
+103
View File
@@ -1380,6 +1380,11 @@
grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0),
// The amount is optional on a meal, so a low day can mean "ate little"
// or "didn't type the number". The food trend reports this rather than
// leaving the reader to assume the first.
mealsMissingGrams: dayEvents
.filter(e => e.type === "eat" && !(Number.isFinite(e.grams) && e.grams > 0)).length,
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
});
}
@@ -1589,6 +1594,53 @@
// Grams of food per day. Hidden entirely until any meal in the window has an
// amount logged, so the weekly card doesn't grow an empty chart.
// A straight least-squares fit through the daily totals, to answer "is he
// eating more as he grows?" — which the bars alone don't, because day-to-day
// variation is large enough to hide a steady climb.
//
// Two days are left out of the fit. A day marked "not counted" has no figure
// to fit (its bar is a hatch, not a zero). And today is still in progress, so
// including it would drag the line down every morning and let it drift back
// up over the day — a moving line that reflects the clock rather than the
// puppy. The line is drawn only across the days it was fitted on, so it never
// implies it knows about the ones it skipped.
function foodTrend(days) {
const pts = [];
days.forEach((d, i) => {
if (d.excluded) return;
if (i === days.length - 1) return; // today, still being eaten
pts.push({ x: i, y: d.grams });
});
// Two points always fit a line perfectly and say nothing; four is the least
// that can show a direction rather than a coincidence.
if (pts.length < 4) return null;
const n = pts.length;
const mx = pts.reduce((s, p) => s + p.x, 0) / n;
const my = pts.reduce((s, p) => s + p.y, 0) / n;
const sxx = pts.reduce((s, p) => s + (p.x - mx) ** 2, 0);
if (sxx === 0) return null;
const slope = pts.reduce((s, p) => s + (p.x - mx) * (p.y - my), 0) / sxx;
const intercept = my - slope * mx;
// How far the fitted line climbs across the days it covers, against how far
// the days themselves scatter around it. Claiming a direction when the
// scatter is the larger of the two would be reading noise as a story.
const first = pts[0].x, last = pts[n - 1].x;
const rise = Math.abs(slope * (last - first));
const residualSD = n > 2
? Math.sqrt(pts.reduce((s, p) => s + (p.y - (slope * p.x + intercept)) ** 2, 0) / (n - 2))
: Infinity;
return {
at: (i) => slope * i + intercept,
first, last,
perWeek: slope * 7, // change in a day's intake from one week to the next
mean: my,
clear: rise > residualSD, // the climb outruns the scatter
};
}
function drawGramsChart(days) {
const wrap = document.getElementById("grams-chart-wrap");
const svg = document.getElementById("chart-grams");
@@ -1642,9 +1694,60 @@
}
});
// The trend goes on top of the bars, and only across the days it was fitted
// on. Clamped to the plot area so a steep fit can't draw outside the axes.
const trend = foodTrend(days);
if (trend) {
const cx = (i) => ML + i * (barW + gap) + barW / 2;
const cy = (g) => MT + innerH * (1 - Math.min(Math.max(g, 0), yMax) / yMax);
parts.push(
`<line class="food-trend" x1="${cx(trend.first).toFixed(1)}" y1="${cy(trend.at(trend.first)).toFixed(1)}" ` +
`x2="${cx(trend.last).toFixed(1)}" y2="${cy(trend.at(trend.last)).toFixed(1)}"/>`
);
}
renderFoodTrendNote(days, trend);
setChartSVG(svg, parts);
}
// Says what the line means, and what it cannot mean. Kept in words under the
// chart rather than as a figure on it: "up 40 g a week" is a claim, and it
// needs the room to be qualified.
function renderFoodTrendNote(days, trend) {
const note = document.getElementById("grams-note");
if (!note) return;
const lines = [];
// The window comes from the 7/14/30 picker, and naming it is the only way
// the reader can tell that switching it changed the answer — the line
// itself often moves too little to notice.
const window = `the last ${chartDays()} days`;
if (!trend) {
// Says why there is no line. Without this the chart looks broken on a
// short window, or on one where most days are marked.
lines.push(`Not enough complete days in ${window} to draw a trend — it needs four, and today doesn't count until it's over.`);
} else {
const perWeek = Math.round(Math.abs(trend.perWeek));
// Under a twentieth of a typical day, a week apart, is not a trend
// anyone could act on, whatever the arithmetic says.
const slight = perWeek < 5 || perWeek < trend.mean * 0.05;
if (!trend.clear || slight) {
lines.push(`Over ${window}, daily intake is roughly steady — day-to-day variation is larger than any trend.`);
} else {
lines.push(`Over ${window}, daily intake is ${trend.perWeek > 0 ? "up" : "down"} about ${perWeek} g a week.`);
}
}
const missing = days.reduce((s, d) => s + (d.mealsMissingGrams || 0), 0);
if (missing > 0) {
const meals = days.reduce((s, d) => s + d.meals, 0);
lines.push(`${missing} of ${meals} meals here have no amount recorded, so those days read lower than they were.`);
}
note.textContent = lines.join(" ");
note.hidden = lines.length === 0;
}
// Minutes walked per day. Hidden until there's a walk to show, like the
// grams chart — no point in an empty panel for someone who doesn't log walks.
function drawWalkChart(days) {