diff --git a/README.md b/README.md index 48526b1..3289494 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,32 @@ of them, because logging has to be one tap from wherever you are. setting — but being module-level is what carries them through the re-render a background sync causes every minute. Long-press has no keyboard equivalent, so this is touch and mouse only. +- **The Meals log** in Habits lists every meal across every day, beside the + Food (grams) chart, ignoring the day picker as the Notes log does — the meals + worth finding here are spread through history, not on the day you happen to + be viewing. It exists because a meal's two extras, its amount and its kind, + are both optional by design, which is exactly what makes them easy to lose: an + unlabelled meal sits in a grey band on the chart, one with no grams is dropped + from it silently, and neither was findable. Each filter chip carries its own + count, so the size of the gap reads off the panel without selecting anything. + Rows go through `attachRowHandlers` like every other log, so tapping one opens + the same edit dialog and long-press still measures. The list is capped at + `MEALS_LOG_PAGE` with a *Show more* — a year of logging is a thousand rows, + and `render()` draws every panel on every pass. Meals are the only type that + gets this: they are the only events with optional fields worth filling in + afterwards, a pee being a pee and a weigh-in being refused without a number. +- **Labelling old meals in bulk.** Kinds arrived after months of logging, so + everything recorded before them has none. `backfillFoodKind` sets one kind on + every meal the current filter lists in a single pass — one save, one sync, one + render, following `setDefaultFoodKind` rather than calling `updateEvent` + hundreds of times. It costs nothing extra on the wire either, since sync + already posts the whole event list. The optional *Only before* date is the + part that matters: 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 + from correctly labelled ones. The cut-off filters the list as well as the + edit, so the number on the button is the rows on screen. There is no undo — + the guards are the live count, the number on the button, and a confirm. + Owner-only, and the server's guest rules would refuse it anyway. - Each tab is a `.tab-panel` wrapper around the existing sections. The **wrapper** is what gets hidden, never the sections: `walk-timeline` and `walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk diff --git a/checks/food-kinds.mjs b/checks/food-kinds.mjs index 3b1ca00..cdfb4a3 100644 --- a/checks/food-kinds.mjs +++ b/checks/food-kinds.mjs @@ -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"); diff --git a/src/app.js b/src/app.js index b2af2aa..a9b8e9e 100644 --- a/src/app.js +++ b/src/app.js @@ -499,12 +499,14 @@ } // The chips both dialogs use. Built from the live kinds plus "No kind", - // which always comes last and is always offered — a meal is allowed to have - // no kind, permanently, and the picker should never imply otherwise. + // which always comes last — a meal is allowed to have no kind, permanently, + // and the picker should never imply otherwise. The bulk label in the meals + // log is the one caller that drops it, labelling unlabelled meals as + // unlabelled being the one thing it cannot mean. // // Hidden entirely when no kinds are defined, so the app looks exactly as it // did to anyone who never wants them. onPick receives the chosen id. - function renderKindPicker(pickerEl, fieldEl, selectedId, onPick) { + function renderKindPicker(pickerEl, fieldEl, selectedId, onPick, { includeNoKind = true } = {}) { const kinds = liveFoodKinds(); fieldEl.hidden = kinds.length === 0; if (kinds.length === 0) return; @@ -522,7 +524,7 @@ pickerEl.appendChild(b); }; for (const k of kinds) chip(k.id, k.name, k.colorIndex ?? 0); - chip(NO_KIND, "No kind", 0); + if (includeNoKind) chip(NO_KIND, "No kind", 0); } // ---------- food kinds ---------- @@ -623,6 +625,61 @@ return new Map(loadFoodKinds().map(k => [k.id, k.name])); } + // ---------- labelling meals after the fact ---------- + // Kinds arrived after months of logging, so history is full of meals that + // carry none. The Meals log in Habits lists them; these are the rules it + // filters by, and the one bulk edit it offers. + // + // What "missing" means, given both fields are legitimately optional: a meal + // has no kind when foodKindId is "", and no amount when grams is absent or + // zero — the same test the charts use to decide there is nothing to plot. + const MEAL_GAPS = { + all: () => true, + kind: (e) => !e.foodKindId, + amount: (e) => !(Number.isFinite(e.grams) && e.grams > 0), + }; + + // The meals a filter selects, newest first. Pure, so the rules above can be + // checked directly. beforeYmd is optional and takes meals strictly before the + // start of that day, which is what makes a bulk label safe when the food was + // switched partway: everything on or after the cut-off keeps what it has. + // + // Deliberately not filtered on: days marked "not counted". That mark means + // "leave this out of the averages", not "this did not happen" — the meal is + // still a record of what was fed, and labelling it keeps the record whole. + function mealsMissing(events, gap, beforeYmd) { + const test = MEAL_GAPS[gap] || MEAL_GAPS.all; + let cut = Infinity; + if (beforeYmd) { + const t = new Date(`${beforeYmd}T00:00:00`).getTime(); + if (Number.isFinite(t)) cut = t; + } + return events + .filter(e => e.type === "eat" && !e.deleted && e.at < cut && test(e)) + .sort((a, b) => b.at - a.at); + } + + // Give every unlabelled meal the same kind in one pass: one save, one sync, + // one render, as setDefaultFoodKind does for the kinds themselves. Calling + // updateEvent per meal would re-render and re-queue a sync hundreds of times + // over. It costs nothing extra on the wire either — sync already posts the + // whole event list, so the server simply sees more rows with newer stamps. + // + // loggedBy / loggedByShare are left alone, so a sitter's meals keep their + // attribution while gaining a kind. Returns how many it changed. + function backfillFoodKind(kindId, beforeYmd) { + if (!kindId) return 0; + const ids = new Set(mealsMissing(loadAll(), "kind", beforeYmd).map(e => e.id)); + if (ids.size === 0) return 0; + const now = Date.now(); + saveAll(loadAll().map(e => + ids.has(e.id) ? { ...e, foodKindId: kindId, updatedAt: now } : e + )); + scheduleSync(); + render(); + return ids.size; + } + // ---------- helpers ---------- function ymd(date) { const y = date.getFullYear(); @@ -1598,6 +1655,157 @@ } } + // ---------- the meals log ---------- + // A cross-day list of every meal, filterable by what a meal is missing. Both + // of a meal's extras are optional by design, which is exactly what makes them + // easy to lose track of: a meal with no kind sits in a grey band on the chart + // and a meal with no amount is dropped from it silently, and until this panel + // there was nowhere to go and see which meals those were. + // + // Meals only. They are the one event type with optional fields worth filling + // in afterwards — a pee is a pee, a walk is a span, and a weigh-in is refused + // without a number — so this stays a meals log rather than a general one. + const MEALS_LOG_PAGE = 50; + const MEAL_GAP_LABELS = { all: "All", kind: "No kind", amount: "No amount" }; + + let mealsLogGap = "all"; + let mealsLogCut = ""; // "" = no cut-off + let mealsLogShown = MEALS_LOG_PAGE; + let mealsLogKind = NO_KIND; // what the bulk label would apply; none until picked + + function renderMealsLog(events) { + const wrap = document.getElementById("meals-log"); + wrap.hidden = !events.some(e => e.type === "eat" && !e.deleted); + if (wrap.hidden) return; + + const kinds = liveFoodKinds(); + const kindNames = foodKindNames(); + // Filtering on a missing kind says nothing before any kind exists: every + // meal would match. The chip stays hidden, so an account that never uses + // kinds sees a plain list. + const usingKinds = kinds.length > 0; + if (!usingKinds && mealsLogGap === "kind") mealsLogGap = "all"; + + const rows = mealsMissing(events, mealsLogGap, mealsLogCut); + const counts = { + all: mealsMissing(events, "all", mealsLogCut).length, + kind: mealsMissing(events, "kind", mealsLogCut).length, + amount: mealsMissing(events, "amount", mealsLogCut).length, + }; + + // The counts ride on the chips, so how much is unlabelled can be read + // without selecting anything — which is the main thing this panel is for. + for (const btn of document.querySelectorAll("#meals-log-filter .kind-chip")) { + const gap = btn.dataset.gap; + const on = gap === mealsLogGap; + btn.hidden = gap === "kind" && !usingKinds; + btn.classList.toggle("active", on); + btn.setAttribute("aria-checked", String(on)); + btn.textContent = `${MEAL_GAP_LABELS[gap]} ${counts[gap]}`; + } + + const list = document.getElementById("meals-log-list"); + const empty = document.getElementById("meals-log-empty"); + list.innerHTML = ""; + empty.hidden = rows.length > 0; + empty.textContent = + mealsLogGap === "kind" ? "Every meal here has a kind." : + mealsLogGap === "amount" ? "Every meal here has an amount." : + "No meals before that date."; + + const shown = rows.slice(0, mealsLogShown); + for (const ev of shown) { + const li = document.createElement("li"); + li.className = "event"; + li.dataset.type = "eat"; + li.dataset.id = ev.id; + li.innerHTML = ` + + + + `; + const when = new Date(ev.at); + li.querySelector(".note-date").textContent = + `${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${formatTime(ev.at)}`; + // What is missing is named rather than left blank — the gaps are the + // point here, unlike the History row, which just omits what it hasn't got. + const bits = []; + bits.push(Number.isFinite(ev.grams) && ev.grams > 0 ? `${Math.round(ev.grams)} g` : "no amount"); + if (usingKinds) { + bits.push(kindNames.get(ev.foodKindId) || (ev.foodKindId ? "Deleted kind" : "no kind")); + } + if (ev.note) bits.push(ev.note); + li.querySelector(".note-text").textContent = bits.join(" · "); + // Same handlers as every other row: tap to edit (where a kind or an + // amount can be filled in one at a time), long-press to measure. + attachRowHandlers(li, ev); + list.appendChild(li); + } + + // Capped rather than paged: a year of logging is a thousand rows, and + // building them all on every render would cost more than it shows. + const more = document.getElementById("meals-log-more"); + const rest = rows.length - shown.length; + more.hidden = rest <= 0; + more.textContent = `Show ${Math.min(rest, MEALS_LOG_PAGE)} more`; + + // The bulk label sits on the list rather than in Settings, so the number on + // the button is the rows being looked at. Owner-only: a guest may not touch + // the owner's meals, and the server would refuse it anyway. + const bulk = document.getElementById("meals-log-bulk"); + const canBulk = usingKinds && !isGuest() && mealsLogGap === "kind" && rows.length > 0; + bulk.hidden = !canBulk; + if (canBulk) { + if (mealsLogKind && !kinds.some(k => k.id === mealsLogKind)) mealsLogKind = NO_KIND; + // No "No kind" chip: labelling unlabelled meals as unlabelled is the one + // thing this cannot mean. + renderKindPicker(document.getElementById("meals-log-kind-picker"), bulk, mealsLogKind, + (id) => { mealsLogKind = id; renderMealsLog(live()); }, { includeNoKind: false }); + const apply = document.getElementById("meals-log-apply"); + // Nothing is preselected, so labelling hundreds of meals always takes a + // deliberate pick first. + apply.disabled = !mealsLogKind; + apply.textContent = `Label ${rows.length} ${rows.length === 1 ? "meal" : "meals"}`; + } + } + + document.getElementById("meals-log-filter").addEventListener("click", (e) => { + const btn = e.target.closest(".kind-chip"); + if (!btn) return; + mealsLogGap = btn.dataset.gap; + mealsLogShown = MEALS_LOG_PAGE; + renderMealsLog(live()); + }); + + document.getElementById("meals-log-more").addEventListener("click", () => { + mealsLogShown += MEALS_LOG_PAGE; + renderMealsLog(live()); + }); + + const mealsCutOn = document.getElementById("meals-log-before-on"); + const mealsCutDate = document.getElementById("meals-log-before"); + function mealsCutChanged() { + // An empty date with the box ticked is not a cut-off of nothing, it is no + // cut-off — otherwise ticking the box would empty the list. + if (mealsCutOn.checked && !mealsCutDate.value) mealsCutDate.value = ymd(new Date()); + mealsLogCut = mealsCutOn.checked ? mealsCutDate.value : ""; + mealsLogShown = MEALS_LOG_PAGE; + renderMealsLog(live()); + } + mealsCutOn.addEventListener("change", mealsCutChanged); + mealsCutDate.addEventListener("change", mealsCutChanged); + + document.getElementById("meals-log-apply").addEventListener("click", () => { + if (!mealsLogKind) return; + const n = mealsMissing(live(), "kind", mealsLogCut).length; + if (n === 0) return; + const name = foodKindNames().get(mealsLogKind) || "this kind"; + // The only guard, so it names both numbers the decision turns on. + if (!confirm(`Label ${n} ${n === 1 ? "meal" : "meals"} as ${name}?\n\nThis cannot be undone.`)) return; + backfillFoodKind(mealsLogKind, mealsLogCut); + mealsLogKind = NO_KIND; + }); + // ---------- lightbox ---------- const lightbox = document.getElementById("lightbox"); const lightboxImg = document.getElementById("lightbox-img"); @@ -3447,6 +3655,7 @@ // logger distorts, so they count everywhere regardless. renderWeight(events); renderNotes(events); + renderMealsLog(events); // After the lists, so a pick whose event has gone is dropped in the same // pass that stops drawing it as picked. renderMeasureBar(events); diff --git a/src/changelog.json b/src/changelog.json index e4549b8..21201dc 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "date": "2026-09-22", "text": "Habits has a Meals log beside the Food (grams) chart: every meal you have logged, newest first, across every day rather than just the one you are looking at. It is there because a meal's amount and its kind are both optional — which is what makes them easy to lose track of. A meal with no kind sits in the grey band on the chart, a meal with no amount is quietly left out of it, and until now there was nowhere to go and see which ones those were. The filter chips carry their own counts, so “No kind 412” tells you the size of the gap before you tap anything, and picking a filter lists exactly those meals; tap any row to fill in what it is missing, in the same edit dialog as everywhere else. If you started using food kinds after months of logging, the “No kind” filter also offers to label the whole list at once: choose a kind and it sets it on every meal shown. Tick “Only before” a date first if you switched foods partway, so the older meals take one kind and the newer ones keep what they have — worth getting right, because there is no undo. The panel folds away by tapping its heading like any other, and appears only once you have logged a meal; the “No kind” filter and the bulk label appear only once you have created a kind, so nothing changes for anyone not using them. A guest can see the list but cannot relabel your meals" }, { "date": "2026-09-22", "text": "Tap a bar in the Food (grams) chart and a line under it spells that day out — “Sat, Sep 20 — 340 g”, or “Dry 260 g · Fresh 100 g · 360 g in total” once you are using kinds. A phone has nothing to hover over, so the amount for a given day was previously only readable by eye off the axis. It follows whichever day is highlighted, so the ← → arrows and the date picker move it too, and it says so plainly when a day has no food logged or is marked as not counted" }, { "date": "2026-09-22", "text": "Meals can be labelled with a kind of food. Make up your own in Settings → Food kinds — dry, fresh, raw, whatever you feed — and pick one when you log a meal; tap the ★ beside one to have it chosen for you automatically. You can also invent a kind from inside the log dialog if you realise you need it mid-meal. All of it is optional: “No kind” is always offered, every meal you have already logged keeps working untouched, and with no kinds defined the app looks and behaves exactly as it did. Once you are using them, today's overview breaks the day's food down inside the Meals card — “Dry 260 g” and “Fresh 100 g” under the total they add up to — and the Food (grams) chart splits each day's bar by kind with a legend, and draws a separate trend line for each, so you can see fresh creeping up while dry comes down. The sentence underneath names only the kinds that are actually moving and folds the rest into one clause, so it stays short however many kinds you have. Renaming a kind updates the meals logged as it; deleting one keeps them readable under the name it had. A guest can label a meal with a kind you have created but cannot add, rename or delete them" }, { "date": "2026-09-21", "text": "Fixed the figures under the Food (grams) chart contradicting each other. It read like “down about 329 g a week — roughly 460 g a day then, 320 g a day now”, where subtracting the two amounts gives 140 g, not 329 g. The rate was worked out per week while the line itself only covers the complete days in the window — at most five of them on a 7-day window, since today isn't finished — so it was stretched past the days it was measured from. It now gives the change between the two ends, which is a figure you can check by subtracting them: “down about 140 g — from roughly 460 g a day to 320 g”" }, diff --git a/src/index.html b/src/index.html index 69ed70b..4d99c86 100644 --- a/src/index.html +++ b/src/index.html @@ -412,6 +412,43 @@ see renderFoodTrendNote. -->
+ + +