Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ every save, so the client sends none, and reads both back to check them. The ser
ingredient's grams toward the batch only if all of the food's measures are type `Weight` (what the
web client saves for a serving with a gram weight); with an `Atomic` one, its nutrients are added but
its grams aren't, so the batch weight comes out short. Custom foods are therefore saved with
`Weight` servings, and recipes refuse other ingredients. No hard-delete endpoint for foods is known, so
`Weight` servings, and recipes refuse other ingredients. Don't fix an older food by switching an
`Atomic` measure to `Weight`: the server stores a diary entry as a count of its measure, with a `Weight` unit
weighing its value in grams and an `Atomic` one 100 g, so the switch rescales every entry logged by
it (180 g logged by an `Atomic` "g" read 1.8 g after switching it to `Weight`). No hard-delete endpoint for foods is known, so
`delete_custom_food` sets `retired` instead (the food leaves search after a few seconds but stays
readable by id, and diary entries that use it are kept).
Retired foods can't be edited. Serving names are sent exactly as given with an explicit quantity,
Expand Down
22 changes: 18 additions & 4 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@
_RECIPE_FIXED_MEASURES = _WEIGHT_UNIT_MEASURES | {_FULL_RECIPE_MEASURE}
# The web client saves a serving with a gram weight as "Weight"; "Atomic" is a
# count unit (a supplement "tablet"). See _check_recipe_ingredient.
#
# The server stores a diary entry as a count of its measure, and reports its
# grams as count x the measure's value for "Weight", but count x 100 for
# "Atomic" (whatever its value). Logging by grams, as this client does, is
# right either way: the count is derived by the same rule. Editing a measure later re-reads every
# entry logged by it (verified live, Oct 13, 2026): resizing a Weight measure
# rescales those entries, resizing an Atomic one doesn't, and switching a
# measure's type multiplies them by value/100 (Atomic to Weight) or 100/value
# (Weight to Atomic): 180 g logged by an Atomic "g" became 1.8 g. Never change
# a logged measure's type.
_WEIGHED_MEASURE = "Weight"
# Recipe serving weights are sent rounded to this many decimals, and read back
# within _GRAMS_TOL: a batch that doesn't divide evenly (4561 g / 6) would
Expand Down Expand Up @@ -893,9 +903,10 @@ def update_custom_food(
alone leaves the stored per-100g values as they are, so the food's
per-serving numbers shift with the new weight.

Resizing a measure rescales every diary entry logged by it (seen
live), so the measure is picked by _custom_food_serving, never guessed,
and a "g" or "oz" measure is never renamed or resized.
Resizing a "Weight" measure rescales every diary entry logged by it
(see _WEIGHED_MEASURE), so the measure is picked by
_custom_food_serving, never guessed, and a "g" or "oz" measure is never
renamed or resized. Measure types are re-sent unchanged.

A measure is `amount` units of `name` weighing `value` grams in total,
and the app renders the serving as "<amount> <name>". Setting the
Expand Down Expand Up @@ -2469,7 +2480,10 @@ def _check_recipe_ingredient(food: dict) -> None:
f"Food {food['id']} ({food['name']!r}) can't be a recipe ingredient: "
f"its measures {[(m['name'], m['type']) for m in others]} aren't type "
f"'Weight', and Cronometer leaves such a food's grams out of a "
f"recipe's batch weight, which would inflate its per-100 g nutrients"
f"recipe's batch weight, which would inflate its per-100 g nutrients. "
f"If it's your own food, don't switch an 'Atomic' measure to 'Weight' "
f"to fix this: Cronometer re-reads every diary entry logged by it at "
f"value/100 of its grams. Make a new custom food for recipes instead"
)


Expand Down
1 change: 1 addition & 0 deletions tests/test_recipe_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1847,6 +1847,7 @@ def test_create_recipe_refuses_unweighable_ingredient_before_saving(tmp_path, fo
f"Food {LEGACY_ID} ('ZZ custom') can't be a recipe ingredient: its measures"
)
assert "leaves such a food's grams out of a recipe's batch weight" in msg
assert "don't switch an 'Atomic' measure to 'Weight'" in msg
assert state["endpoints"] == ["get_foods"] # nothing created
assert srv.foods == {}

Expand Down