From 316ff3db06ebaa4707dd17d460bbf06db548bdb3 Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 13 Oct 2026 13:57:11 -0700 Subject: [PATCH 1/2] Document how the server counts diary entries by measure type Verified live: a diary entry is stored as a count of its measure; its grams are count x value for Weight measures and count x 100 for Atomic ones. Foods made by create_custom_food (Weight serving + server g) log correctly by either measure. Switching a logged measure's type rescales its entries by value/100, so the recipe-ingredient refusal now warns against that fix. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++- src/cronometer_api_mcp/client.py | 22 ++++++++++++++++++---- tests/test_recipe_edit.py | 1 + 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4957f17..ade6e94 100644 --- a/README.md +++ b/README.md @@ -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 a +measure's type: 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, diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index b9d7169..d23ea65 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -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 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 @@ -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 " ". Setting the @@ -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"Don't switch a measure's type to fix this: Cronometer would rescale " + f"every diary entry logged by it (x value/100). Make a new custom food " + f"for recipes instead" ) diff --git a/tests/test_recipe_edit.py b/tests/test_recipe_edit.py index d5d7b45..e67b146 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -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 a measure's type to fix this" in msg assert state["endpoints"] == ["get_foods"] # nothing created assert srv.foods == {} From f15dd0563bd364d6721bfc36d507388c314b2d2f Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 13 Oct 2026 13:59:13 -0700 Subject: [PATCH 2/2] Scope the type-switch warning to Atomic-to-Weight Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- src/cronometer_api_mcp/client.py | 10 +++++----- tests/test_recipe_edit.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ade6e94..b5a8ca8 100644 --- a/README.md +++ b/README.md @@ -226,8 +226,8 @@ 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. Don't fix an older food by switching a -measure's type: the server stores a diary entry as a count of its measure, with a `Weight` unit +`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 diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index d23ea65..870d70a 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -91,8 +91,8 @@ # # 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 is right either way: the -# count is derived by the same rule. Editing a measure later re-reads every +# "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 @@ -2481,9 +2481,9 @@ def _check_recipe_ingredient(food: dict) -> None: 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"Don't switch a measure's type to fix this: Cronometer would rescale " - f"every diary entry logged by it (x value/100). Make a new custom food " - f"for recipes instead" + 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" ) diff --git a/tests/test_recipe_edit.py b/tests/test_recipe_edit.py index e67b146..d845727 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -1847,7 +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 a measure's type to fix this" in msg + assert "don't switch an 'Atomic' measure to 'Weight'" in msg assert state["endpoints"] == ["get_foods"] # nothing created assert srv.foods == {}