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:
@@ -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");
|
||||||
+103
@@ -1380,6 +1380,11 @@
|
|||||||
grams: dayEvents
|
grams: dayEvents
|
||||||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||||
.reduce((s, e) => s + e.grams, 0),
|
.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,
|
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
|
// 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.
|
// 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) {
|
function drawGramsChart(days) {
|
||||||
const wrap = document.getElementById("grams-chart-wrap");
|
const wrap = document.getElementById("grams-chart-wrap");
|
||||||
const svg = document.getElementById("chart-grams");
|
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);
|
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
|
// 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.
|
// grams chart — no point in an empty panel for someone who doesn't log walks.
|
||||||
function drawWalkChart(days) {
|
function drawWalkChart(days) {
|
||||||
|
|||||||
@@ -1,4 +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-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 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-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" },
|
{ "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" },
|
||||||
|
|||||||
+4
-1
@@ -397,7 +397,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart" id="grams-chart-wrap" hidden>
|
<div class="chart" id="grams-chart-wrap" hidden>
|
||||||
<div class="chart-title">Food (grams)</div>
|
<div class="chart-title">Food (grams)</div>
|
||||||
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
|
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day, with a trend line through them"></svg>
|
||||||
|
<!-- What the trend line says, and when it is not saying anything —
|
||||||
|
see renderFoodTrendNote. -->
|
||||||
|
<p id="grams-note" class="muted-note" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<div class="chart-title">By hour of day</div>
|
<div class="chart-title">By hour of day</div>
|
||||||
|
|||||||
@@ -1399,6 +1399,17 @@ input.switch:checked::after { transform: translateX(18px); }
|
|||||||
.chart-svg .now-rule { stroke: var(--text); stroke-width: 1; opacity: 0.75; pointer-events: none; }
|
.chart-svg .now-rule { stroke: var(--text); stroke-width: 1; opacity: 0.75; pointer-events: none; }
|
||||||
.chart-svg .now-rule-cap { fill: var(--text); opacity: 0.75; pointer-events: none; }
|
.chart-svg .now-rule-cap { fill: var(--text); opacity: 0.75; pointer-events: none; }
|
||||||
|
|
||||||
|
/* The fit through the food bars. Dashed and in the weight colour rather than
|
||||||
|
the food one: it is a reading of the bars, not another bar, and the same
|
||||||
|
teal carries the other charts' "this is a derived line" (see .trend-avg). */
|
||||||
|
.chart-svg .food-trend {
|
||||||
|
stroke: var(--weight);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
|
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
|
||||||
.chart-svg .trend-today {
|
.chart-svg .trend-today {
|
||||||
stroke: var(--sleep);
|
stroke: var(--sleep);
|
||||||
|
|||||||
Reference in New Issue
Block a user