diff --git a/README.md b/README.md
index c4211b3..48526b1 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,16 @@ source-of-truth and sync between devices.
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
events (`type: "training"`) referencing an exercise by id, so they ride the
event sync unchanged.
+- Food kinds (`Dry`, `Fresh`) are a third synced collection with the same
+ contract, via `POST /api/foodkinds/sync`; a meal references one by
+ `foodKindId`. Empty means **no kind**, which is what every meal logged before
+ kinds existed carries — so nothing needed migrating and nobody is made to
+ classify their food. Which kind a new meal starts on is a flag on the kind
+ itself rather than a profile field: the profile is last-write-wins across the
+ whole row (see the `pedigree_id` special case below), and per-item LWW lets
+ two devices that each chose a default resolve to the newer instead of
+ fighting. Each kind also keeps a fixed `colorIndex`, so deleting one never
+ repaints the charts of the ones around it.
- The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in
diff --git a/checks/food-kinds.mjs b/checks/food-kinds.mjs
new file mode 100644
index 0000000..c536572
--- /dev/null
+++ b/checks/food-kinds.mjs
@@ -0,0 +1,125 @@
+// Kinds of food: a library of user-named labels a meal can carry. The rules
+// worth holding still are the ones that decide what happens to people who
+// never use the feature, and what happens to history when a kind is deleted.
+import { load } from "./extract.mjs";
+import { suite, eq, ok, report } from "./assert.mjs";
+
+let store = [];
+let synced = 0, rendered = 0;
+let nextId = 0;
+
+const app = load({
+ names: [
+ "NO_KIND", "FOOD_COLORS",
+ "loadFoodKinds", "saveFoodKinds", "liveFoodKinds",
+ "addFoodKind", "updateFoodKind", "deleteFoodKind",
+ "setDefaultFoodKind", "defaultFoodKindId", "foodKindNames",
+ ],
+ stubs: {
+ foodKindsKey: () => "k",
+ localStorage: {
+ getItem: () => JSON.stringify(store),
+ setItem: (_, v) => { store = JSON.parse(v); },
+ },
+ uuid: () => `id${++nextId}`,
+ scheduleSync: () => { synced++; },
+ render: () => { rendered++; },
+ },
+});
+
+const reset = () => { store = []; nextId = 0; };
+const names = () => app.liveFoodKinds().map(k => k.name);
+
+suite("an account with no kinds behaves as it always did");
+{
+ reset();
+ eq(app.liveFoodKinds(), [], "no kinds to begin with");
+ eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind");
+ eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry");
+}
+
+suite("creating kinds");
+{
+ reset();
+ const dry = app.addFoodKind("Dry");
+ const fresh = app.addFoodKind("Fresh");
+ eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical");
+ eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot");
+ ok(synced > 0, "a new kind is queued for sync");
+}
+
+suite("the default");
+{
+ reset();
+ const dry = app.addFoodKind("Dry");
+ const fresh = app.addFoodKind("Fresh");
+ eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so");
+
+ app.setDefaultFoodKind(dry.id);
+ eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default");
+
+ app.setDefaultFoodKind(fresh.id);
+ eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it");
+ eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1,
+ "…and unflags the old one, so there is never more than one");
+
+ app.setDefaultFoodKind(app.NO_KIND);
+ eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind");
+}
+
+suite("two devices that each set a default");
+{
+ // What a sync race leaves behind: both rows flagged, different timestamps.
+ // Resolving to the newer beats showing two defaults or picking at random.
+ reset();
+ store = [
+ { id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 },
+ { id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 },
+ ];
+ eq(app.defaultFoodKindId(), "b", "the more recent flag wins");
+}
+
+suite("renaming and deleting keep history readable");
+{
+ reset();
+ const dry = app.addFoodKind("Dry");
+ app.updateFoodKind(dry.id, { name: "Dry kibble" });
+ eq(names(), ["Dry kibble"], "renaming changes the name in place");
+ eq(app.foodKindNames().get(dry.id), "Dry kibble",
+ "…and meals pointing at the id follow it, since they resolve by id");
+
+ app.deleteFoodKind(dry.id);
+ eq(names(), [], "a deleted kind leaves the picker");
+ eq(app.foodKindNames().get(dry.id), "Dry kibble",
+ "…but its name still resolves, so meals logged as it stay readable");
+}
+
+suite("colours survive a deletion");
+{
+ // Deriving colour from position in the live list would repaint every past
+ // chart the moment a kind was removed. The index is fixed at creation.
+ reset();
+ app.addFoodKind("Dry");
+ const fresh = app.addFoodKind("Fresh");
+ const raw = app.addFoodKind("Raw");
+ eq(raw.colorIndex, 2, "the third kind takes the third slot");
+
+ app.deleteFoodKind(fresh.id);
+ const live = app.liveFoodKinds();
+ eq(live.map(k => k.colorIndex), [0, 2],
+ "deleting the middle kind leaves the others' colours alone");
+ eq(app.addFoodKind("Treats").colorIndex, 3,
+ "and the next kind does not reuse the freed slot");
+}
+
+suite("the palette wraps rather than running out");
+{
+ reset();
+ for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`);
+ const live = app.liveFoodKinds();
+ eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours");
+ eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0,
+ "…and the palette wraps, so two share rather than one having none");
+}
+
+export default report("food-kinds");
diff --git a/checks/food-trend.mjs b/checks/food-trend.mjs
index 8b4469f..baa23a1 100644
--- a/checks/food-trend.mjs
+++ b/checks/food-trend.mjs
@@ -137,4 +137,113 @@ suite("what the sentence is allowed to say");
ok(/down about/.test(falling), "a clear fall says down");
}
+// ---------------------------------------------------------------- by kind
+// Splitting the bars must not change what the chart says for anyone who never
+// defines a kind, and the per-kind caption must stay bounded as kinds are added.
+{
+ let kinds = [];
+ const split = load({
+ names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
+ "foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
+ stubs: {
+ liveFoodKinds: () => kinds,
+ foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
+ },
+ });
+
+ // days carrying a per-kind split, as weeklyData builds them.
+ const byKind = (rows) => rows.map(r => ({
+ grams: Object.values(r).reduce((s, v) => s + v, 0),
+ gramsByKind: r,
+ excluded: false,
+ meals: 0, mealsMissingGrams: 0,
+ }));
+
+ suite("an unsplit chart is unchanged");
+ {
+ kinds = [];
+ const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
+ const series = split.foodSeriesFor(days);
+ eq(series.length, 1, "no kinds defined gives exactly one series");
+ eq(series[0].name, "No kind", "…the unnamed one");
+ const s = split.foodSeriesSentences(series, 7);
+ eq(s.length, 1, "…and one sentence, as before kinds existed");
+ ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
+ }
+
+ suite("the split adds up");
+ {
+ kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
+ const days = byKind([
+ { d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
+ { d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
+ ]);
+ const series = split.foodSeriesFor(days);
+ eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
+ for (const d of days) {
+ const summed = series.reduce((acc, s) => acc + s.of(d), 0);
+ eq(summed, d.grams, "each day's segments sum to the day's own total");
+ }
+ }
+
+ suite("a kind with nothing logged is left out");
+ {
+ kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
+ const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
+ eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
+ "an unused kind gets no segment and no legend entry");
+ }
+
+ suite("a deleted kind's food still appears");
+ {
+ // The kind is gone from the picker but meals still point at it, and that
+ // food is real — it has to show under the name the tombstone kept.
+ kinds = [];
+ const namesOnly = load({
+ names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
+ stubs: {
+ liveFoodKinds: () => [],
+ foodKindNames: () => new Map([["gone", "Old recipe"]]),
+ },
+ });
+ const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
+ eq(namesOnly.foodSeriesFor(days).map(s => s.name), ["Old recipe"],
+ "it keeps its name rather than vanishing or reading as 'No kind'");
+ }
+
+ suite("the caption stays bounded as kinds are added");
+ {
+ kinds = [
+ { id: "a", name: "Dry", colorIndex: 0 },
+ { id: "b", name: "Fresh", colorIndex: 1 },
+ { id: "c", name: "Raw", colorIndex: 2 },
+ { id: "e", name: "Treats", colorIndex: 3 },
+ ];
+ // Dry climbs clearly; the rest are flat or noise.
+ const days = byKind([
+ { a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
+ { a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
+ { a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
+ ]);
+ const series = split.foodSeriesFor(days);
+ const lines = split.foodSeriesSentences(series, 7);
+ ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
+ ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
+ ok(lines.some(l => /no clear trend/.test(l)),
+ "…and the rest are folded into one clause rather than a sentence each");
+ }
+
+ suite("no kind moves at all");
+ {
+ kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
+ const days = byKind([
+ { a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
+ { a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
+ ]);
+ const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
+ eq(lines.length, 1, "one line when nothing is claimable");
+ ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
+ }
+}
+
export default report("food-trend");
diff --git a/server/auth.go b/server/auth.go
index 3a6d732..330e56a 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -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 = ?`,
diff --git a/server/auth_test.go b/server/auth_test.go
index be3f040..61d88dd 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -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)
diff --git a/server/main.go b/server/main.go
index 53e6414..1c2ab64 100644
--- a/server/main.go
+++ b/server/main.go
@@ -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) {
diff --git a/src/app.js b/src/app.js
index 038f71b..fc4e45f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -24,6 +24,7 @@
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
+ const foodKindsKey = () => `puppy-tracker:${currentUser.id}:foodkinds:v1`;
const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000;
@@ -340,7 +341,7 @@
return null;
}
- function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
+ function addEvent(type, note, at, { photoId, weight, grams, exerciseId, foodKindId } = {}) {
const events = loadAll();
const now = Date.now();
const ev = {
@@ -352,6 +353,8 @@
weight: Number.isFinite(weight) ? weight : undefined,
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "",
+ // "" is a real value here — no kind — not a missing one.
+ foodKindId: foodKindId || NO_KIND,
updatedAt: now,
// Only set when logging through a guest link, and only so the badge and
// the "you may edit this" check work before the first sync: the server
@@ -444,6 +447,131 @@
return new Map(loadExercises().map(x => [x.id, x.name]));
}
+ // The chips both dialogs use. Built from the live kinds plus "No kind",
+ // which always comes last and is always offered — a meal is allowed to have
+ // no kind, permanently, and the picker should never imply otherwise.
+ //
+ // Hidden entirely when no kinds are defined, so the app looks exactly as it
+ // did to anyone who never wants them. onPick receives the chosen id.
+ function renderKindPicker(pickerEl, fieldEl, selectedId, onPick) {
+ const kinds = liveFoodKinds();
+ fieldEl.hidden = kinds.length === 0;
+ if (kinds.length === 0) return;
+
+ pickerEl.innerHTML = "";
+ const chip = (id, name, colorIndex) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "kind-chip" + (id === selectedId ? " active" : "");
+ if (id !== NO_KIND) b.dataset.color = String(colorIndex % FOOD_COLORS);
+ b.setAttribute("role", "radio");
+ b.setAttribute("aria-checked", String(id === selectedId));
+ b.textContent = name;
+ b.addEventListener("click", () => onPick(id));
+ pickerEl.appendChild(b);
+ };
+ for (const k of kinds) chip(k.id, k.name, k.colorIndex ?? 0);
+ chip(NO_KIND, "No kind", 0);
+ }
+
+ // ---------- food kinds ----------
+ // A meal can be labelled with a sort of food the user names themselves
+ // ("Dry", "Fresh"). The same collection contract as exercises: uuid ids, LWW
+ // on updatedAt, tombstoned deletes so a meal logged against a kind that has
+ // since been deleted still shows what it was.
+ //
+ // No kind is a real answer, not a missing one. Every meal logged before this
+ // existed has no kind, and nobody is made to invent a taxonomy before they
+ // can record that the dog ate — so "" is a first-class value throughout, and
+ // an app with no kinds defined behaves exactly as it did.
+ const NO_KIND = "";
+ // How many colours the palette holds before it wraps (see --food-N in the
+ // stylesheet). Kinds beyond this share a colour, which is a far better
+ // failure than running out of chart.
+ const FOOD_COLORS = 6;
+
+ function loadFoodKinds() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(foodKindsKey()));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ function saveFoodKinds(list) {
+ localStorage.setItem(foodKindsKey(), JSON.stringify(list));
+ }
+
+ // Creation order, not alphabetical: it matches the palette (colorIndex is
+ // assigned in the same order) and keeps the stack's layers from reshuffling
+ // under you when a kind is renamed.
+ function liveFoodKinds() {
+ return loadFoodKinds()
+ .filter(k => !k.deleted)
+ .sort((a, b) => (a.colorIndex ?? 0) - (b.colorIndex ?? 0));
+ }
+
+ function addFoodKind(name) {
+ const list = loadFoodKinds();
+ // Counted over every kind ever, tombstones included, so deleting one never
+ // shifts the colour of another and silently repaints old charts.
+ const colorIndex = list.length;
+ list.push({ id: uuid(), name, colorIndex, isDefault: false, updatedAt: Date.now() });
+ saveFoodKinds(list);
+ scheduleSync();
+ render();
+ return list[list.length - 1];
+ }
+
+ function updateFoodKind(id, patch) {
+ saveFoodKinds(loadFoodKinds().map(k =>
+ k.id === id ? { ...k, ...patch, updatedAt: Date.now() } : k
+ ));
+ scheduleSync();
+ render();
+ }
+
+ function deleteFoodKind(id) {
+ // Tombstone, like an exercise: meals keep referencing the id and
+ // foodKindNames still resolves it, so history stays readable.
+ saveFoodKinds(loadFoodKinds().map(k =>
+ k.id === id ? { ...k, deleted: true, updatedAt: Date.now() } : k
+ ));
+ scheduleSync();
+ render();
+ }
+
+ // Exactly one kind is the default, or none — in which case new meals start
+ // with no kind, which is also where everyone starts. Passing NO_KIND clears
+ // it. Every other kind is unflagged in the same pass, so two devices that
+ // each set a different default converge on one rather than showing two.
+ function setDefaultFoodKind(id) {
+ const now = Date.now();
+ saveFoodKinds(loadFoodKinds().map(k => {
+ const shouldBe = k.id === id;
+ return k.isDefault === shouldBe ? k : { ...k, isDefault: shouldBe, updatedAt: now };
+ }));
+ scheduleSync();
+ render();
+ }
+
+ // Which kind a new meal starts with. Newest flag wins, so a sync race between
+ // two devices that each chose a default resolves rather than picking at
+ // random; no flag at all means no kind.
+ function defaultFoodKindId() {
+ const flagged = liveFoodKinds().filter(k => k.isDefault);
+ if (flagged.length === 0) return NO_KIND;
+ return flagged.reduce((a, b) => ((b.updatedAt || 0) > (a.updatedAt || 0) ? b : a)).id;
+ }
+
+ // id -> name across *all* kinds, tombstones included, for the same reason
+ // exerciseNames keeps them: a deleted kind's meals should still say what
+ // they were.
+ function foodKindNames() {
+ return new Map(loadFoodKinds().map(k => [k.id, k.name]));
+ }
+
// ---------- helpers ----------
function ymd(date) {
const y = date.getFullYear();
@@ -1304,6 +1432,7 @@
const rails = historyRails(events, chronological, day);
const dayEvents = [...chronological].reverse();
const exNames = exerciseNames();
+ const kindNames = foodKindNames();
eventList.innerHTML = "";
if (dayEvents.length === 0) {
emptyState.hidden = false;
@@ -1339,9 +1468,14 @@
const noteEl = li.querySelector(".note");
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
- } else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
- const g = `${Math.round(ev.grams)} g`;
- noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
+ } else if (ev.type === "eat") {
+ // Amount and kind are both optional, so the row shows whichever it has.
+ const bits = [];
+ if (Number.isFinite(ev.grams) && ev.grams > 0) bits.push(`${Math.round(ev.grams)} g`);
+ const kindName = ev.foodKindId ? kindNames.get(ev.foodKindId) : "";
+ if (kindName) bits.push(kindName);
+ if (ev.note) bits.push(ev.note);
+ noteEl.textContent = bits.join(" · ");
} else {
noteEl.textContent = ev.note || "";
}
@@ -1515,6 +1649,16 @@
// leaving the reader to assume the first.
mealsMissingGrams: dayEvents
.filter(e => e.type === "eat" && !(Number.isFinite(e.grams) && e.grams > 0)).length,
+ // The same total, split by kind. The plain `grams` above stays: it is
+ // still what the axis, the tooltip and the day's overview want, and
+ // keeping both means the split can never disagree with the total.
+ gramsByKind: dayEvents
+ .filter(e => e.type === "eat" && Number.isFinite(e.grams))
+ .reduce((acc, e) => {
+ const id = e.foodKindId || NO_KIND;
+ acc[id] = (acc[id] || 0) + e.grams;
+ return acc;
+ }, {}),
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
});
}
@@ -1734,12 +1878,14 @@
// up over the day — a moving line that reflects the clock rather than the
// puppy. The line is drawn only across the days it was fitted on, so it never
// implies it knows about the ones it skipped.
- function foodTrend(days) {
+ // valueOf picks which figure to fit: the day total by default, or one
+ // kind's share of it when the bars are split.
+ function foodTrend(days, valueOf = (d) => d.grams) {
const pts = [];
days.forEach((d, i) => {
if (d.excluded) return;
if (i === days.length - 1) return; // today, still being eaten
- pts.push({ x: i, y: d.grams });
+ pts.push({ x: i, y: valueOf(d) });
});
// Two points always fit a line perfectly and say nothing; four is the least
// that can show a direction rather than a coincidence.
@@ -1790,6 +1936,7 @@
const gap = days.length > 14 ? 2 : 4;
const barW = (innerW - (days.length - 1) * gap) / days.length;
+ const series = foodSeriesFor(days);
const parts = [];
for (let i = 0; i <= ySteps; i++) {
@@ -1813,12 +1960,34 @@
}
if (d.excluded) {
parts.push(excludedSlot(d, x, barW, MT, innerH));
- } else {
+ } else if (series.length <= 1) {
+ // Nothing to split: one bar, exactly as before kinds existed. This is
+ // the shape an account that never defines a kind always sees.
parts.push(
`
+ Label a meal with the sort of food it was — dry, fresh, whatever you + feed. Optional: with none defined, nothing changes, and “No kind” + stays available even once you have some. +
+No kinds yet.
+