// 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");