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