Let a meal say what kind of food it was

Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.

The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.

Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.

Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.

The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.

One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.

A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
This commit is contained in:
Alexander Heldt
2026-09-22 10:31:27 +00:00
parent 9e47aa53ff
commit 5a08fb4510
10 changed files with 1082 additions and 43 deletions
+1
View File
@@ -504,6 +504,7 @@ func (a *Auth) deleteAccount(userID string) error {
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM food_kinds WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM push_subscriptions WHERE user_id = ?`,
`DELETE FROM reminders WHERE user_id = ?`,
+64
View File
@@ -680,6 +680,70 @@ func TestGuestCannotExcludeADay(t *testing.T) {
}
}
// The food kinds are the owner's library, like the exercise list: a guest
// labels a meal with a kind that exists but does not invent or rename one.
func TestGuestCannotChangeFoodKinds(t *testing.T) {
a := testAuth(t)
kinds := newFoodKindStore(a.db)
ownerID := testOwner(t, a)
if _, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// What the route hands the store for a guest: nothing incoming, everything
// back. Mirrors the exercises guard in main.go.
merged, err := kinds.sync(ownerID, nil)
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].Name != "Dry" {
t.Fatalf("a guest should still receive the library: %+v", merged)
}
if !merged[0].IsDefault {
t.Error("the default flag did not survive the round trip")
}
// And the owner can still rename it, which is the other half of the rule.
renamed, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
})
if err != nil {
t.Fatalf("owner rename: %v", err)
}
if renamed[0].Name != "Dry kibble" {
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
}
}
// A meal's kind rides the event sync like any other field, and an older client
// that doesn't know about kinds must not wipe one.
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
})
if err != nil {
t.Fatalf("sync: %v", err)
}
byID := map[string]Event{}
for _, e := range merged {
byID[e.ID] = e
}
if byID["e1"].FoodKindID != "k1" {
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
}
if byID["e2"].FoodKindID != "" {
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
}
}
// Guests still log freely — the guard is on changing what already exists.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)
+168 -8
View File
@@ -32,8 +32,12 @@ type Event struct {
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"`
// FoodKindID names which sort of food, for "eat" events. Empty is a real
// answer — "no kind" — and is what every meal logged before kinds existed
// carries, so none of them needed rewriting.
FoodKindID string `json:"foodKindId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
// owner's own. It is stamped by the server from the session (see Store.sync)
// and never read off the wire, so a client can neither forge nor rewrite it.
@@ -176,12 +180,13 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_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,
grams = excluded.grams, exercise_id = excluded.exercise_id,
food_kind_id = excluded.food_kind_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id
@@ -204,7 +209,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
); err != nil {
return nil, err
}
@@ -218,7 +223,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// 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, grams, exercise_id, updated, deleted, logged_by, logged_by_share
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -228,7 +233,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.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare,
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
); err != nil {
return nil, err
}
@@ -237,6 +242,100 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err()
}
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
// logged before kinds existed carries and what anyone who doesn't want to
// classify their food keeps carrying.
//
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
// tombstoned deletes — plus two fields of its own:
//
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
// than in the profile because the profile is last-write-wins across the
// whole row, and this file already carries a special case for pedigree_id
// to stop a clock race dropping it. Per-item LWW needs no such case: two
// devices setting different defaults resolve to the newer one.
// - ColorIndex fixes which palette entry the charts give it, assigned at
// creation. Deriving colour from position in the live list would silently
// recolour every past chart the moment a kind was deleted.
type FoodKind struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault,omitempty"`
ColorIndex int `json:"colorIndex"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
// Store and ExerciseStore are already near-twins, so a third in the same shape
// is the pattern this file has established, and it leaves both working
// collections untouched. Folding all three into one store parameterised by
// table name is the tidier end state, and a separate job.
type FoodKindStore struct {
db *sql.DB
}
func newFoodKindStore(db *sql.DB) *FoodKindStore {
return &FoodKindStore{db: db}
}
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, is_default = excluded.is_default,
color_index = excluded.color_index,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > food_kinds.updated
AND food_kinds.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, k := range client {
if k.ID == "" {
continue
}
if _, err := stmt.Exec(
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
rows, err := s.db.Query(
`SELECT id, name, is_default, color_index, updated, deleted
FROM food_kinds WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FoodKind, 0)
for rows.Next() {
var k FoodKind
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
// UpdatedAt, same user_id guard against cross-user id collisions, same
// tombstone propagation.
@@ -341,7 +440,8 @@ func openDB(path string) (*sql.DB, error) {
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT ''
logged_by_share TEXT NOT NULL DEFAULT '',
food_kind_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
@@ -353,6 +453,16 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS food_kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
color_index INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
@@ -489,6 +599,17 @@ func migrateSchema(db *sql.DB) error {
return err
}
}
// Which sort of food a meal was. Empty on every existing row, which is
// exactly right: those meals have no kind, and none of them need rewriting.
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
if err != nil {
return err
}
if !hasFoodKind {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
@@ -649,6 +770,14 @@ type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type foodKindSyncRequest struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type foodKindSyncResponse struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type cacheControlFS struct {
root http.FileSystem
}
@@ -751,6 +880,7 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
foodKindStore := newFoodKindStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
@@ -857,6 +987,36 @@ func main() {
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// POST /api/foodkinds/sync — the same contract again for the food kinds a
// meal can be labelled with.
mux.HandleFunc("/api/foodkinds/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req foodKindSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// The library is the owner's, exactly as the exercise list is: a guest
// labels a meal with a kind that exists, but does not invent, rename or
// delete one. They still receive the full set, so the picker works.
incoming := req.FoodKinds
if isGuest(r) {
incoming = nil
}
merged, err := foodKindStore.sync(userID(r), incoming)
if err != nil {
log.Printf("food kinds sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(foodKindSyncResponse{FoodKinds: merged})
}))
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {