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:
+168
-8
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user