Files
puppy-tracker/checks/food-kinds.mjs
T
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

189 lines
6.8 KiB
JavaScript

// 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");
}
}
export default report("food-kinds");