diff --git a/checks/food-trend.mjs b/checks/food-trend.mjs
new file mode 100644
index 0000000..10373d8
--- /dev/null
+++ b/checks/food-trend.mjs
@@ -0,0 +1,89 @@
+// The fit through the Food (grams) bars. The arithmetic is easy to get subtly
+// wrong and the result is a sentence stating a fact about the puppy, so the
+// cases that matter are the ones where it should decline to say anything.
+import { load } from "./extract.mjs";
+import { suite, eq, ok, report } from "./assert.mjs";
+
+const app = load({ names: ["foodTrend"] });
+
+// 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.
+let windowDays = 7;
+const weekly = load({
+ names: ["startOfDay", "endOfDay", "ymd", "weeklyData"],
+ stubs: {
+ chartDays: () => windowDays,
+ isExcluded: () => false,
+ eventsForDay: () => [],
+ sleepMsInRange: () => 0,
+ walkMsInRange: () => 0,
+ },
+});
+
+suite("the day picker is what sets the trend's window");
+for (const n of [7, 14, 30]) {
+ windowDays = n;
+ eq(weekly.weeklyData([]).length, n, `picking ${n}d gives the charts ${n} days to fit over`);
+}
+windowDays = 7;
+
+// weeklyData's shape, as far as foodTrend reads it. Today is last, as there.
+const days = (grams, { excluded = [] } = {}) =>
+ grams.map((g, i) => ({ grams: g, excluded: excluded.includes(i) }));
+
+suite("it declines to fit when there is nothing to fit");
+{
+ eq(app.foodTrend(days([300, 320, 310])), null,
+ "three days is too few — today is dropped, leaving two, and two always fit perfectly");
+ eq(app.foodTrend(days([300, 320, 310, 330, 340], { excluded: [0, 1] })), null,
+ "marked days don't count toward the four either");
+ eq(app.foodTrend(days([])), null, "an empty window fits nothing");
+}
+
+suite("today is left out, being half-eaten");
+{
+ // Four steady days then a partial today. Including today would tip the line
+ // down; the fit should not see it at all.
+ const t = app.foodTrend(days([400, 400, 400, 400, 50]));
+ ok(t, "four complete days are enough");
+ eq(Math.round(t.perWeek), 0, "a flat run stays flat despite today being low");
+ eq(t.last, 3, "the line stops at the last complete day, not at today");
+}
+
+suite("it reports a direction only when the climb beats the scatter");
+{
+ const rising = app.foodTrend(days([200, 250, 300, 350, 400, 450, 0]));
+ ok(rising.clear, "a clean climb is reported");
+ eq(Math.round(rising.perWeek), 350, "…at 50 g a day, which is 350 g a week");
+
+ const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
+ ok(falling.clear, "a clean fall is reported");
+ ok(falling.perWeek < 0, "…with a negative weekly change");
+
+ // Same mean, no direction, plenty of noise: the honest answer is "steady".
+ const noisy = app.foodTrend(days([200, 500, 210, 480, 190, 520, 0]));
+ ok(!noisy.clear, "a see-saw is not a trend, however the slope comes out");
+
+ // A gentle real climb buried in large day-to-day swings: also not claimable.
+ const buried = app.foodTrend(days([300, 520, 180, 540, 200, 560, 0]));
+ ok(!buried.clear, "a slope smaller than the scatter is not reported as a trend");
+}
+
+suite("the fitted line passes through the data");
+{
+ const t = app.foodTrend(days([100, 200, 300, 400, 500, 0]));
+ eq(Math.round(t.at(0)), 100, "it starts where the first day sits");
+ eq(Math.round(t.at(4)), 500, "and ends where the last complete day sits");
+ eq(Math.round(t.mean), 300, "the mean is the mean of the days it fitted");
+}
+
+suite("marked days are skipped without shifting the line");
+{
+ // The middle day is marked; the rest describe a clean 50 g/day climb. The fit
+ // must ignore the hatch rather than reading it as a day of zero grams.
+ const t = app.foodTrend(days([200, 250, 0, 350, 400, 450, 0], { excluded: [2] }));
+ eq(Math.round(t.perWeek), 350, "the climb is unchanged by the marked day");
+ ok(t.clear, "…and it is still clear, not drowned by a false zero");
+}
+
+export default report("food-trend");
diff --git a/src/app.js b/src/app.js
index 0afd30d..ee8c1ee 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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(
+ `