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(`