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
+17 -6
View File
@@ -30,6 +30,7 @@ type Event struct {
Note string `json:"note"` Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` PhotoID string `json:"photoId,omitempty"`
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
UpdatedAt int64 `json:"updatedAt"` UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"` Deleted bool `json:"deleted,omitempty"`
@@ -127,12 +128,12 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// clobber another's row even if a client forges a colliding event ID — // clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them. // the row stays put and, because reads are scoped, stays invisible to them.
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id) INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note, type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight, photo_id = excluded.photo_id, weight = excluded.weight,
exercise_id = excluded.exercise_id, grams = excluded.grams, exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`) AND events.user_id = excluded.user_id`)
@@ -146,7 +147,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
continue continue
} }
if _, err := stmt.Exec( if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -160,7 +161,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// all returns one user's events, tombstones included. // all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) { func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, exercise_id, updated, deleted `SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
FROM events WHERE user_id = ?`, userID) FROM events WHERE user_id = ?`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -170,7 +171,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() { for rows.Next() {
var e Event var e Event
if err := rows.Scan( if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -277,6 +278,7 @@ func openDB(path string) (*sql.DB, error) {
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0, weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '', exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0, updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0,
@@ -348,6 +350,15 @@ func migrateSchema(db *sql.DB) error {
return err return err
} }
} }
hasGrams, err := columnExists(db, "events", "grams")
if err != nil {
return err
}
if !hasGrams {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN grams REAL NOT NULL DEFAULT 0`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id") oldConfig, err := columnExists(db, "config", "id")
if err != nil { if err != nil {
return err return err
+101 -6
View File
@@ -257,7 +257,7 @@
return `${wk} · ${mo} old`; 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 events = loadAll();
const now = Date.now(); const now = Date.now();
const ev = { const ev = {
@@ -267,6 +267,7 @@
note: note || "", note: note || "",
photoId: photoId || "", photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined, weight: Number.isFinite(weight) ? weight : undefined,
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "", exerciseId: exerciseId || "",
updatedAt: now, updatedAt: now,
}; };
@@ -590,6 +591,12 @@
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs); document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
document.getElementById("stat-awake").textContent = formatDuration(awakeMs); document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
document.getElementById("stat-meals").textContent = count("eat"); 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-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo"); document.getElementById("stat-poos").textContent = count("poo");
document.getElementById("stat-training").textContent = count("training"); document.getElementById("stat-training").textContent = count("training");
@@ -827,6 +834,9 @@
pees: dayEvents.filter(e => e.type === "pee").length, pees: dayEvents.filter(e => e.type === "pee").length,
poos: dayEvents.filter(e => e.type === "poo").length, poos: dayEvents.filter(e => e.type === "poo").length,
meals: dayEvents.filter(e => e.type === "eat").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; return days;
@@ -854,6 +864,18 @@
return { yMax: m, steps: m / 5 }; 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, // Sleep-specific axis: always 2-hour granularity, capped at 24h/day,
// for a more readable picture of typical 1018 h puppy sleep. // for a more readable picture of typical 1018 h puppy sleep.
function niceAxisSleepHours(rawMax) { function niceAxisSleepHours(rawMax) {
@@ -977,10 +999,58 @@
setChartSVG(svg, parts); 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) { function renderWeekly(events) {
const days = weeklyData(events); const days = weeklyData(events);
drawSleepChart(days); drawSleepChart(days);
drawCountsChart(days); drawCountsChart(days);
drawGramsChart(days);
} }
// ---------- pattern charts (last 14 days) ---------- // ---------- pattern charts (last 14 days) ----------
@@ -1379,7 +1449,7 @@
logBtn.textContent = "Log"; logBtn.textContent = "Log";
logBtn.addEventListener("click", (e) => { logBtn.addEventListener("click", (e) => {
e.stopPropagation(); 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); showSnackbar(`${ex.name} logged`, ev);
}); });
@@ -1629,6 +1699,8 @@
const notePhotoPreview = document.getElementById("note-photo-preview"); const notePhotoPreview = document.getElementById("note-photo-preview");
const noteWeightField = document.getElementById("note-weight-field"); const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight"); const noteWeight = document.getElementById("note-weight");
const noteGramsField = document.getElementById("note-grams-field");
const noteGrams = document.getElementById("note-grams");
let pendingType = null; let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
let notePhotoURL = null; // current preview object URL let notePhotoURL = null; // current preview object URL
@@ -1656,11 +1728,14 @@
noteTime.value = toTimeInput(now); noteTime.value = toTimeInput(now);
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
const isWeight = type === "weight"; const isWeight = type === "weight";
const isEat = type === "eat";
noteWeightField.hidden = !isWeight; noteWeightField.hidden = !isWeight;
noteWeight.value = ""; noteWeight.value = "";
noteGramsField.hidden = !isEat;
noteGrams.value = "";
clearNotePhoto(); clearNotePhoto();
noteDialog.showModal(); noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50); setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
} }
function noteDialogAt() { function noteDialogAt() {
@@ -1711,6 +1786,12 @@
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; } if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
weight = Math.round(weight * 100) / 100; 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 = ""; let photoId = "";
if (notePhotoBlob) { if (notePhotoBlob) {
photoId = uuid(); photoId = uuid();
@@ -1720,7 +1801,7 @@
return; return;
} }
} }
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight); addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId, weight, grams });
pendingType = null; pendingType = null;
clearNotePhoto(); clearNotePhoto();
noteDialog.close(); noteDialog.close();
@@ -1745,6 +1826,8 @@
const editPhotoPreview = document.getElementById("edit-photo-preview"); const editPhotoPreview = document.getElementById("edit-photo-preview");
const editWeightField = document.getElementById("edit-weight-field"); const editWeightField = document.getElementById("edit-weight-field");
const editWeight = document.getElementById("edit-weight"); const editWeight = document.getElementById("edit-weight");
const editGramsField = document.getElementById("edit-grams-field");
const editGrams = document.getElementById("edit-grams");
let editingId = null; let editingId = null;
let editPhotoId = ""; // current photoId for this event let editPhotoId = ""; // current photoId for this event
let editPhotoBlob = null; // new blob chosen in this session let editPhotoBlob = null; // new blob chosen in this session
@@ -1770,6 +1853,8 @@
editNote.value = ev.note || ""; editNote.value = ev.note || "";
editWeightField.hidden = ev.type !== "weight"; editWeightField.hidden = ev.type !== "weight";
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.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 || ""; editPhotoId = ev.photoId || "";
editPhotoCleared = false; editPhotoCleared = false;
clearEditPhotoLocalState(); clearEditPhotoLocalState();
@@ -1823,6 +1908,15 @@
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; } if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
patch.weight = Math.round(kg * 100) / 100; 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) { if (editPhotoBlob) {
const newId = uuid(); const newId = uuid();
try { await putPhoto(newId, editPhotoBlob, false); } try { await putPhoto(newId, editPhotoBlob, false); }
@@ -2142,8 +2236,9 @@
document.querySelectorAll("button.action").forEach(btn => { document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const type = btn.dataset.type; const type = btn.dataset.type;
// Weigh-ins need a typed value, so they keep the full dialog. // Weigh-ins need a typed value and meals ask for grams, so those two
if (type === "weight") { openNoteDialog(type); return; } // keep the full dialog.
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
quickLog(type); 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": "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": "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" }, { "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">
<div class="stat-label">Meals</div> <div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div> <div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Pees</div> <div class="stat-label">Pees</div>
@@ -189,6 +190,10 @@
<span class="lg eat"><span class="sw"></span>Meals</span> <span class="lg eat"><span class="sw"></span>Meals</span>
</div> </div>
</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>
<section class="patterns" data-panel="sleep-timeline"> <section class="patterns" data-panel="sleep-timeline">
@@ -320,6 +325,9 @@
<label id="note-weight-field" hidden>Weight (kg) <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" /> <input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
</label> </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 <label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea> <textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label> </label>
@@ -348,6 +356,9 @@
<label id="edit-weight-field" hidden>Weight (kg) <label id="edit-weight-field" hidden>Weight (kg)
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" /> <input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
</label> </label>
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<label>Note <label>Note
<textarea id="edit-note" rows="4"></textarea> <textarea id="edit-note" rows="4"></textarea>
</label> </label>
+6
View File
@@ -232,6 +232,12 @@ button.danger { background: var(--danger); }
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.stat-sub {
color: var(--muted);
font-size: 0.7rem;
margin-top: 2px;
}
.stat-value { .stat-value {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;