Track food eaten by weight in grams

Logging a meal now opens the dialog (like weigh-ins) with an optional
Amount (g) field; existing meals can get an amount via the edit dialog,
where clearing the field drops it. The overview's Meals tile shows the
day's total grams, and the weekly card gains a Food (grams) chart with
a self-scaling axis that stays hidden until any meal has an amount.

Events carry a new grams field (REAL column, auto-migrated); addEvent's
growing optional parameters are folded into an options object.
This commit is contained in:
Alexander Heldt
2026-07-13 20:28:53 +00:00
parent 68964b55e4
commit 3a829161e3
5 changed files with 136 additions and 12 deletions
+101 -6
View File
@@ -257,7 +257,7 @@
return `${wk} · ${mo} old`;
}
function addEvent(type, note, at, photoId, weight, exerciseId) {
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
const events = loadAll();
const now = Date.now();
const ev = {
@@ -267,6 +267,7 @@
note: note || "",
photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined,
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "",
updatedAt: now,
};
@@ -590,6 +591,12 @@
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
document.getElementById("stat-meals").textContent = count("eat");
const gramsTotal = dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0);
const gramsEl = document.getElementById("stat-meals-grams");
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
gramsEl.hidden = !(gramsTotal > 0);
document.getElementById("stat-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo");
document.getElementById("stat-training").textContent = count("training");
@@ -827,6 +834,9 @@
pees: dayEvents.filter(e => e.type === "pee").length,
poos: dayEvents.filter(e => e.type === "poo").length,
meals: dayEvents.filter(e => e.type === "eat").length,
grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0),
});
}
return days;
@@ -854,6 +864,18 @@
return { yMax: m, steps: m / 5 };
}
// Grams axis: 0-based with a "nice" step so tick labels stay round whatever
// the daily totals are (tens of grams for a tiny puppy, hundreds+ later).
function niceAxisGrams(rawMax) {
if (!(rawMax > 0)) return { yMax: 100, steps: 2 };
const rawStep = rawMax / 4;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const norm = rawStep / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const yMax = Math.ceil(rawMax / step) * step;
return { yMax, steps: Math.max(1, Math.round(yMax / step)) };
}
// Sleep-specific axis: always 2-hour granularity, capped at 24h/day,
// for a more readable picture of typical 1018 h puppy sleep.
function niceAxisSleepHours(rawMax) {
@@ -977,10 +999,58 @@
setChartSVG(svg, parts);
}
// Grams of food per day. Hidden entirely until any meal in the window has an
// amount logged, so the weekly card doesn't grow an empty chart.
function drawGramsChart(days) {
const wrap = document.getElementById("grams-chart-wrap");
const svg = document.getElementById("chart-grams");
if (!days.some(d => d.grams > 0)) { wrap.hidden = true; return; }
wrap.hidden = false;
const W = 320, H = 160;
const ML = 34, MR = 6, MT = 10, MB = 26;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams)));
const gap = 6;
const barW = (innerW - (days.length - 1) * gap) / days.length;
const parts = [];
for (let i = 0; i <= ySteps; i++) {
const y = MT + innerH * (1 - i / ySteps);
const v = yMax * i / ySteps;
const vText = v % 1 === 0 ? v : v.toFixed(1);
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`);
}
days.forEach((d, i) => {
const isToday = i === days.length - 1;
const x = ML + i * (barW + gap);
const h = (d.grams / yMax) * innerH;
const y = MT + innerH - h;
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`;
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>`
);
parts.push(
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>`
);
});
setChartSVG(svg, parts);
}
function renderWeekly(events) {
const days = weeklyData(events);
drawSleepChart(days);
drawCountsChart(days);
drawGramsChart(days);
}
// ---------- pattern charts (last 14 days) ----------
@@ -1379,7 +1449,7 @@
logBtn.textContent = "Log";
logBtn.addEventListener("click", (e) => {
e.stopPropagation();
const ev = addEvent("training", "", Date.now(), "", undefined, ex.id);
const ev = addEvent("training", "", Date.now(), { exerciseId: ex.id });
showSnackbar(`${ex.name} logged`, ev);
});
@@ -1629,6 +1699,8 @@
const notePhotoPreview = document.getElementById("note-photo-preview");
const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight");
const noteGramsField = document.getElementById("note-grams-field");
const noteGrams = document.getElementById("note-grams");
let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
let notePhotoURL = null; // current preview object URL
@@ -1656,11 +1728,14 @@
noteTime.value = toTimeInput(now);
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
const isWeight = type === "weight";
const isEat = type === "eat";
noteWeightField.hidden = !isWeight;
noteWeight.value = "";
noteGramsField.hidden = !isEat;
noteGrams.value = "";
clearNotePhoto();
noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50);
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
}
function noteDialogAt() {
@@ -1711,6 +1786,12 @@
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
weight = Math.round(weight * 100) / 100;
}
let grams;
if (pendingType === "eat" && noteGrams.value.trim() !== "") {
const g = parseFloat(noteGrams.value);
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
grams = Math.round(g);
}
let photoId = "";
if (notePhotoBlob) {
photoId = uuid();
@@ -1720,7 +1801,7 @@
return;
}
}
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight);
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId, weight, grams });
pendingType = null;
clearNotePhoto();
noteDialog.close();
@@ -1745,6 +1826,8 @@
const editPhotoPreview = document.getElementById("edit-photo-preview");
const editWeightField = document.getElementById("edit-weight-field");
const editWeight = document.getElementById("edit-weight");
const editGramsField = document.getElementById("edit-grams-field");
const editGrams = document.getElementById("edit-grams");
let editingId = null;
let editPhotoId = ""; // current photoId for this event
let editPhotoBlob = null; // new blob chosen in this session
@@ -1770,6 +1853,8 @@
editNote.value = ev.note || "";
editWeightField.hidden = ev.type !== "weight";
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 : "";
editPhotoId = ev.photoId || "";
editPhotoCleared = false;
clearEditPhotoLocalState();
@@ -1823,6 +1908,15 @@
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
patch.weight = Math.round(kg * 100) / 100;
}
if (!editGramsField.hidden) {
if (editGrams.value.trim() === "") {
patch.grams = undefined; // cleared → drop the amount
} else {
const g = parseFloat(editGrams.value);
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
patch.grams = Math.round(g);
}
}
if (editPhotoBlob) {
const newId = uuid();
try { await putPhoto(newId, editPhotoBlob, false); }
@@ -2142,8 +2236,9 @@
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.type;
// Weigh-ins need a typed value, so they keep the full dialog.
if (type === "weight") { openNoteDialog(type); return; }
// Weigh-ins need a typed value and meals ask for grams, so those two
// keep the full dialog.
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
quickLog(type);
});
});
+1
View File
@@ -1,4 +1,5 @@
[
{ "date": "2026-07-13", "text": "Track food by weight: logging a meal asks for grams (optional), with a daily total in the overview and a weekly chart" },
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
{ "date": "2026-07-12", "text": "The update banner now lists what changed in the new version" },
+11
View File
@@ -128,6 +128,7 @@
<div class="stat">
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Pees</div>
@@ -189,6 +190,10 @@
<span class="lg eat"><span class="sw"></span>Meals</span>
</div>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day for the last 7 days"></svg>
</div>
</section>
<section class="patterns" data-panel="sleep-timeline">
@@ -320,6 +325,9 @@
<label id="note-weight-field" hidden>Weight (kg)
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
</label>
<label id="note-grams-field" hidden>Amount (g)
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
</label>
<label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label>
@@ -348,6 +356,9 @@
<label id="edit-weight-field" hidden>Weight (kg)
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
</label>
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<label>Note
<textarea id="edit-note" rows="4"></textarea>
</label>
+6
View File
@@ -232,6 +232,12 @@ button.danger { background: var(--danger); }
letter-spacing: 0.05em;
}
.stat-sub {
color: var(--muted);
font-size: 0.7rem;
margin-top: 2px;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;