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