Let a meal say what kind of food it was

Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.

The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.

Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.

Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.

The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.

One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.

A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
This commit is contained in:
Alexander Heldt
2026-09-22 10:31:27 +00:00
parent 9e47aa53ff
commit 5a08fb4510
10 changed files with 1082 additions and 43 deletions
+456 -23
View File
@@ -24,6 +24,7 @@
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
const foodKindsKey = () => `puppy-tracker:${currentUser.id}:foodkinds:v1`;
const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000;
@@ -340,7 +341,7 @@
return null;
}
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
function addEvent(type, note, at, { photoId, weight, grams, exerciseId, foodKindId } = {}) {
const events = loadAll();
const now = Date.now();
const ev = {
@@ -352,6 +353,8 @@
weight: Number.isFinite(weight) ? weight : undefined,
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "",
// "" is a real value here — no kind — not a missing one.
foodKindId: foodKindId || NO_KIND,
updatedAt: now,
// Only set when logging through a guest link, and only so the badge and
// the "you may edit this" check work before the first sync: the server
@@ -444,6 +447,131 @@
return new Map(loadExercises().map(x => [x.id, x.name]));
}
// 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.
//
// 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) {
const kinds = liveFoodKinds();
fieldEl.hidden = kinds.length === 0;
if (kinds.length === 0) return;
pickerEl.innerHTML = "";
const chip = (id, name, colorIndex) => {
const b = document.createElement("button");
b.type = "button";
b.className = "kind-chip" + (id === selectedId ? " active" : "");
if (id !== NO_KIND) b.dataset.color = String(colorIndex % FOOD_COLORS);
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", String(id === selectedId));
b.textContent = name;
b.addEventListener("click", () => onPick(id));
pickerEl.appendChild(b);
};
for (const k of kinds) chip(k.id, k.name, k.colorIndex ?? 0);
chip(NO_KIND, "No kind", 0);
}
// ---------- food kinds ----------
// A meal can be labelled with a sort of food the user names themselves
// ("Dry", "Fresh"). The same collection contract as exercises: uuid ids, LWW
// on updatedAt, tombstoned deletes so a meal logged against a kind that has
// since been deleted still shows what it was.
//
// No kind is a real answer, not a missing one. Every meal logged before this
// existed has no kind, and nobody is made to invent a taxonomy before they
// can record that the dog ate — so "" is a first-class value throughout, and
// an app with no kinds defined behaves exactly as it did.
const NO_KIND = "";
// How many colours the palette holds before it wraps (see --food-N in the
// stylesheet). Kinds beyond this share a colour, which is a far better
// failure than running out of chart.
const FOOD_COLORS = 6;
function loadFoodKinds() {
try {
const parsed = JSON.parse(localStorage.getItem(foodKindsKey()));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveFoodKinds(list) {
localStorage.setItem(foodKindsKey(), JSON.stringify(list));
}
// Creation order, not alphabetical: it matches the palette (colorIndex is
// assigned in the same order) and keeps the stack's layers from reshuffling
// under you when a kind is renamed.
function liveFoodKinds() {
return loadFoodKinds()
.filter(k => !k.deleted)
.sort((a, b) => (a.colorIndex ?? 0) - (b.colorIndex ?? 0));
}
function addFoodKind(name) {
const list = loadFoodKinds();
// Counted over every kind ever, tombstones included, so deleting one never
// shifts the colour of another and silently repaints old charts.
const colorIndex = list.length;
list.push({ id: uuid(), name, colorIndex, isDefault: false, updatedAt: Date.now() });
saveFoodKinds(list);
scheduleSync();
render();
return list[list.length - 1];
}
function updateFoodKind(id, patch) {
saveFoodKinds(loadFoodKinds().map(k =>
k.id === id ? { ...k, ...patch, updatedAt: Date.now() } : k
));
scheduleSync();
render();
}
function deleteFoodKind(id) {
// Tombstone, like an exercise: meals keep referencing the id and
// foodKindNames still resolves it, so history stays readable.
saveFoodKinds(loadFoodKinds().map(k =>
k.id === id ? { ...k, deleted: true, updatedAt: Date.now() } : k
));
scheduleSync();
render();
}
// Exactly one kind is the default, or none — in which case new meals start
// with no kind, which is also where everyone starts. Passing NO_KIND clears
// it. Every other kind is unflagged in the same pass, so two devices that
// each set a different default converge on one rather than showing two.
function setDefaultFoodKind(id) {
const now = Date.now();
saveFoodKinds(loadFoodKinds().map(k => {
const shouldBe = k.id === id;
return k.isDefault === shouldBe ? k : { ...k, isDefault: shouldBe, updatedAt: now };
}));
scheduleSync();
render();
}
// Which kind a new meal starts with. Newest flag wins, so a sync race between
// two devices that each chose a default resolves rather than picking at
// random; no flag at all means no kind.
function defaultFoodKindId() {
const flagged = liveFoodKinds().filter(k => k.isDefault);
if (flagged.length === 0) return NO_KIND;
return flagged.reduce((a, b) => ((b.updatedAt || 0) > (a.updatedAt || 0) ? b : a)).id;
}
// id -> name across *all* kinds, tombstones included, for the same reason
// exerciseNames keeps them: a deleted kind's meals should still say what
// they were.
function foodKindNames() {
return new Map(loadFoodKinds().map(k => [k.id, k.name]));
}
// ---------- helpers ----------
function ymd(date) {
const y = date.getFullYear();
@@ -1304,6 +1432,7 @@
const rails = historyRails(events, chronological, day);
const dayEvents = [...chronological].reverse();
const exNames = exerciseNames();
const kindNames = foodKindNames();
eventList.innerHTML = "";
if (dayEvents.length === 0) {
emptyState.hidden = false;
@@ -1339,9 +1468,14 @@
const noteEl = li.querySelector(".note");
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
} else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
const g = `${Math.round(ev.grams)} g`;
noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
} else if (ev.type === "eat") {
// Amount and kind are both optional, so the row shows whichever it has.
const bits = [];
if (Number.isFinite(ev.grams) && ev.grams > 0) bits.push(`${Math.round(ev.grams)} g`);
const kindName = ev.foodKindId ? kindNames.get(ev.foodKindId) : "";
if (kindName) bits.push(kindName);
if (ev.note) bits.push(ev.note);
noteEl.textContent = bits.join(" · ");
} else {
noteEl.textContent = ev.note || "";
}
@@ -1515,6 +1649,16 @@
// leaving the reader to assume the first.
mealsMissingGrams: dayEvents
.filter(e => e.type === "eat" && !(Number.isFinite(e.grams) && e.grams > 0)).length,
// The same total, split by kind. The plain `grams` above stays: it is
// still what the axis, the tooltip and the day's overview want, and
// keeping both means the split can never disagree with the total.
gramsByKind: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((acc, e) => {
const id = e.foodKindId || NO_KIND;
acc[id] = (acc[id] || 0) + e.grams;
return acc;
}, {}),
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
});
}
@@ -1734,12 +1878,14 @@
// up over the day — a moving line that reflects the clock rather than the
// puppy. The line is drawn only across the days it was fitted on, so it never
// implies it knows about the ones it skipped.
function foodTrend(days) {
// valueOf picks which figure to fit: the day total by default, or one
// kind's share of it when the bars are split.
function foodTrend(days, valueOf = (d) => d.grams) {
const pts = [];
days.forEach((d, i) => {
if (d.excluded) return;
if (i === days.length - 1) return; // today, still being eaten
pts.push({ x: i, y: d.grams });
pts.push({ x: i, y: valueOf(d) });
});
// Two points always fit a line perfectly and say nothing; four is the least
// that can show a direction rather than a coincidence.
@@ -1790,6 +1936,7 @@
const gap = days.length > 14 ? 2 : 4;
const barW = (innerW - (days.length - 1) * gap) / days.length;
const series = foodSeriesFor(days);
const parts = [];
for (let i = 0; i <= ySteps; i++) {
@@ -1813,12 +1960,34 @@
}
if (d.excluded) {
parts.push(excludedSlot(d, x, barW, MT, innerH));
} else {
} else if (series.length <= 1) {
// Nothing to split: one bar, exactly as before kinds existed. This is
// the shape an account that never defines a kind always sees.
parts.push(
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>`
);
} else {
// Stacked, in the series' own order so the layers never reshuffle
// between days. Segments are squared off; only the whole bar is
// rounded, or every layer would show a notch.
let below = 0;
series.forEach((s, si) => {
const g = s.of(d);
if (!(g > 0)) return;
const segH = (g / yMax) * innerH;
const segY = MT + innerH - ((below + g) / yMax) * innerH;
below += g;
const rounded = si === series.length - 1 || below >= d.grams - 0.001;
parts.push(
`<rect class="bar bar-food-kind ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
(s.colorIndex == null ? "" : `data-color="${s.colorIndex % FOOD_COLORS}" `) +
`x="${x}" y="${segY.toFixed(1)}" width="${barW}" height="${Math.max(0, segH).toFixed(1)}" ` +
`rx="${rounded ? 3 : 0}">` +
`<title>${escapeText(`${title} · ${s.name} ${Math.round(g)} g`)}</title></rect>`
);
});
}
if (showDayLabel(i, days.length) || isSel) {
parts.push(
@@ -1828,22 +1997,93 @@
}
});
// The trend goes on top of the bars, and only across the days it was fitted
// on. Clamped to the plot area so a steep fit can't draw outside the axes.
const trend = foodTrend(days);
if (trend) {
const cx = (i) => ML + i * (barW + gap) + barW / 2;
const cy = (g) => MT + innerH * (1 - Math.min(Math.max(g, 0), yMax) / yMax);
// A trend line per series, on top of the bars and only across the days it
// was fitted on. Clamped to the plot area so a steep fit can't draw outside
// the axes.
//
// Each line sits at its own kind's daily amount, not at the top of that
// kind's segment — the segment's height is what the kind ate, but its
// position is an accident of what is stacked beneath it. So a line can
// cross a segment it doesn't belong to; it is dashed and in the kind's own
// colour, and the sentence underneath names the figures either way.
const cx = (i) => ML + i * (barW + gap) + barW / 2;
const cy = (g) => MT + innerH * (1 - Math.min(Math.max(g, 0), yMax) / yMax);
for (const s of series) {
if (!s.trend) continue;
parts.push(
`<line class="food-trend" x1="${cx(trend.first).toFixed(1)}" y1="${cy(trend.at(trend.first)).toFixed(1)}" ` +
`x2="${cx(trend.last).toFixed(1)}" y2="${cy(trend.at(trend.last)).toFixed(1)}"/>`
`<line class="food-trend" ` +
(s.colorIndex == null ? "" : `data-color="${s.colorIndex % FOOD_COLORS}" `) +
`x1="${cx(s.trend.first).toFixed(1)}" y1="${cy(s.trend.at(s.trend.first)).toFixed(1)}" ` +
`x2="${cx(s.trend.last).toFixed(1)}" y2="${cy(s.trend.at(s.trend.last)).toFixed(1)}"/>`
);
}
renderFoodTrendNote(days, trend);
renderFoodLegend(series);
renderFoodTrendNote(days, series);
setChartSVG(svg, parts);
}
// Which kinds the window actually holds food for, in a stable order: the
// kinds in creation order, then "No kind" last. A kind with nothing logged
// this window is left out rather than shown as an empty legend entry.
//
// With no kinds defined this returns a single unnamed series, which is what
// makes the whole feature invisible to anyone not using it.
function foodSeriesFor(days) {
const names = foodKindNames();
const used = (id) => days.some(d => (d.gramsByKind?.[id] || 0) > 0);
const out = [];
for (const k of liveFoodKinds()) {
if (!used(k.id)) continue;
out.push({
id: k.id, name: k.name, colorIndex: k.colorIndex ?? 0,
of: (d) => d.gramsByKind?.[k.id] || 0,
});
}
// Kinds deleted since, but still on meals in this window: their food is
// real and has to appear somewhere, under the name the tombstone kept.
for (const d of days) {
for (const id of Object.keys(d.gramsByKind || {})) {
if (id === NO_KIND || out.some(s => s.id === id)) continue;
if (!used(id)) continue;
out.push({
id, name: names.get(id) || "Deleted kind", colorIndex: null,
of: (dd) => dd.gramsByKind?.[id] || 0,
});
}
}
if (used(NO_KIND) || out.length === 0) {
out.push({
id: NO_KIND, name: "No kind", colorIndex: null,
of: (d) => d.gramsByKind?.[NO_KIND] || 0,
});
}
for (const s of out) s.trend = foodTrend(days, s.of);
return out;
}
function renderFoodLegend(series) {
const el = document.getElementById("grams-legend");
if (!el) return;
// One series is the unsplit chart; a legend naming it would be noise.
el.hidden = series.length <= 1;
if (el.hidden) return;
el.innerHTML = "";
for (const s of series) {
const chip = document.createElement("span");
chip.className = "lg";
const sw = document.createElement("span");
sw.className = "sw food-sw";
if (s.colorIndex != null) sw.dataset.color = String(s.colorIndex % FOOD_COLORS);
chip.appendChild(sw);
const text = document.createElement("span");
text.textContent = s.name;
chip.appendChild(text);
el.appendChild(chip);
}
}
// Says what the line means, and what it cannot mean. Kept in words under the
// chart rather than as a figure on it: "up 40 g a week" is a claim, and it
// needs the room to be qualified.
@@ -1854,8 +2094,11 @@
//
// Figures are rounded to 10 g. The fitted endpoints are model output, not
// measurements — quoting "287 g" would dress a guess up as a reading.
function foodTrendSentence(trend, windowDays) {
// bare: drop the "Over the last N days" opener, for when the caller is
// listing several kinds and has already said which window they share.
function foodTrendSentence(trend, windowDays, { bare = false } = {}) {
const window = `the last ${windowDays} days`;
const opener = bare ? "" : `Over ${window}, `;
if (!trend) {
// Says why there is no line. Without this the chart looks broken on a
// short window, or on one where most days are marked.
@@ -1868,25 +2111,58 @@
if (!trend.clear || slight) {
// The average is a real measurement and survives the noise; the fitted
// endpoints would not, so they are not quoted here.
return `Over ${window}, daily intake is roughly steady, averaging about ` +
`${round10(trend.mean)} g a day — day-to-day variation is larger than any trend.`;
return `${opener}${bare ? "roughly steady" : "daily intake is roughly steady"}, averaging about ` +
`${round10(trend.mean)} g a day${bare ? "" : " — day-to-day variation is larger than any trend"}.`;
}
// The change is derived from the *rounded* ends rather than from the slope,
// so that subtracting the two figures on screen gives exactly the figure
// quoted. A reader who checks the arithmetic has to find it correct.
const from = round10(trend.at(trend.first));
const to = round10(trend.at(trend.last));
return `Over ${window}, daily intake is ${to > from ? "up" : "down"} about ` +
return `${opener}${bare ? "" : "daily intake is "}${to > from ? "up" : "down"} about ` +
`${Math.abs(to - from)} g — from roughly ${from} g a day to ${to} g.`;
}
function renderFoodTrendNote(days, trend) {
// One sentence per kind would grow with the list and bury the answer, so
// only kinds with something to report get a sentence of their own and the
// rest are folded into a clause. Both rules are foodTrend's, not new ones:
// a kind with too few days has no fit, and one whose move is under its own
// scatter is not claimed.
function foodSeriesSentences(series, windowDays) {
// Unsplit: the original single sentence, unchanged.
if (series.length <= 1) return [foodTrendSentence(series[0]?.trend ?? null, windowDays)];
const moving = series.filter(s => s.trend && foodTrendMoves(s.trend));
const rest = series.filter(s => !moving.includes(s));
const out = moving.map(s => `${s.name}: ${foodTrendSentence(s.trend, windowDays, { bare: true })}`);
if (rest.length > 0) {
const names = rest.map(s => s.name);
const list = names.length === 1 ? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
out.push(moving.length === 0
? `Over the last ${windowDays} days, no kind shows a trend bigger than its day-to-day variation (${list}).`
: `${list} ${rest.length === 1 ? "shows" : "show"} no clear trend.`);
}
return out;
}
// Whether the sentence for this fit would name a direction, so the caller can
// decide which kinds are worth a sentence of their own. Same two tests the
// sentence itself applies.
function foodTrendMoves(trend) {
if (!trend) return false;
const slight = Math.abs(trend.change) < 5 || Math.abs(trend.change) < trend.mean * 0.05;
return trend.clear && !slight;
}
function renderFoodTrendNote(days, series) {
const note = document.getElementById("grams-note");
if (!note) return;
// The window comes from the 7/14/30 picker, and naming it is the only way
// the reader can tell that switching it changed the answer — the line
// itself often moves too little to notice.
const lines = [foodTrendSentence(trend, chartDays())];
const lines = foodSeriesSentences(series, chartDays());
const missing = days.reduce((s, d) => s + (d.mealsMissingGrams || 0), 0);
if (missing > 0) {
@@ -3067,6 +3343,8 @@
// After the lists, so a pick whose event has gone is dropped in the same
// pass that stops drawing it as picked.
renderMeasureBar(events);
// Adding or renaming a kind re-renders; keep the open Settings list in step.
if (settingsDialog.open) renderFoodKindSettings();
// These take the whole list even though they aggregate, because each
// already knows about marked days and does something more precise with
// them than dropping their events would:
@@ -3180,6 +3458,16 @@
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const kindRes = await fetch("api/foodkinds/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
// A guest reads the library but never writes to it, same as exercises.
body: JSON.stringify({ foodKinds: isGuest() ? [] : loadFoodKinds() }),
});
if (kindRes.status === 401) { handleLoggedOut(); return; }
if (!kindRes.ok) throw new Error(`HTTP ${kindRes.status}`);
const kindBody = await kindRes.json();
const exRes = await fetch("api/exercises/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -3191,6 +3479,9 @@
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
const exBody = await exRes.json();
if (Array.isArray(kindBody.foodKinds)) {
mergeSynced(kindBody.foodKinds, loadFoodKinds, saveFoodKinds, isGuest);
}
if (Array.isArray(exBody.exercises)) {
// Same reasoning as the events below: a guest's local exercise edits
// can never land, so the server's copy is always the truth.
@@ -3275,6 +3566,36 @@
const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight");
const noteGramsField = document.getElementById("note-grams-field");
const noteKindField = document.getElementById("note-kind-field");
const noteKindPicker = document.getElementById("note-kind-picker");
const noteKindNew = document.getElementById("note-kind-new");
const noteKindName = document.getElementById("note-kind-name");
// Which kind the dialog currently has selected. Held here rather than read
// off the DOM so the picker can be redrawn (after adding a kind) without
// losing the choice.
let notePickedKind = NO_KIND;
function drawNoteKindPicker() {
renderKindPicker(noteKindPicker, noteKindField, notePickedKind, (id) => {
notePickedKind = id;
drawNoteKindPicker();
});
}
// Inventing a kind mid-log: it is created, selected, and the box clears, so
// you carry on logging the meal you came here for.
document.getElementById("note-kind-add").addEventListener("click", () => {
const name = noteKindName.value.trim();
if (!name) return;
notePickedKind = addFoodKind(name).id;
noteKindName.value = "";
drawNoteKindPicker();
});
noteKindName.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault(); // the dialog's default button would otherwise save
document.getElementById("note-kind-add").click();
});
const noteGrams = document.getElementById("note-grams");
let pendingType = null;
let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
@@ -3352,6 +3673,13 @@
noteWeight.value = "";
noteGramsField.hidden = !isEat;
noteGrams.value = "";
// A new meal starts on whichever kind is the default, which may well be no
// kind — that is the starting state and stays a legitimate choice.
notePickedKind = defaultFoodKindId();
noteKindNew.hidden = isGuest(); // inventing a kind is the owner's
noteKindName.value = "";
if (isEat) drawNoteKindPicker();
else noteKindField.hidden = true;
clearNotePhotos();
noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
@@ -3421,7 +3749,7 @@
}
photoIds.push(id);
}
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams, foodKindId: notePickedKind });
pendingType = null;
clearNotePhotos();
noteDialog.close();
@@ -3440,6 +3768,17 @@
const editTime = document.getElementById("edit-time");
const editNote = document.getElementById("edit-note");
const editLoggedBy = document.getElementById("edit-logged-by");
const editKindField = document.getElementById("edit-kind-field");
const editKindPicker = document.getElementById("edit-kind-picker");
let editPickedKind = NO_KIND;
let editType = "";
function drawEditKindPicker() {
renderKindPicker(editKindPicker, editKindField, editPickedKind, (id) => {
editPickedKind = id;
drawEditKindPicker();
});
}
const editReadOnly = document.getElementById("edit-readonly");
const editTitle = document.getElementById("edit-title");
const editDelete = document.getElementById("edit-delete");
@@ -3493,6 +3832,7 @@
async function openEditDialog(ev) {
editingId = ev.id;
editType = ev.type;
editLoggedBy.hidden = !ev.loggedBy;
if (ev.loggedBy) editLoggedBy.textContent = `Logged by ${ev.loggedBy} on a guest link.`;
setEditReadOnly(!canEditEvent(ev));
@@ -3503,6 +3843,10 @@
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
editGramsField.hidden = ev.type !== "eat";
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
// The kind as it stands, so leaving the dialog alone changes nothing.
editPickedKind = ev.foodKindId || NO_KIND;
if (ev.type === "eat") drawEditKindPicker();
else editKindField.hidden = true;
resetEditPhotos();
for (const id of photoIdsOf(ev)) {
editPhotos.push({ id, url: await photoSrc(id) });
@@ -3564,6 +3908,8 @@
photoIds.push(id);
}
patch.photoId = photoIds.join(",");
// Only meals carry a kind; writing it on other types would be noise.
if (editType === "eat") patch.foodKindId = editPickedKind;
updateEvent(editingId, patch);
editingId = null;
resetEditPhotos();
@@ -3633,6 +3979,9 @@
settingsProfile.hidden = guest;
settingsDanger.hidden = guest;
guestAccess.hidden = guest;
// The library is the owner's, like the exercise list.
foodKindSection.hidden = guest;
if (!guest) renderFoodKindSettings();
settingsDialog.showModal();
refreshRemindersUI();
if (!guest) {
@@ -3687,6 +4036,89 @@
settingsDialog.close();
});
// ---------- food kinds in Settings ----------
// The library: add, rename, delete, and choose which one a new meal starts
// on. Renaming is in-place rather than through a dialog — there is one field
// to change, and a dialog for a single text box is a tax.
const foodKindSection = document.getElementById("food-kinds-section");
const foodKindList = document.getElementById("food-kind-list");
const foodKindEmpty = document.getElementById("food-kind-empty");
const foodKindName = document.getElementById("food-kind-name");
function renderFoodKindSettings() {
const kinds = liveFoodKinds();
foodKindEmpty.hidden = kinds.length > 0;
foodKindList.innerHTML = "";
const defaultId = defaultFoodKindId();
for (const k of kinds) {
const li = document.createElement("li");
li.className = "food-kind-item";
const swatch = document.createElement("span");
swatch.className = "food-kind-swatch";
swatch.dataset.color = String((k.colorIndex ?? 0) % FOOD_COLORS);
li.appendChild(swatch);
// The name is the input: typing renames it, which is the whole edit.
const name = document.createElement("input");
name.type = "text";
name.className = "food-kind-name";
name.value = k.name;
name.maxLength = 30;
name.setAttribute("aria-label", `Name of ${k.name}`);
const commit = () => {
const next = name.value.trim();
if (!next || next === k.name) { name.value = k.name; return; }
updateFoodKind(k.id, { name: next });
};
name.addEventListener("blur", commit);
name.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); name.blur(); } });
li.appendChild(name);
// Tapping the star sets the default; tapping the one that already is
// clears it, which is how you get back to starting on "No kind".
const star = document.createElement("button");
star.type = "button";
star.className = "food-kind-default" + (k.id === defaultId ? " active" : "");
star.textContent = k.id === defaultId ? "★" : "☆";
star.title = k.id === defaultId
? "The default for a new meal — tap to start on No kind instead"
: "Make this the default for a new meal";
star.setAttribute("aria-pressed", String(k.id === defaultId));
star.addEventListener("click", () => setDefaultFoodKind(k.id === defaultId ? NO_KIND : k.id));
li.appendChild(star);
const del = document.createElement("button");
del.type = "button";
del.className = "linklike food-kind-delete";
del.textContent = "✕";
del.setAttribute("aria-label", `Delete ${k.name}`);
del.addEventListener("click", () => {
// Meals keep the id and the tombstone keeps the name, so nothing in
// the history becomes unreadable — worth saying, since "delete" on a
// thing other records point at sounds more destructive than it is.
if (!confirm(`Delete the kind “${k.name}”? Meals already logged as it keep their label.`)) return;
deleteFoodKind(k.id);
});
li.appendChild(del);
foodKindList.appendChild(li);
}
}
document.getElementById("food-kind-add").addEventListener("click", () => {
const name = foodKindName.value.trim();
if (!name) return;
addFoodKind(name);
foodKindName.value = "";
});
foodKindName.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
document.getElementById("food-kind-add").click();
});
// ---------- guest links ----------
// Hand someone a URL that logs events on this account without giving them the
// password. The server holds only a hash of the token (server/auth.go), so the
@@ -5580,6 +6012,7 @@
localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey());
localStorage.removeItem(exercisesKey());
localStorage.removeItem(foodKindsKey());
} catch { /* ignore */ }
}