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.
This commit is contained in:
@@ -185,4 +185,97 @@ suite("the palette wraps rather than running out");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------- 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");
|
||||
|
||||
Reference in New Issue
Block a user