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.
282 lines
11 KiB
JavaScript
282 lines
11 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");
|
|
}
|
|
}
|
|
|
|
|
|
// ---------------------------------------------- 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");
|