Compare commits

...
8 Commits
Author SHA1 Message Date
Alexander Heldt fab0ae1420 Add a Meals log to Habits, with a bulk label for unlabelled meals
A meal's amount and its kind are both optional by design, which is what
makes them easy to lose track of: a meal with no kind sits in the grey
band on the food chart, one with no grams is dropped from it silently,
and there was nowhere to go and see which meals those were.

The panel lists every meal across every day, ignoring the day picker as
the Notes log does — the ones worth finding are spread through history
rather than sitting on the day being viewed. Each filter chip carries
its own count, so the size of the gap reads off the panel without
selecting anything. Rows go through attachRowHandlers, so tapping one
opens the usual edit dialog and long-press still measures.

On the "no kind" filter it also offers to label the whole list at once,
which is what history needs after kinds arrived months into logging.
backfillFoodKind does it in one pass — one save, one sync, one render,
following setDefaultFoodKind rather than calling updateEvent hundreds of
times; sync already posts the whole event list, so it costs nothing
extra on the wire. The optional cut-off date filters the list as well as
the edit, so the number on the button is the rows on screen: labelling
all of history as one kind is wrong if the food was switched partway,
and once labelled the early meals cannot be told apart. There is no
undo, so the guards are the live count, the number on the button and a
confirm. Owner-only; the server's guest rules would refuse it anyway.

Meals are the only event type that gets this — the only one with
optional fields worth filling in afterwards.

Nothing appears until there is a meal to list, and the kind filter and
bulk block wait for a kind to exist, so an account not using kinds is
unchanged.
2026-09-22 15:38:13 +00:00
Alexander Heldt 41827664de Put the day's food breakdown inside the Meals card
The total sat in the Meals card and the per-kind split sat in a row beneath the
whole stats grid, so the two halves of the same figure were in different
places. The split now goes under the total it adds up to.

I argued against this when the row went in, on the grounds that a 90px tile
cannot hold a breakdown. That was true of the shape I had in mind — "Dry 260 g
· Fresh 100 g" inline is about 110px — and not of the one that belongs there:
stacked, a line per kind, it is about 60px.

A single kind gets no line of its own. The total is already that kind's figure,
so the name joins it — "540 g · Dry" — rather than repeating the number
underneath. Same rule the chart's day readout uses, and for the same reason.

A day with no kinds on its meals leaves the tile exactly as it was, which is
the overview anyone not using kinds keeps.

The changelog entry for this has shipped and said the breakdown appears "under
the stat tiles". It does not any more, so it is corrected in place: leaving a
description that is now wrong would be worse than editing an entry some readers
have already seen.
2026-09-22 15:10:51 +00:00
Alexander Heldt cd13efdbed Judge the day readout by the day's kinds, not the window's
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.
2026-09-22 15:05:02 +00:00
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
Alexander Heldt 3584758b86 Keep a deleted kind's colour, not just its name
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.
2026-09-22 10:42:30 +00:00
Alexander Heldt 18d3778241 Break today's food down by kind in the overview
The Food chart splits by kind but today's overview did not, so the one place
you look first still lumped dry and fresh into a single total.

It is a line under the stat tiles rather than part of the Meals tile. That tile
is about 90px wide with a 0.7rem sub-line, and two kinds will not sit in it
without wrapping into a mess — so the tile keeps the day's total, which is the
headline figure, and the breakdown gets the room it needs.

It follows the chart's conventions so the two read as one breakdown rather than
two arbitrary lists: the same layer order, "No kind" last, a kind deleted since
still named through its tombstone, and a meal logged without an amount adding
nothing.

Hidden unless a meal that day carries a kind, which keeps the overview
untouched for anyone not using them — the case the first assertion in the new
suite pins down.

