Give the food trend's figures, not only its rate

"Up about 40 g a week" is a rate with nothing to anchor it: it says the line
slopes without saying where it sits. The sentence now names both ends of the
fit — "roughly 280 g a day then, 400 g a day now" — which is the reading anyone
actually wants from a growth chart.

When the fit is not trustworthy it quotes the average instead, and says the
day-to-day variation is larger than any trend. That difference is the point.
The average is a measurement and survives the noise; the ends of the line are
the line's own output, and quoting them on a fit nobody should read would dress
a guess up as a reading. Everything rounds to 10 g for the same reason — "287 g
a day" would be false precision from four noisy points.

The wording moves into a pure foodTrendSentence() so those rules can be
checked, which is worth doing precisely because they are judgement rather than
arithmetic: the checks now pin that a clear climb gives both figures, a flat run
gives the average and no endpoints, a see-saw gives neither, and nothing is ever
quoted to the gram.
This commit is contained in:
Alexander Heldt
2026-09-21 20:43:50 +00:00
parent 668f1f039e
commit 7451650b6f
3 changed files with 67 additions and 22 deletions
+32
View File
@@ -5,6 +5,7 @@ import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
const app = load({ names: ["foodTrend"] });
const words = load({ names: ["foodTrendSentence"] });
// The 7/14/30 picker reaches the trend by deciding how many days weeklyData
// builds — there is no second mechanism, so this is the thing to hold still.
@@ -86,4 +87,35 @@ suite("marked days are skipped without shifting the line");
ok(t.clear, "…and it is still clear, not drowned by a false zero");
}
suite("what the sentence is allowed to say");
{
const say = (grams, opts) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), 14);
const rising = say([200, 250, 300, 350, 400, 450, 0]);
ok(/up about 350 g a week/.test(rising), "a clear climb gives the rate");
ok(/200 g a day then/.test(rising) && /450 g a day now/.test(rising),
"…and the figures at each end of the line, so it is not only a rate");
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
const steady = say([300, 302, 298, 301, 299, 300, 0]);
ok(/roughly steady/.test(steady), "a flat run is called steady");
ok(/averaging about 300 g a day/.test(steady),
"…and quotes the average, which is a measurement rather than model output");
ok(!/then/.test(steady) && !/ now\b/.test(steady),
"…but not fitted endpoints, which would dress up a line nobody should read");
const noisy = say([200, 500, 210, 480, 190, 520, 0]);
ok(/roughly steady/.test(noisy) && /variation is larger/.test(noisy),
"a see-saw says the variation beat the trend, rather than quoting a slope");
const none = say([300, 320, 310]);
ok(/Not enough complete days/.test(none) && /needs four/.test(none),
"too few days explains itself instead of leaving the chart bare");
// False precision would make a fit look like a reading.
ok(/\b\d*[05] g a day/.test(rising), "figures are rounded to 10 g, not quoted to the gram");
const falling = say([450, 400, 350, 300, 250, 200, 0]);
ok(/down about/.test(falling), "a clear fall says down");
}
export default report("food-trend");
+30 -17
View File
@@ -1713,31 +1713,44 @@
// 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`;
// What the line is allowed to claim, in words. Separated from the drawing
// because this is where the judgement lives: a straight line through noisy
// points always has a slope, and stating it as a fact is how a chart starts
// lying. Kept pure so the rules can be checked.
//
// Figures are rounded to 10 g. The fitted endpoints are model output, not
// measurements — quoting "287 g" would dress a guess up as a reading.
function foodTrendSentence(trend, windowDays) {
const window = `the last ${windowDays} 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 {
return `Not enough complete days in ${window} to draw a trend — it needs four, and today doesn't count until it's over.`;
}
const round10 = (v) => Math.round(Math.max(0, v) / 10) * 10;
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.
// 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.`);
// The average is a real measurement and survives the noise; the fitted
// endpoints would not, so they are not quoted here.
return `Over ${window}, daily intake is roughly steady, averaging about ` +
`${round10(trend.mean)} g a day — day-to-day variation is larger than any trend.`;
}
return `Over ${window}, daily intake is ${trend.perWeek > 0 ? "up" : "down"} about ` +
`${perWeek} g a week — roughly ${round10(trend.at(trend.first))} g a day then, ` +
`${round10(trend.at(trend.last))} g a day now.`;
}
function renderFoodTrendNote(days, trend) {
const note = document.getElementById("grams-note");
if (!note) return;
// 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 lines = [foodTrendSentence(trend, chartDays())];
const missing = days.reduce((s, d) => s + (d.mealsMissingGrams || 0), 0);
if (missing > 0) {
const meals = days.reduce((s, d) => s + d.meals, 0);
@@ -1745,7 +1758,7 @@
}
note.textContent = lines.join(" ");
note.hidden = lines.length === 0;
note.hidden = false; // there is always a sentence now, even if it is "no trend"
}
// Minutes walked per day. Hidden until there's a walk to show, like the
+1 -1
View File
@@ -1,5 +1,5 @@
[
{ "date": "2026-09-21", "text": "The Food (grams) chart has a trend line through it now, so you can see whether he is eating more as he grows — the daily bars bounce around enough to hide a steady climb. A line under the chart says what it amounts to: “daily intake is up about 40 g a week”, or that it is roughly steady when the day-to-day variation is bigger than any trend, which is most of the time over a short window. Today is left out of the line, since the day isn't finished and including it would drag the line down every morning; days marked “not counted” are skipped too. The line follows the 7 / 14 / 30 day picker like the rest of the charts, and the sentence names the window so you can see it change when you switch. If there aren't four complete days to fit it says so rather than leaving you with an empty chart, and if some meals have no amount recorded it says how many, because those days read lower than they really were" },
{ "date": "2026-09-21", "text": "The Food (grams) chart has a trend line through it now, so you can see whether he is eating more as he grows — the daily bars bounce around enough to hide a steady climb. A line under the chart says what it amounts to in figures: “daily intake is up about 40 g a week — roughly 280 g a day then, 400 g a day now”. When the day-to-day variation is bigger than any trend, which is most of the time over a short window, it says so and gives the average instead — that is a real measurement, where the ends of the line would only be the line's own guess. Today is left out of the line, since the day isn't finished and including it would drag the line down every morning; days marked “not counted” are skipped too. The line follows the 7 / 14 / 30 day picker like the rest of the charts, and the sentence names the window so you can see it change when you switch. If there aren't four complete days to fit it says so rather than leaving you with an empty chart, and if some meals have no amount recorded it says how many, because those days read lower than they really were" },
{ "date": "2026-09-20", "text": "Fixed the page being wider than the screen on a phone, which is why it had started letting you zoom out. The month grid behind the date was the main culprit: it was centred on the date button, which sits near the right edge, so part of the panel hung off the side of the screen. It is anchored to the edge of the bar now and stays on screen at any width. Also fixed a long unbroken word — a link, or something copied off a food bag — in a history note, an exercise name or its instructions pushing its row wider than the screen instead of wrapping" },
{ "date": "2026-09-20", "text": "Fixed three things that went wrong around a day marked “not counted”. The Timing panel measured “how long since the last pee” from before the marked day rather than from the actual last one, so the marker sat far out to the right. The Sleep and Walk trends drew the marked day's own curve as a flat zero when you were looking at that day — marking a day means don't let it drag the average, not pretend nothing happened on it. And a nap that started on a marked day and ended the next morning vanished from that next day's figures, even though the next day wasn't marked and the puppy really did sleep those hours" },
{ "date": "2026-09-09", "text": "The big asleep/awake card at the top of Today is gone, and both timers now live permanently in the frozen bar at the top — visible on every tab, wherever you have scrolled to. The card only existed on one tab and the timers hid themselves whenever it was on screen, which meant the thing you most often want at a glance was the thing you had to go and find. With only one place left to show them they have their seconds back too" },