diff --git a/server/main.go b/server/main.go index ec8636c..1951b0c 100644 --- a/server/main.go +++ b/server/main.go @@ -30,6 +30,7 @@ type Event struct { Note string `json:"note"` PhotoID string `json:"photoId,omitempty"` 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 UpdatedAt int64 `json:"updatedAt"` 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 — // the row stays put and, because reads are scoped, stays invisible to them. stmt, err := tx.Prepare(` - INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, at = excluded.at, note = excluded.note, 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 WHERE excluded.updated > events.updated AND events.user_id = excluded.user_id`) @@ -146,7 +147,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) { continue } 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 { 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. func (s *Store) all(userID string) ([]Event, error) { 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) if err != nil { return nil, err @@ -170,7 +171,7 @@ func (s *Store) all(userID string) ([]Event, error) { for rows.Next() { var e Event 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 { return nil, err } @@ -277,6 +278,7 @@ func openDB(path string) (*sql.DB, error) { note TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '', weight REAL NOT NULL DEFAULT 0, + grams REAL NOT NULL DEFAULT 0, exercise_id TEXT NOT NULL DEFAULT '', updated INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0, @@ -348,6 +350,15 @@ func migrateSchema(db *sql.DB) error { 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") if err != nil { return err diff --git a/src/app.js b/src/app.js index 4310bf7..400b62a 100644 --- a/src/app.js +++ b/src/app.js @@ -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(``); + parts.push(`${vText}`); + } + + 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( + `` + + `${escapeText(title)}` + ); + parts.push( + `` + + `${escapeText(dayLabel(d.date, isToday))}` + ); + }); + + 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); }); }); diff --git a/src/changelog.json b/src/changelog.json index eb87916..56a4c51 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -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" }, diff --git a/src/index.html b/src/index.html index 3432e0c..393a33e 100644 --- a/src/index.html +++ b/src/index.html @@ -128,6 +128,7 @@
Meals
0
+
Pees
@@ -189,6 +190,10 @@ Meals
+
@@ -320,6 +325,9 @@ + @@ -348,6 +356,9 @@ + diff --git a/src/style.css b/src/style.css index 152b199..56254c1 100644 --- a/src/style.css +++ b/src/style.css @@ -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;