This was a gap rather than a reversal: when the split was scoped to "the grams
chart only", the options named the counts chart, the by-hour heatmap and Timing
as staying put. The overview's food total was in neither list, so it was never
decided either way.
2026-09-22 10:38:47 +00:00
Alexander Heldt 5a08fb4510 Let a meal say what kind of food it was
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.
2026-09-22 10:31:27 +00:00
Alexander Heldt 9e47aa53ff Stop the food trend's figures contradicting each other
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.
2026-09-21 21:01:27 +00:00
10 changed files with 1828 additions and 59 deletions
+36
View File
@@ -26,6 +26,16 @@ source-of-truth and sync between devices.
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
events (`type: "training"`) referencing an exercise by id, so they ride the
event sync unchanged.
- Food kinds (`Dry`, `Fresh`) are a third synced collection with the same
contract, via `POST /api/foodkinds/sync`; a meal references one by
`foodKindId`. Empty means **no kind**, which is what every meal logged before
kinds existed carries — so nothing needed migrating and nobody is made to
classify their food. Which kind a new meal starts on is a flag on the kind
itself rather than a profile field: the profile is last-write-wins across the
whole row (see the `pedigree_id` special case below), and per-item LWW lets
two devices that each chose a default resolve to the newer instead of
fighting. Each kind also keeps a fixed `colorIndex`, so deleting one never
repaints the charts of the ones around it.
- The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in
@@ -82,6 +92,32 @@ of them, because logging has to be one tap from wherever you are.
setting — but being module-level is what carries them through the re-render a
background sync causes every minute. Long-press has no keyboard equivalent, so
this is touch and mouse only.
- **The Meals log** in Habits lists every meal across every day, beside the
Food (grams) chart, ignoring the day picker as the Notes log does — the meals
worth finding here are spread through history, not on the day you happen to
be viewing. It exists because a meal's two extras, its amount and its kind,
are both optional by design, which is exactly what makes them easy to lose: an
unlabelled meal sits in a grey band on the chart, one with no grams is dropped
from it silently, and neither was findable. Each filter chip carries its own
count, so the size of the gap reads off the panel without selecting anything.
Rows go through `attachRowHandlers` like every other log, so tapping one opens
the same edit dialog and long-press still measures. The list is capped at
`MEALS_LOG_PAGE` with a *Show more* — a year of logging is a thousand rows,
and `render()` draws every panel on every pass. Meals are the only type that
gets this: they are the only events with optional fields worth filling in
afterwards, a pee being a pee and a weigh-in being refused without a number.
- **Labelling old meals in bulk.** Kinds arrived after months of logging, so
everything recorded before them has none. `backfillFoodKind` sets one kind on
every meal the current filter lists in a single pass — one save, one sync, one
render, following `setDefaultFoodKind` rather than calling `updateEvent`
hundreds of times. It costs nothing extra on the wire either, since sync
already posts the whole event list. The optional *Only before* date is the
part that matters: labelling all of history as one kind is wrong if the food
was switched partway, and once labelled the early meals cannot be told apart
from correctly labelled ones. The cut-off filters the list as well as the
edit, so the number on the button is the rows on screen. There is no undo —
the guards are the live count, the number on the button, and a confirm.
Owner-only, and the server's guest rules would refuse it anyway.
- Each tab is a `.tab-panel` wrapper around the existing sections. The
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
+281
View File
@@ -0,0 +1,281 @@
// Kinds of food: a library of user-named labels a meal can carry. The rules
// worth holding still are the ones that decide what happens to people who
// never use the feature, and what happens to history when a kind is deleted.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
let store = [];
let synced = 0, rendered = 0;
let nextId = 0;
const app = load({
names: [
"NO_KIND", "FOOD_COLORS",
"loadFoodKinds", "saveFoodKinds", "liveFoodKinds",
"addFoodKind", "updateFoodKind", "deleteFoodKind",
"setDefaultFoodKind", "defaultFoodKindId", "foodKindNames",
],
stubs: {
foodKindsKey: () => "k",
localStorage: {
getItem: () => JSON.stringify(store),
setItem: (_, v) => { store = JSON.parse(v); },
},
uuid: () => `id${++nextId}`,
scheduleSync: () => { synced++; },
render: () => { rendered++; },
},
});
const reset = () => { store = []; nextId = 0; };
const names = () => app.liveFoodKinds().map(k => k.name);
suite("an account with no kinds behaves as it always did");
{
reset();
eq(app.liveFoodKinds(), [], "no kinds to begin with");
eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind");
eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry");
}
suite("creating kinds");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical");
eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot");
ok(synced > 0, "a new kind is queued for sync");
}
suite("the default");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so");
app.setDefaultFoodKind(dry.id);
eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default");
app.setDefaultFoodKind(fresh.id);
eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it");
eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1,
"…and unflags the old one, so there is never more than one");
app.setDefaultFoodKind(app.NO_KIND);
eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind");
}
suite("two devices that each set a default");
{
// What a sync race leaves behind: both rows flagged, different timestamps.
// Resolving to the newer beats showing two defaults or picking at random.
reset();
store = [
{ id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 },
{ id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 },
];
eq(app.defaultFoodKindId(), "b", "the more recent flag wins");
}
suite("renaming and deleting keep history readable");
{
reset();
const dry = app.addFoodKind("Dry");
app.updateFoodKind(dry.id, { name: "Dry kibble" });
eq(names(), ["Dry kibble"], "renaming changes the name in place");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…and meals pointing at the id follow it, since they resolve by id");
app.deleteFoodKind(dry.id);
eq(names(), [], "a deleted kind leaves the picker");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…but its name still resolves, so meals logged as it stay readable");
}
suite("colours survive a deletion");
{
// Deriving colour from position in the live list would repaint every past
// chart the moment a kind was removed. The index is fixed at creation.
reset();
app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
const raw = app.addFoodKind("Raw");
eq(raw.colorIndex, 2, "the third kind takes the third slot");
app.deleteFoodKind(fresh.id);
const live = app.liveFoodKinds();
eq(live.map(k => k.colorIndex), [0, 2],
"deleting the middle kind leaves the others' colours alone");
eq(app.addFoodKind("Treats").colorIndex, 3,
"and the next kind does not reuse the freed slot");
}
suite("the palette wraps rather than running out");
{
reset();
for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`);
const live = app.liveFoodKinds();
eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours");
eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0,
"…and the palette wraps, so two share rather than one having none");
}
// ---------------------------------------------- the day's split in the Meals tile
// The tile holds the day's total; this puts the per-kind figures under it, so
// the parts and the sum they make are read in one place. The case that matters
// is the one where none of it should appear.
{
let kinds = [];
let kindsEl, gramsEl;
const view = load({
names: ["NO_KIND", "renderDayFoodKinds"],
stubs: {
document: {
getElementById: () => kindsEl,
createElement: () => ({ textContent: "", appendChild() {} }),
},
liveFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId });
const show = (evs) => {
const lines = [];
kindsEl = {
hidden: false, textContent: "",
appendChild: (n) => lines.push(n.textContent),
};
gramsEl = { textContent: "" };
const total = evs.filter(e => e.type === "eat" && e.grams > 0)
.reduce((s, e) => s + e.grams, 0);
view.renderDayFoodKinds(evs, gramsEl, total);
return { lines, grams: gramsEl.textContent, hidden: kindsEl.hidden };
};
suite("the day's food in the Meals tile");
{
kinds = [];
const plain = show([meal(180), meal(120)]);
eq(plain.hidden, true, "meals with no kind add nothing under the total");
eq(plain.grams, "", "…and leave the tile's own total line alone");
eq(show([]).hidden, true, "a day with no meals adds nothing");
kinds = [{ id: "d", name: "Dry" }];
const one = show([meal(300, "d"), meal(240, "d")]);
eq(one.hidden, true, "one kind adds no lines — it would only repeat the total");
eq(one.grams, "540 g · Dry", "…the total carries its name instead");
kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }];
const two = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]);
eq(two.hidden, false, "two kinds do get their own lines");
eq(two.lines, ["Dry 260 g", "Fresh 100 g"], "…summed per kind, in the chart's order");
eq(show([meal(200, "d"), meal(50)]).lines, ["Dry 200 g", "No kind 50 g"],
"unlabelled food alongside a kind is named, not dropped");
kinds = [];
eq(show([meal(90, "gone")]).grams, "90 g · Deleted kind",
"a kind deleted since still labels its food rather than vanishing");
kinds = [{ id: "d", name: "Dry" }];
eq(show([meal(0, "d"), meal(120, "d")]).grams, "120 g · Dry",
"a meal logged without an amount adds nothing to the split");
}
}
// ---------------------------------------------- finding and labelling the gaps
// Both of a meal's extras are optional, so history accumulates meals missing
// one. These are the rules the Meals log filters by, and the one bulk edit it
// offers — the only place in the app where a single tap rewrites hundreds of
// events, which is why the selection is worth pinning down exactly.
{
let saved = null, synced = 0, rendered = 0;
const gaps = load({
names: ["MEAL_GAPS", "mealsMissing", "backfillFoodKind"],
stubs: {
loadAll: () => saved,
saveAll: (v) => { saved = v; },
scheduleSync: () => { synced++; },
render: () => { rendered++; },
},
});
const at = (iso) => new Date(iso).getTime();
const ev = (o) => ({ id: o.id, type: o.type || "eat", at: at(o.at), ...o, at: at(o.at) });
const ids = (list) => list.map(e => e.id);
const log = [
ev({ id: "m1", at: "2026-05-08T08:00:00", grams: 200, foodKindId: "" }),
ev({ id: "m2", at: "2026-05-09T08:00:00", grams: 0, foodKindId: "" }),
ev({ id: "m3", at: "2026-05-10T08:00:00", grams: 180, foodKindId: "dry" }),
ev({ id: "m4", at: "2026-05-11T08:00:00", foodKindId: "" }),
ev({ id: "m5", at: "2026-05-12T08:00:00", grams: 90, foodKindId: "", deleted: true }),
ev({ id: "p1", at: "2026-05-08T09:00:00", type: "pee" }),
ev({ id: "n1", at: "2026-05-08T10:00:00", type: "note", note: "vet" }),
ev({ id: "w1", at: "2026-05-08T11:00:00", type: "weight", weight: 7.2 }),
];
suite("which meals the log picks out");
{
eq(ids(gaps.mealsMissing(log, "all", "")), ["m4", "m3", "m2", "m1"],
"only live meals, newest first — a pee, a note and a weigh-in are not meals");
ok(!ids(gaps.mealsMissing(log, "all", "")).includes("m5"),
"…and a deleted meal is gone for good, not merely unlabelled");
eq(ids(gaps.mealsMissing(log, "kind", "")), ["m4", "m2", "m1"],
"'no kind' skips the meal that has one");
ok(gaps.mealsMissing(log, "kind", "").some(e => e.id === "m4"),
"…and a meal with no amount still counts as one needing a kind");
eq(ids(gaps.mealsMissing(log, "amount", "")), ["m4", "m2"],
"'no amount' takes both a missing grams and a zero one");
eq(gaps.MEAL_GAPS.amount({ grams: 0 }), true, "zero grams is no amount, not an amount of none");
}
suite("the cut-off that makes a bulk label safe");
{
eq(ids(gaps.mealsMissing(log, "kind", "2026-05-10")), ["m2", "m1"],
"takes meals strictly before the start of that day");
eq(ids(gaps.mealsMissing(log, "kind", "2026-05-09")), ["m1"],
"…so a meal on the cut-off day itself is left alone");
eq(ids(gaps.mealsMissing(log, "kind", "")), ["m4", "m2", "m1"],
"no cut-off means all of them");
eq(ids(gaps.mealsMissing(log, "kind", "not-a-date")), ["m4", "m2", "m1"],
"…as does a date that isn't one, rather than silently emptying the list");
}
suite("labelling them in bulk");
{
saved = log.map(e => ({ ...e }));
const before = JSON.stringify(saved.filter(e => e.id !== "m1" && e.id !== "m2" && e.id !== "m4"));
synced = rendered = 0;
const n = gaps.backfillFoodKind("dry", "");
eq(n, 3, "reports how many it changed");
eq(saved.filter(e => e.foodKindId === "dry").map(e => e.id).sort(), ["m1", "m2", "m3", "m4"],
"every unlabelled meal now carries the kind — and the one that had it still does");
eq(JSON.stringify(saved.filter(e => e.id !== "m1" && e.id !== "m2" && e.id !== "m4")), before,
"everything it did not pick is left byte for byte as it was");
ok(saved.find(e => e.id === "m1").updatedAt > 0,
"a relabelled meal is stamped, so it wins last-write-wins on the next sync");
eq([synced, rendered], [1, 1],
"one sync and one render for the lot, not one per meal");
}
suite("a bulk label that would do nothing does nothing");
{
saved = log.map(e => ({ ...e }));
const untouched = JSON.stringify(saved);
synced = rendered = 0;
eq(gaps.backfillFoodKind("", ""), 0, "no kind chosen is not a label to apply");
eq(gaps.backfillFoodKind("dry", "2026-01-01"), 0, "nor is a cut-off with nothing before it");
eq(JSON.stringify(saved), untouched, "…and neither writes anything");
eq([synced, rendered], [0, 0], "…or queues a sync for a change that never happened");
}
}
export default report("food-kinds");
+249 -8
View File
@@ -47,7 +47,7 @@ suite("today is left out, being half-eaten");
// 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(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");
}
@@ -55,11 +55,11 @@ 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");
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.perWeek < 0, "…with a negative weekly change");
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]));
@@ -83,20 +83,39 @@ 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");
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) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), 14);
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 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(/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),
@@ -118,4 +137,226 @@ suite("what the sentence is allowed to say");
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("a day holding only one kind");
{
// The chart is split — other days have several kinds — but this day's food
// is all one. The total is that one figure, so saying it twice is noise.
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = [day(18, { "": 300 }), day(19, { d: 210, f: 90 }), day(20, { d: 300 })];
selected = "2026-09-18";
const unlabelled = read(days).textContent;
eq(unlabelled, "Fri, Sep 18 — 300 g.",
"a day of unlabelled food gives the bare total, on a split chart too");
ok(!/No kind/.test(unlabelled),
"…without the 'No kind' label, which has nothing to distinguish it from");
ok(!/in total/.test(unlabelled), "…and without saying the number twice");
selected = "2026-09-20";
const oneKind = read(days).textContent;
eq(oneKind, "Sun, Sep 20 — Dry 300 g.",
"a day of one named kind keeps the name, which does say something");
ok(!/in total/.test(oneKind), "…but still does not repeat the figure");
selected = "2026-09-19";
ok(/in total/.test(read(days).textContent),
"two kinds on a day still get the total beside them");
}
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");
+1
View File
@@ -504,6 +504,7 @@ func (a *Auth) deleteAccount(userID string) error {
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM food_kinds WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM push_subscriptions WHERE user_id = ?`,
`DELETE FROM reminders WHERE user_id = ?`,
+64
View File
@@ -680,6 +680,70 @@ func TestGuestCannotExcludeADay(t *testing.T) {
}
}
// The food kinds are the owner's library, like the exercise list: a guest
// labels a meal with a kind that exists but does not invent or rename one.
func TestGuestCannotChangeFoodKinds(t *testing.T) {
a := testAuth(t)
kinds := newFoodKindStore(a.db)
ownerID := testOwner(t, a)
if _, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// What the route hands the store for a guest: nothing incoming, everything
// back. Mirrors the exercises guard in main.go.
merged, err := kinds.sync(ownerID, nil)
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].Name != "Dry" {
t.Fatalf("a guest should still receive the library: %+v", merged)
}
if !merged[0].IsDefault {
t.Error("the default flag did not survive the round trip")
}
// And the owner can still rename it, which is the other half of the rule.
renamed, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
})
if err != nil {
t.Fatalf("owner rename: %v", err)
}
if renamed[0].Name != "Dry kibble" {
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
}
}
// A meal's kind rides the event sync like any other field, and an older client
// that doesn't know about kinds must not wipe one.
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
})
if err != nil {
t.Fatalf("sync: %v", err)
}
byID := map[string]Event{}
for _, e := range merged {
byID[e.ID] = e
}
if byID["e1"].FoodKindID != "k1" {
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
}
if byID["e2"].FoodKindID != "" {
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
}
}
// Guests still log freely — the guard is on changing what already exists.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)
+166 -6
View File
@@ -32,6 +32,10 @@ type Event struct {
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
// FoodKindID names which sort of food, for "eat" events. Empty is a real
// answer — "no kind" — and is what every meal logged before kinds existed
// carries, so none of them needed rewriting.
FoodKindID string `json:"foodKindId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
@@ -176,12 +180,13 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
food_kind_id = excluded.food_kind_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id
@@ -204,7 +209,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
); err != nil {
return nil, err
}
@@ -218,7 +223,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -228,7 +233,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare,
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
); err != nil {
return nil, err
}
@@ -237,6 +242,100 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err()
}
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
// logged before kinds existed carries and what anyone who doesn't want to
// classify their food keeps carrying.
//
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
// tombstoned deletes — plus two fields of its own:
//
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
// than in the profile because the profile is last-write-wins across the
// whole row, and this file already carries a special case for pedigree_id
// to stop a clock race dropping it. Per-item LWW needs no such case: two
// devices setting different defaults resolve to the newer one.
// - ColorIndex fixes which palette entry the charts give it, assigned at
// creation. Deriving colour from position in the live list would silently
// recolour every past chart the moment a kind was deleted.
type FoodKind struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault,omitempty"`
ColorIndex int `json:"colorIndex"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
// Store and ExerciseStore are already near-twins, so a third in the same shape
// is the pattern this file has established, and it leaves both working
// collections untouched. Folding all three into one store parameterised by
// table name is the tidier end state, and a separate job.
type FoodKindStore struct {
db *sql.DB
}
func newFoodKindStore(db *sql.DB) *FoodKindStore {
return &FoodKindStore{db: db}
}
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, is_default = excluded.is_default,
color_index = excluded.color_index,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > food_kinds.updated
AND food_kinds.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, k := range client {
if k.ID == "" {
continue
}
if _, err := stmt.Exec(
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
rows, err := s.db.Query(
`SELECT id, name, is_default, color_index, updated, deleted
FROM food_kinds WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FoodKind, 0)
for rows.Next() {
var k FoodKind
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
// UpdatedAt, same user_id guard against cross-user id collisions, same
// tombstone propagation.
@@ -341,7 +440,8 @@ func openDB(path string) (*sql.DB, error) {
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT ''
logged_by_share TEXT NOT NULL DEFAULT '',
food_kind_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
@@ -353,6 +453,16 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS food_kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
color_index INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
@@ -489,6 +599,17 @@ func migrateSchema(db *sql.DB) error {
return err
}
}
// Which sort of food a meal was. Empty on every existing row, which is
// exactly right: those meals have no kind, and none of them need rewriting.
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
if err != nil {
return err
}
if !hasFoodKind {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
@@ -649,6 +770,14 @@ type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type foodKindSyncRequest struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type foodKindSyncResponse struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type cacheControlFS struct {
root http.FileSystem
}
@@ -751,6 +880,7 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
foodKindStore := newFoodKindStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
@@ -857,6 +987,36 @@ func main() {
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// POST /api/foodkinds/sync — the same contract again for the food kinds a
// meal can be labelled with.
mux.HandleFunc("/api/foodkinds/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req foodKindSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// The library is the owner's, exactly as the exercise list is: a guest
// labels a meal with a kind that exists, but does not invent, rename or
// delete one. They still receive the full set, so the picker works.
incoming := req.FoodKinds
if isGuest(r) {
incoming = nil
}
merged, err := foodKindStore.sync(userID(r), incoming)
if err != nil {
log.Printf("food kinds sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(foodKindSyncResponse{FoodKinds: merged})
}))
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
+784 -28
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,7 +1,11 @@
[
{ "date": "2026-09-22", "text": "Habits has a Meals log beside the Food (grams) chart: every meal you have logged, newest first, across every day rather than just the one you are looking at. It is there because a meal's amount and its kind are both optional — which is what makes them easy to lose track of. A meal with no kind sits in the grey band on the chart, a meal with no amount is quietly left out of it, and until now there was nowhere to go and see which ones those were. The filter chips carry their own counts, so “No kind 412” tells you the size of the gap before you tap anything, and picking a filter lists exactly those meals; tap any row to fill in what it is missing, in the same edit dialog as everywhere else. If you started using food kinds after months of logging, the “No kind” filter also offers to label the whole list at once: choose a kind and it sets it on every meal shown. Tick “Only before” a date first if you switched foods partway, so the older meals take one kind and the newer ones keep what they have — worth getting right, because there is no undo. The panel folds away by tapping its heading like any other, and appears only once you have logged a meal; the “No kind” filter and the bulk label appear only once you have created a kind, so nothing changes for anyone not using them. A guest can see the list but cannot relabel your meals" },
{ "date": "2026-09-22", "text": "Tap a bar in the Food (grams) chart and a line under it spells that day out — “Sat, Sep 20 — 340 g”, or “Dry 260 g · Fresh 100 g · 360 g in total” once you are using kinds. A phone has nothing to hover over, so the amount for a given day was previously only readable by eye off the axis. It follows whichever day is highlighted, so the ← → arrows and the date picker move it too, and it says so plainly when a day has no food logged or is marked as not counted" },
{ "date": "2026-09-22", "text": "Meals can be labelled with a kind of food. Make up your own in Settings → Food kinds — dry, fresh, raw, whatever you feed — and pick one when you log a meal; tap the ★ beside one to have it chosen for you automatically. You can also invent a kind from inside the log dialog if you realise you need it mid-meal. All of it is optional: “No kind” is always offered, every meal you have already logged keeps working untouched, and with no kinds defined the app looks and behaves exactly as it did. Once you are using them, today's overview breaks the day's food down inside the Meals card — “Dry 260 g” and “Fresh 100 g” under the total they add up to — and the Food (grams) chart splits each day's bar by kind with a legend, and draws a separate trend line for each, so you can see fresh creeping up while dry comes down. The sentence underneath names only the kinds that are actually moving and folds the rest into one clause, so it stays short however many kinds you have. Renaming a kind updates the meals logged as it; deleting one keeps them readable under the name it had. A guest can label a meal with a kind you have created but cannot add, rename or delete them" },
{ "date": "2026-09-21", "text": "Fixed the figures under the Food (grams) chart contradicting each other. It read like “down about 329 g a week — roughly 460 g a day then, 320 g a day now”, where subtracting the two amounts gives 140 g, not 329 g. The rate was worked out per week while the line itself only covers the complete days in the window — at most five of them on a 7-day window, since today isn't finished — so it was stretched past the days it was measured from. It now gives the change between the two ends, which is a figure you can check by subtracting them: “down about 140 g — from roughly 460 g a day to 320 g”" },
{ "date": "2026-09-21", "text": "You can measure the time between two events. Press and hold one row, press and hold another, and a bar along the bottom shows the gap — “3h 42m · Ate 12:10 → Poo 15:52” — which answers things like how long after a meal he needs to go out. It stays there until you clear it with the ✕, so you can change day in between and pick the second event from another day; when the pair straddles midnight the bar shows the dates too. It works on any row that is a single moment: the history log, the notes log and weigh-ins. Holding a row you already picked unpicks it, and a third pick is ignored until you clear. Tapping a row still opens it for editing as before. One cost: because holding a row now means something, you can no longer select the text of a note to copy it" },
{ "date": "2026-09-21", "text": "A day marked “not counted” no longer appears in the Sleep trend or the Walk trend. It was already left out of the average and out of the “yesterday” comparison, but the day you were actually looking at was still drawn as the boldest line on the chart — so the one day you had said not to trust was the one the panel led with. Now it is left off and its legend chip goes with it, leaving the average and yesterday, which is what you would want to see on a day like that" },
{ "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-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 120 g — from roughly 280 g a day to 400 g”. 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" },
+86 -1
View File
@@ -250,6 +250,10 @@
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
<!-- The day's food per kind, under the total it adds up to.
Only when there is more than one kind that day: with one,
the total line already carries its name. -->
<div class="stat-sub stat-kinds" id="stat-meals-kinds" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Pees</div>
@@ -397,11 +401,54 @@
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<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, with a trend line through them"></svg>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day, split by kind, with a trend line through each"></svg>
<div id="grams-legend" class="legend" hidden></div>
<!-- The highlighted day, broken down. Tapping a bar selects that
day (as it does on every chart), so this is what the selection
amounts to here — there is no hover on a phone, and the bar's
tooltip is unreachable. -->
<p id="grams-day-info" class="muted-note food-day-info" aria-live="polite" hidden></p>
<!-- What the trend line says, and when it is not saying anything —
see renderFoodTrendNote. -->
<p id="grams-note" class="muted-note" hidden></p>
</div>
<!-- Every meal logged, newest first. Like the Notes log this ignores
the day picker: the meals worth finding here are the ones missing
a kind or an amount, and those are spread across all of history
rather than sitting on the day you happen to be viewing.
Folds on its own h3 (see the data-panel machinery) because the
list grows by one every meal, and the charts above it are what
you usually want left on screen. -->
<div class="subpanel meals-log" data-panel="meals-log" id="meals-log" hidden>
<h3>Meals</h3>
<!-- Counts live on the chips, so how much is unlabelled is legible
without selecting anything. -->
<div class="kind-picker meals-filter" id="meals-log-filter" role="radiogroup" aria-label="Which meals to list">
<button type="button" class="kind-chip active" data-gap="all" role="radio" aria-checked="true">All</button>
<button type="button" class="kind-chip" data-gap="kind" role="radio" aria-checked="false" hidden>No kind</button>
<button type="button" class="kind-chip" data-gap="amount" role="radio" aria-checked="false">No amount</button>
</div>
<!-- The cut-off filters the list, not just the bulk edit, so what
the button acts on is exactly what is drawn beneath it. -->
<label class="meals-cutoff" id="meals-log-cutoff">
<input type="checkbox" id="meals-log-before-on" />
<span>Only before</span>
<input type="date" id="meals-log-before" aria-label="Only meals before this date" />
</label>
<ul id="meals-log-list" class="event-list"></ul>
<p id="meals-log-empty" class="empty" hidden></p>
<button type="button" id="meals-log-more" class="ghost meals-more" hidden></button>
<!-- The bulk label lives on the list rather than in Settings, so the
number on the button is the rows you are looking at. Owner-only:
a guest may not touch the owner's meals. -->
<div class="meals-bulk" id="meals-log-bulk" hidden>
<div class="meals-bulk-head">Label these as</div>
<div id="meals-log-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind to label these meals as"></div>
<button type="button" id="meals-log-apply" class="ghost meals-apply"></button>
</div>
</div>
<div class="chart">
<div class="chart-title">By hour of day</div>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
@@ -546,6 +593,25 @@
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
</div>
<!-- The food kinds a meal can be labelled with. Owner-only, like the
exercise library. Empty by default and entirely optional: an
account with no kinds never sees a picker when logging. -->
<div id="food-kinds-section">
<hr class="settings-sep" />
<h4 class="settings-subhead">Food kinds</h4>
<p class="settings-hint">
Label a meal with the sort of food it was — dry, fresh, whatever you
feed. Optional: with none defined, nothing changes, and “No kind”
stays available even once you have some.
</p>
<ul id="food-kind-list" class="food-kind-list"></ul>
<p id="food-kind-empty" class="settings-hint">No kinds yet.</p>
<div class="kind-new">
<input type="text" id="food-kind-name" maxlength="30" autocomplete="off" placeholder="e.g. Dry" />
<button type="button" id="food-kind-add" class="ghost">Add</button>
</div>
</div>
<!-- Guest links: hand a dog sitter a URL that logs events on this
account without giving them the password. Owner-only. -->
<div id="guest-access">
@@ -636,6 +702,18 @@
<label id="note-grams-field" hidden>Amount (g)
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
</label>
<!-- Which sort of food. Only on a meal, and only once at least one kind
exists: someone who never defines one should never see it. "No
kind" is always an option and is where everyone starts. The chips
are built by renderKindPicker. -->
<div id="note-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="note-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
<div id="note-kind-new" class="kind-new" hidden>
<input type="text" id="note-kind-name" maxlength="30" autocomplete="off" placeholder="New kind, e.g. Fresh" />
<button type="button" id="note-kind-add" class="ghost">Add</button>
</div>
</div>
<label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label>
@@ -673,6 +751,13 @@
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<!-- The same picker, so a meal's kind can be corrected after the fact.
No "new kind" box here: inventing one belongs where you are
logging, not where you are fixing a typo. -->
<div id="edit-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="edit-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
</div>
<label>Note
<textarea id="edit-note" rows="4"></textarea>
</label>
+152 -11
View File
@@ -1110,6 +1110,13 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
.excluded-note { margin: 8px 0 0; }
/* The per-kind lines under the Meals tile's total. Kind names are free
text, so a long one wraps inside the tile rather than widening it. */
.stat-kinds { margin-top: 1px; }
.stat-kinds div { overflow-wrap: anywhere; }
/* Tied to the bar above it, so it sits closer to the chart than the trend
caption below and takes the accent to read as "the highlighted one". */
.food-day-info { margin: 6px 0 0; color: var(--accent); }
/* The day's own figures stay readable but visibly step back, so "this one is
not in the numbers" is legible from the day as well as from the charts. */
@@ -1331,6 +1338,151 @@ input.switch:checked::after { transform: translateX(18px); }
.update-banner-btn:hover { filter: brightness(0.97); }
/* ---------- quick-log snackbar ---------- */
/* ---------- food kinds ---------- */
/* A palette of its own. The app's semantic colours are spoken for --pee
yellow on a food bar would actively mislead so kinds get six hues that sit
around --eat's orange and stay apart from each other. A kind keeps its index
for life (see addFoodKind), so a deletion never repaints old charts; beyond
six, kinds share, which is a gentler failure than running out. */
:root {
--food-0: #ff9b3d;
--food-1: #2bb3a3;
--food-2: #b04ecf;
--food-3: #3f9e63;
--food-4: #e0603c;
--food-5: #5a7fd6;
}
[data-color="0"] { --food-color: var(--food-0); }
[data-color="1"] { --food-color: var(--food-1); }
[data-color="2"] { --food-color: var(--food-2); }
[data-color="3"] { --food-color: var(--food-3); }
[data-color="4"] { --food-color: var(--food-4); }
[data-color="5"] { --food-color: var(--food-5); }
/* A stacked segment, and the dashed fit through that kind's own amounts. Both
take the kind's colour from the data-color attribute set on the element. */
.chart-svg .bar-food-kind { fill: var(--food-color, var(--muted)); }
.chart-svg .food-trend {
stroke: var(--food-color, var(--weight));
stroke-width: 2;
stroke-dasharray: 5 3;
stroke-linecap: round;
fill: none;
}
.legend .sw.food-sw { background: var(--food-color, var(--muted)); }
.kind-field { margin-bottom: 10px; }
.kind-label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 4px;
}
.kind-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
button.kind-chip {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.85rem;
font-weight: 600;
/* Names are free text, so a long one wraps the row rather than the chip. */
max-width: 100%;
overflow-wrap: anywhere;
}
/* The chosen chip fills with its own colour; "No kind" has none and falls back
to the accent, so it reads as a choice rather than as a colourless gap. */
button.kind-chip.active {
background: var(--food-color, var(--accent));
border-color: transparent;
color: #fff;
}
.kind-new {
display: flex;
gap: 8px;
margin-top: 8px;
}
.kind-new input { flex: 1; min-width: 0; }
.kind-new button { flex: none; padding: 8px 14px; }
.food-kind-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.food-kind-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
.food-kind-swatch {
flex: none;
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--food-color, var(--muted));
}
input.food-kind-name {
flex: 1;
min-width: 0;
width: auto; /* the global input rule sets 100%, which would push the row */
}
button.food-kind-default {
flex: none;
background: transparent;
color: var(--muted);
padding: 4px 6px;
font-size: 1.1rem;
line-height: 1;
}
button.food-kind-default.active { color: var(--wake); }
button.food-kind-delete { color: var(--danger); flex: none; }
/* ---------- the meals log ---------- */
/* Beside the food chart in Habits: every meal, across every day, with the
count of what each filter would show carried on its own chip. */
.meals-log .kind-picker { margin-bottom: 8px; }
.meals-cutoff {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--muted);
font-size: 0.85rem;
}
.meals-cutoff input[type="checkbox"] { flex: none; }
.meals-cutoff input[type="date"] {
flex: 1;
width: auto; /* the global input rule sets 100%, which would push the row */
min-width: 0;
padding: 4px 8px;
font-size: 0.85rem;
}
button.meals-more { margin-top: 8px; width: 100%; }
/* Set off by a rule, so it reads as an action on the list above rather than
as another row of it. */
.meals-bulk {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.meals-bulk-head {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 8px;
}
button.meals-apply { margin-top: 10px; width: 100%; }
button.meals-apply[disabled] { opacity: 0.5; }
/* ---------- measuring between two events ---------- */
/* Fixed at the bottom, near the thumb, and it stays until cleared the
measurement is the answer to a question you asked, not a notification. */
@@ -1464,17 +1616,6 @@ button.measure-clear {
.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; }
/* 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. */
.chart-svg .trend-today {
stroke: var(--sleep);