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.
This commit is contained in:
Alexander Heldt
2026-09-22 10:31:27 +00:00
parent 9e47aa53ff
commit 5a08fb4510
10 changed files with 1082 additions and 43 deletions
+125
View File
@@ -0,0 +1,125 @@
// 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");
}
export default report("food-kinds");
+109
View File
@@ -137,4 +137,113 @@ 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,
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: () => [],
foodKindNames: () => new Map([["gone", "Old recipe"]]),
},
});
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
eq(namesOnly.foodSeriesFor(days).map(s => s.name), ["Old recipe"],
"it keeps its name rather than vanishing or reading as 'No kind'");
}
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");
}
}
export default report("food-trend");