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:
+101
-6
@@ -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 10–18 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user