A day whose food was all unlabelled read "Fri, Sep 18 — No kind 300 g · 300 g
in total": the number twice, under a label with nothing to distinguish it from.
The guard was on the wrong quantity. It asked whether the *chart* was split,
when what decides this is how many kinds *that day* holds — and a chart split
across other days can still land on a day of one kind. Keying off the day's own
parts fixes the reported case and a second one nobody had hit yet, where a day
of a single named kind read "Dry 300 g · 300 g in total".
So: several kinds keep the total beside them, being what the bar's height shows
and what you would otherwise add up. One kind does not, because it already is
the total. And "No kind" alone drops its label, which was only ever there to
tell it apart from something else.
The new assertions were checked against the old guard, where five of them fail.
The original ones passed throughout, which is the point — they only ever
exercised a chart with one series, and this bug lives on the other axis.
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.
A kind that is deleted keeps its tombstone so meals logged as it stay readable,
and the chart recovered its name from there — but not its colour, falling back
to grey. Grey is what "No kind" uses, so a deleted kind's food and unlabelled
food drew as the same colour: two distinct series, indistinguishable in the
stack and in the legend beneath it.
The tombstone has the colorIndex all along, so reading the whole record rather
than just the name fixes it. The check now pins the colour as well as the name,
since the name alone was what let this through.
Deleting a kind still leaves the meals alone — confirmed as the wanted
behaviour. This only makes that behaviour legible.
Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.
The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.
Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.
Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.
The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.
One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.
A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
The caption read like "down about 329 g a week — roughly 460 g a day then,
320 g a day now". Subtract the two amounts and you get 140 g, not 329. The
arithmetic behind it was self-consistent, but the sentence was not, and a
caption a reader can disprove by subtracting its own numbers is wrong whatever
the code was doing.
The rate was slope x 7, while the line only covers the complete days in the
window. Today is never fitted, being unfinished, so a 7-day window leaves at
most five days and any marked day takes another — in this case about three.
The rate was therefore stretched well past the days it was measured from, and
the two endpoints, which were not, could never agree with it.
It quotes the change between the two ends now, which is the one figure a reader
can check: "down about 140 g — from roughly 460 g a day to 320 g." The change
is derived from the rounded ends rather than from the slope, so the subtraction
works exactly rather than to within the rounding.
The weekly rate goes rather than being repaired. It cannot reconcile on a short
window, and it only ever meant anything where the fit spanned a week or more —
which is not something to leave as a trap for whichever window the reader
happens to have picked.
The check that let this through asserted the sentence contained certain
phrases, not that its numbers agreed with each other. There is now one that
parses all three figures back out and asserts the move is exactly the
difference of the ends, across each window length; it fails against the old
wording, which is the only evidence worth having that it would have caught this.
"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.
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.