Files
puppy-tracker/checks/food-trend.mjs
T
Alexander Heldt 8c9ea45410 Spell out the highlighted day under the food chart
A stacked bar cannot be read on a phone. There is nothing to hover, so the
bar's title is unreachable, and the only way to get a day's figure was to
estimate it against the axis — which a split bar makes harder, not easier.

Tapping a bar now writes that day out beneath the chart: "Sun, Sep 20 — Dry
260 g · Fresh 100 g · 360 g in total", or just the total where no kinds are in
play. The same problem exists without kinds, so it is not gated on them; the
unsplit form says the number once rather than "No kind 340 g · 340 g in total".

It reads off the day already selected rather than keeping a selection of its
own. Tapping a bar selects that day on every chart in the app and this one
already highlights it, so a second piece of "which day" state would only be
something to keep in step and eventually fail to. It falls out of that choice
that the arrows and the date picker move the readout too, which is the
behaviour you would want anyway.

Three cases say something rather than reading as blank: a day with no food, a
day marked not counted, and a day outside the window — which has no bar, so no
readout.

The changelog entry for this sits on its own rather than inside the food-kinds
one. It started life gated on kinds and is not any more, and "once you are
using kinds" would have been the wrong condition to file it under.
2026-09-22 10:47:58 +00:00

336 lines
14 KiB
JavaScript

