The Food chart splits by kind but today's overview did not, so the one place you look first still lumped dry and fresh into a single total. It is a line under the stat tiles rather than part of the Meals tile. That tile is about 90px wide with a 0.7rem sub-line, and two kinds will not sit in it without wrapping into a mess — so the tile keeps the day's total, which is the headline figure, and the breakdown gets the room it needs. It follows the chart's conventions so the two read as one breakdown rather than two arbitrary lists: the same layer order, "No kind" last, a kind deleted since still named through its tombstone, and a meal logged without an amount adding nothing. Hidden unless a meal that day carries a kind, which keeps the overview untouched for anyone not using them — the case the first assertion in the new suite pins down. This was a gap rather than a reversal: when the split was scoped to "the grams chart only", the options named the counts chart, the by-hour heatmap and Timing as staying put. The overview's food total was in neither list, so it was never decided either way.
168 lines
6.1 KiB
JavaScript
168 lines
6.1 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 overview
|
|
// The Meals tile keeps the day's total; this is the breakdown under it. The
|
|
// case that matters is the one where it must not appear at all.
|
|
{
|
|
let kinds = [];
|
|
let el = { hidden: false, textContent: "" };
|
|
const view = load({
|
|
names: ["NO_KIND", "renderDayFoodKinds"],
|
|
stubs: {
|
|
document: { getElementById: () => el },
|
|
liveFoodKinds: () => kinds,
|
|
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
|
},
|
|
});
|
|
const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId });
|
|
const show = (evs) => { el = { hidden: false, textContent: "" }; view.renderDayFoodKinds(evs); return el; };
|
|
|
|
suite("the day's food split");
|
|
{
|
|
kinds = [];
|
|
eq(show([meal(180), meal(120)]).hidden, true,
|
|
"meals with no kind show no breakdown — the tile's total already says it");
|
|
eq(show([]).hidden, true, "a day with no meals shows nothing");
|
|
|
|
kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }];
|
|
const both = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]);
|
|
eq(both.hidden, false, "once a meal carries a kind, the breakdown appears");
|
|
eq(both.textContent, "Dry 260 g · Fresh 100 g", "…summed per kind, in the chart's order");
|
|
|
|
eq(show([meal(200, "d"), meal(50)]).textContent, "Dry 200 g · No kind 50 g",
|
|
"unlabelled food on a day that has kinds is named, not dropped");
|
|
|
|
kinds = [];
|
|
eq(show([meal(90, "gone")]).textContent, "Deleted kind 90 g",
|
|
"a kind deleted since still labels its food rather than vanishing");
|
|
|
|
kinds = [{ id: "d", name: "Dry" }];
|
|
eq(show([meal(0, "d"), meal(120, "d")]).textContent, "Dry 120 g",
|
|
"a meal logged without an amount adds nothing to the split");
|
|
}
|
|
}
|
|
|
|
export default report("food-kinds");
|