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:
@@ -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
|
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
|
background sync causes every minute. Long-press has no keyboard equivalent, so
|
||||||
this is touch and mouse only.
|
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
|
- Each tab is a `.tab-panel` wrapper around the existing sections. The
|
||||||
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
||||||
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
||||||
|
|||||||
@@ -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");
|
export default report("food-kinds");
|
||||||
|
|||||||
+213
-4
@@ -499,12 +499,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The chips both dialogs use. Built from the live kinds plus "No kind",
|
// 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
|
// which always comes last — a meal is allowed to have no kind, permanently,
|
||||||
// no kind, permanently, and the picker should never imply otherwise.
|
// 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
|
// 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.
|
// 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();
|
const kinds = liveFoodKinds();
|
||||||
fieldEl.hidden = kinds.length === 0;
|
fieldEl.hidden = kinds.length === 0;
|
||||||
if (kinds.length === 0) return;
|
if (kinds.length === 0) return;
|
||||||
@@ -522,7 +524,7 @@
|
|||||||
pickerEl.appendChild(b);
|
pickerEl.appendChild(b);
|
||||||
};
|
};
|
||||||
for (const k of kinds) chip(k.id, k.name, k.colorIndex ?? 0);
|
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 ----------
|
// ---------- food kinds ----------
|
||||||
@@ -623,6 +625,61 @@
|
|||||||
return new Map(loadFoodKinds().map(k => [k.id, k.name]));
|
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 ----------
|
// ---------- helpers ----------
|
||||||
function ymd(date) {
|
function ymd(date) {
|
||||||
const y = date.getFullYear();
|
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 = `
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="note-date"></span>
|
||||||
|
<span class="note-text"></span>
|
||||||
|
`;
|
||||||
|
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 ----------
|
// ---------- lightbox ----------
|
||||||
const lightbox = document.getElementById("lightbox");
|
const lightbox = document.getElementById("lightbox");
|
||||||
const lightboxImg = document.getElementById("lightbox-img");
|
const lightboxImg = document.getElementById("lightbox-img");
|
||||||
@@ -3447,6 +3655,7 @@
|
|||||||
// logger distorts, so they count everywhere regardless.
|
// logger distorts, so they count everywhere regardless.
|
||||||
renderWeight(events);
|
renderWeight(events);
|
||||||
renderNotes(events);
|
renderNotes(events);
|
||||||
|
renderMealsLog(events);
|
||||||
// After the lists, so a pick whose event has gone is dropped in the same
|
// After the lists, so a pick whose event has gone is dropped in the same
|
||||||
// pass that stops drawing it as picked.
|
// pass that stops drawing it as picked.
|
||||||
renderMeasureBar(events);
|
renderMeasureBar(events);
|
||||||
|
|||||||
@@ -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": "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-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”" },
|
{ "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”" },
|
||||||
|
|||||||
@@ -412,6 +412,43 @@
|
|||||||
see renderFoodTrendNote. -->
|
see renderFoodTrendNote. -->
|
||||||
<p id="grams-note" class="muted-note" hidden></p>
|
<p id="grams-note" class="muted-note" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Every meal logged, newest first. Like the Notes log this ignores
|
||||||
|
the day picker: the meals worth finding here are the ones missing
|
||||||
|
a kind or an amount, and those are spread across all of history
|
||||||
|
rather than sitting on the day you happen to be viewing.
|
||||||
|
|
||||||
|
Folds on its own h3 (see the data-panel machinery) because the
|
||||||
|
list grows by one every meal, and the charts above it are what
|
||||||
|
you usually want left on screen. -->
|
||||||
|
<div class="subpanel meals-log" data-panel="meals-log" id="meals-log" hidden>
|
||||||
|
<h3>Meals</h3>
|
||||||
|
<!-- Counts live on the chips, so how much is unlabelled is legible
|
||||||
|
without selecting anything. -->
|
||||||
|
<div class="kind-picker meals-filter" id="meals-log-filter" role="radiogroup" aria-label="Which meals to list">
|
||||||
|
<button type="button" class="kind-chip active" data-gap="all" role="radio" aria-checked="true">All</button>
|
||||||
|
<button type="button" class="kind-chip" data-gap="kind" role="radio" aria-checked="false" hidden>No kind</button>
|
||||||
|
<button type="button" class="kind-chip" data-gap="amount" role="radio" aria-checked="false">No amount</button>
|
||||||
|
</div>
|
||||||
|
<!-- The cut-off filters the list, not just the bulk edit, so what
|
||||||
|
the button acts on is exactly what is drawn beneath it. -->
|
||||||
|
<label class="meals-cutoff" id="meals-log-cutoff">
|
||||||
|
<input type="checkbox" id="meals-log-before-on" />
|
||||||
|
<span>Only before</span>
|
||||||
|
<input type="date" id="meals-log-before" aria-label="Only meals before this date" />
|
||||||
|
</label>
|
||||||
|
<ul id="meals-log-list" class="event-list"></ul>
|
||||||
|
<p id="meals-log-empty" class="empty" hidden></p>
|
||||||
|
<button type="button" id="meals-log-more" class="ghost meals-more" hidden></button>
|
||||||
|
<!-- The bulk label lives on the list rather than in Settings, so the
|
||||||
|
number on the button is the rows you are looking at. Owner-only:
|
||||||
|
a guest may not touch the owner's meals. -->
|
||||||
|
<div class="meals-bulk" id="meals-log-bulk" hidden>
|
||||||
|
<div class="meals-bulk-head">Label these as</div>
|
||||||
|
<div id="meals-log-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind to label these meals as"></div>
|
||||||
|
<button type="button" id="meals-log-apply" class="ghost meals-apply"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<div class="chart-title">By hour of day</div>
|
<div class="chart-title">By hour of day</div>
|
||||||
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
||||||
|
|||||||
@@ -1446,6 +1446,43 @@ button.food-kind-default {
|
|||||||
button.food-kind-default.active { color: var(--wake); }
|
button.food-kind-default.active { color: var(--wake); }
|
||||||
button.food-kind-delete { color: var(--danger); flex: none; }
|
button.food-kind-delete { color: var(--danger); flex: none; }
|
||||||
|
|
||||||
|
/* ---------- the meals log ---------- */
|
||||||
|
/* Beside the food chart in Habits: every meal, across every day, with the
|
||||||
|
count of what each filter would show carried on its own chip. */
|
||||||
|
.meals-log .kind-picker { margin-bottom: 8px; }
|
||||||
|
.meals-cutoff {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.meals-cutoff input[type="checkbox"] { flex: none; }
|
||||||
|
.meals-cutoff input[type="date"] {
|
||||||
|
flex: 1;
|
||||||
|
width: auto; /* the global input rule sets 100%, which would push the row */
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.meals-more { margin-top: 8px; width: 100%; }
|
||||||
|
/* Set off by a rule, so it reads as an action on the list above rather than
|
||||||
|
as another row of it. */
|
||||||
|
.meals-bulk {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.meals-bulk-head {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
button.meals-apply { margin-top: 10px; width: 100%; }
|
||||||
|
button.meals-apply[disabled] { opacity: 0.5; }
|
||||||
|
|
||||||
/* ---------- measuring between two events ---------- */
|
/* ---------- measuring between two events ---------- */
|
||||||
/* Fixed at the bottom, near the thumb, and it stays until cleared — the
|
/* Fixed at the bottom, near the thumb, and it stays until cleared — the
|
||||||
measurement is the answer to a question you asked, not a notification. */
|
measurement is the answer to a question you asked, not a notification. */
|
||||||
|
|||||||
Reference in New Issue
Block a user