// 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"] });
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.
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.change), 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.change), 250, "…as the move across the five days it fitted, 200 g to 450 g");
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
ok(falling.clear, "a clean fall is reported");
ok(falling.change < 0, "…with a negative 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.change), 250, "the climb is unchanged by the marked day");
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, win = 14) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), win);
const rising = say([200, 250, 300, 350, 400, 450, 0]);
ok(/up about 250 g/.test(rising), "a clear climb gives the size of the move");
ok(/from roughly 200 g a day to 450 g/.test(rising), "…and the figures at each end");
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
// The defect this replaced: the move was quoted per week while the fit spans
// at most five days on a 7-day window, so the figure and the two endpoints
// disagreed and a reader who subtracted them found the sentence wrong.
// Whatever the window, the three numbers in the sentence must reconcile.
for (const [label, grams, win] of [
["a steep 7-day fall", [460, 425, 390, 355, 320, 285, 0], 7],
["a long 14-day climb", [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 0], 14],
["a gentle 30-day climb", [...Array(29).fill(0).map((_, i) => 300 + i * 12), 0], 30],
]) {
const s = say(grams, undefined, win);
const m = s.match(/about (\d+) g — from roughly (\d+) g a day to (\d+) g/);
ok(m, `${label}: the sentence has all three figures`);
if (m) {
const [, moved, from, to] = m.map(Number);
eq(moved, Math.abs(to - from), `${label}: the move is exactly the difference of the two ends`);
ok(new RegExp(`is ${to > from ? "up" : "down"} about`).test(s),
`${label}: and the direction matches which end is larger`);
}
}
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");
}
// ---------------------------------------------------------------- by kind
// Splitting the bars must not change what the chart says for anyone who never
// defines a kind, and the per-kind caption must stay bounded as kinds are added.
{
let kinds = [];
const split = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
"foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
// days carrying a per-kind split, as weeklyData builds them.
const byKind = (rows) => rows.map(r => ({
grams: Object.values(r).reduce((s, v) => s + v, 0),
gramsByKind: r,
excluded: false,
meals: 0, mealsMissingGrams: 0,
}));
suite("an unsplit chart is unchanged");
{
kinds = [];
const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
const series = split.foodSeriesFor(days);
eq(series.length, 1, "no kinds defined gives exactly one series");
eq(series[0].name, "No kind", "…the unnamed one");
const s = split.foodSeriesSentences(series, 7);
eq(s.length, 1, "…and one sentence, as before kinds existed");
ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
}
suite("the split adds up");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
{ d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
]);
const series = split.foodSeriesFor(days);
eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
for (const d of days) {
const summed = series.reduce((acc, s) => acc + s.of(d), 0);
eq(summed, d.grams, "each day's segments sum to the day's own total");
}
}
suite("a kind with nothing logged is left out");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
"an unused kind gets no segment and no legend entry");
}
suite("a deleted kind's food still appears");
{
// The kind is gone from the picker but meals still point at it, and that
// food is real — it has to show under the name the tombstone kept.
kinds = [];
const namesOnly = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => [],
// The tombstone: gone from the picker, still carrying name and colour.
loadFoodKinds: () => [{ id: "gone", name: "Old recipe", colorIndex: 2, deleted: true }],
},
});
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
const series = namesOnly.foodSeriesFor(days);
eq(series.map(s => s.name), ["Old recipe"],
"it keeps its name rather than vanishing or reading as 'No kind'");
eq(series[0].colorIndex, 2,
"…and its colour, which grey would confuse with the 'No kind' series");
}
suite("the caption stays bounded as kinds are added");
{
kinds = [
{ id: "a", name: "Dry", colorIndex: 0 },
{ id: "b", name: "Fresh", colorIndex: 1 },
{ id: "c", name: "Raw", colorIndex: 2 },
{ id: "e", name: "Treats", colorIndex: 3 },
];
// Dry climbs clearly; the rest are flat or noise.
const days = byKind([
{ a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
{ a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
{ a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
]);
const series = split.foodSeriesFor(days);
const lines = split.foodSeriesSentences(series, 7);
ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
ok(lines.some(l => /no clear trend/.test(l)),
"…and the rest are folded into one clause rather than a sentence each");
}
suite("no kind moves at all");
{
kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
{ a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
]);
const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
eq(lines.length, 1, "one line when nothing is claimable");
ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
}
}
// ------------------------------------------------- the highlighted day's readout
// Tapping a bar selects that day; this is what the selection says. It reads off
// the existing selection rather than keeping its own, so the two cannot drift.
{
let kinds = [];
let selected = "2026-09-20";
let el = { hidden: false, textContent: "" };
const info = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor", "renderFoodDayInfo"],
stubs: {
document: { getElementById: () => el },
selectedDay: () => new Date(selected + "T12:00:00"),
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const day = (n, byKind, excluded = false) => ({
ymd: `2026-09-${String(n).padStart(2, "0")}`,
date: new Date(2026, 8, n),
grams: Object.values(byKind).reduce((s, v) => s + v, 0),
gramsByKind: byKind, excluded, meals: 0, mealsMissingGrams: 0,
});
const read = (days) => {
el = { hidden: false, textContent: "" };
info.renderFoodDayInfo(days, info.foodSeriesFor(days));
return el;
};
suite("the highlighted day's breakdown");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = [
day(18, { d: 200, f: 100 }), day(19, { d: 210, f: 90 }), day(20, { d: 260, f: 100 }),
];
selected = "2026-09-20";
const r = read(days);
eq(r.hidden, false, "the selected day gets a readout");
ok(/Dry 260 g · Fresh 100 g/.test(r.textContent), "each kind's amount, in the stack's order");
ok(/360 g in total/.test(r.textContent), "…and the total, so you needn't add them up");
ok(/Sep 20/.test(r.textContent), "…named, so it is clear which bar it belongs to");
selected = "2026-09-18";
ok(/Dry 200 g/.test(read(days).textContent), "selecting another bar moves the readout");
// Out of the window entirely: the chart is not showing that day at all.
selected = "2026-08-01";
eq(read(days).hidden, true, "a day outside the window has no bar and so no readout");
}
suite("the days that say something else");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
selected = "2026-09-20";
const withEmpty = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {})];
ok(/no food logged/.test(read(withEmpty).textContent), "a day with no food says so");
const withExcluded = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {}, true)];
ok(/not counted/.test(read(withExcluded).textContent),
"a day marked not counted says that instead of reading as empty");
}
suite("an unsplit chart gets the day total too");
{
// No kinds defined: there is still no hover on a phone, so the figure was
// only readable by eye off the axis.
kinds = [];
selected = "2026-09-20";
const days = [day(18, { "": 300 }), day(19, { "": 320 }), day(20, { "": 340 })];
const r = read(days);
eq(r.hidden, false, "the readout appears without any kinds defined");
eq(r.textContent, "Sun, Sep 20 — 340 g.", "…as the plain total, named by day");
ok(!/No kind/.test(r.textContent),
"…without inventing a kind name for food that has none");
ok(!/in total/.test(r.textContent),
"…and without saying the same number twice");
}
}
export default report("food-trend");