Adding Recipes to a Nutrition App, and the Decisions That Didn't Make the Screenshots
OpenNutriTracker now has recipes. The interesting parts aren't in the demo: snapshot semantics on edit, multi-source routing on import, the recursion v1 dodged.
The Recipes feature went into OpenNutriTracker last week, and it is the largest single piece of work I have shipped on the project by a comfortable margin. The PR is around 7600 lines added, with a fourth navigation tab, a recipe builder, a detail screen, an ingredient picker that reuses the existing food search, aggregated nutrition computation, tags, QR sharing, CSV import, and survival of the existing zip export-import path. The motivation behind it was small and domestic: I wanted the recipes I cook for my partner to be a thing they could log on their own phone in a single tap, rather than something either of us had to recreate from scratch every time it came around again.
The screenshots and the demo cover the obvious shape of the feature, the parts that look good in a marketing post. The actually interesting decisions are the ones that do not appear in either, because they are invisible until something edge-case happens and then they are quietly load-bearing. This post is about those, and about the small careful thinking that went into them on evenings when the kitchen was quiet and the dishes were done.
Snapshot semantics
The first decision worth talking about, and the one that turned out to shape almost every later piece of the feature, is what happens to past diary entries when you edit a recipe after the fact.
If you logged 100 grams of “Sunday Lasagne” on Tuesday, and then on Wednesday you go in and change the lasagne recipe because you realised, halfway through grating the parmesan for the next batch, that you had forgotten to record the parmesan at all the first time around, what should Tuesday’s diary entry show afterwards? The same calorie number it had on Tuesday, faithful to what you actually ate that night, or the recalculated number that includes the parmesan you only later remembered?
There is a real argument for going either way, and both arguments have a coherent shape behind them. Recalculating gives you the most accurate-as-of-now picture of your nutrition history, which is what you might want if you are using the diary primarily as a longitudinal record of intake. Snapshotting, on the other hand, gives you the most truthful as-of-then picture, which is what you want if you are using the diary as a record of what you actually ate at the time. Those are different goals served by the same UI surface, and choosing between them is a real design decision rather than a question with an obvious answer.
The decision that landed in the end was to snapshot, so that past diary entries never change once they have been written. The intake table already stores its own copy of the meal data through IntakeDBO.meal, which meant the snapshot was free to implement and only the policy needed deciding. Only future logs see the updated aggregation, and editing a recipe is, in effect, a forward-only operation that respects what the past already was.
The argument for snapshotting, when I sat with it, is that someone scrolling back through her diary on a quiet evening, perhaps with a cup of tea while the kitchen radio plays low, is not really asking “what is the most up-to-date nutritional view of my history?” She is asking “what did I eat on Tuesday?” The lasagne she ate on Tuesday is the version of the lasagne that existed on Tuesday, which is the version she actually cooked and put on the table for the people she was feeding, regardless of what she later thought about the recipe. The diary is a record of the past as it actually happened, not a live recompute against the present.
The same logic governs deletion of a recipe, where deleting it from the recipes list preserves the diary intakes that referenced it. The snapshot is intact, the recipe is gone from the recipes list itself, and three months from now you can still look back at your diary and see a meaningful entry rather than a ghost where the lasagne used to be. “I ate cake on Tuesday” does not stop being true when the cake recipe gets deleted, and somebody who is tracking her eating to make sense of it deserves a diary that holds onto that fact for her, gently and without judgement.
If you genuinely want to remove a historical entry from your diary, the diary screen has its own delete that does exactly that, and the data model is shaped to keep the two intentions cleanly separate from each other. Removing a recipe from the recipes list, and removing a specific past meal from the diary, are different things you might want for different reasons on different days, and the app is structured to let each one happen on its own without quietly affecting the other.
Multi-source routing on import
The QR meal sharing feature already existed in the codebase before any of this work began, and the original payload format encoded a list of items that all came from the same kind of internal source. Either they were cached remote lookups, drawn from Open Food Facts or the Supabase-hosted FDC mirror, or they were entries from the user’s own custom-meal box. Just two cases, with a clean boundary between them.
Recipes quietly broke that assumption. A shared recipe is itself an item in a payload, but it composes ingredients that might be cached remote lookups, or the sender’s own custom-meal templates, or other recipes the sender has built up over time. A shared meal that happens to contain a recipe ingredient is, by structure, a multi-source object whose component pieces need to land in different places on the receiving end of the share.
The payload format bumped to v2 to accommodate this, and each item in a shared meal now carries a source tag of either off, fdc, custom, or recipe. On the receiving end, the import code reads that tag and routes each item to its appropriate local store, with OFF and FDC lookups landing in the receiver’s lookup cache, custom items landing in the receiver’s custom-meal box, and recipes landing in the receiver’s recipe box along with the full embedded recipe payload, so that the receiver does not need to make any network call to log them later that evening when she actually sits down to eat.
The thing that makes this routing more interesting than it sounds is that the receiver might not have a recipe box at all if she is on an older app version that does not yet know about recipes. So v1 payloads stay readable from the v2 reader, items default to source=custom, and the recipes bucket stays empty for those older clients. An older sender’s payload imports cleanly without complaint. A newer sender’s payload, received by an older app, would degrade by failing the version check explicitly and throwing a typed exception the UI can show, rather than silently dropping the recipe and leaving the receiver wondering what was supposed to be there.
Backward compatibility on a shared-data feature outlives most of the app’s other compatibility surfaces, which is something I had to sit with for a while before I was sure I had thought it through. A QR code generated today might be scanned six months from now, by which point both apps have updated and everything is fine. Or it might be scanned six months from now by an app that has not updated since the day the code was generated, and the share still has to work or fail kindly. Both directions need to handle the version skew without losing data or producing the wrong nutrition numbers, which is the kind of constraint that makes the format design more careful than it would be otherwise.
The recursion problem v1 dodged
A recipe, structurally, is a list of ingredients, and an ingredient can in turn be a remote food, a custom meal, or another recipe. The “another recipe” case is the one that creates a real problem if you let it through.
If somebody builds a “pasta with sauce” recipe that contains her “tomato sauce” recipe as an ingredient, and then a few weeks later she edits the tomato sauce recipe to add a clove of garlic she had forgotten about, what should happen to the pasta recipe? The snapshot rule from the previous section would say the pasta recipe should not change retroactively, because past versions of recipes ought to stay where they were. But the tomato sauce inside the pasta recipe is, in her mental model, the same tomato sauce as the one she just edited, and she will quite reasonably expect the pasta calorie count to update along with it.
That collides with the snapshot rule, and the tension can be resolved either way once you decide which mental model wins, but resolving it cleanly requires answering a sequence of harder questions about what should happen at each surrounding edge. What does the inner recipe being deleted mean for the outer one? What does it mean if the inner recipe is renamed? What if the user wants to override the inner recipe’s nutrition specifically for this outer recipe? What if the recursion depth is more than two levels? What about cycles, and how do they get prevented from forming?
The decision for v1, taken in full awareness of how much that decision was leaving on the table for later, was to exclude recipes-inside-recipes entirely. The ingredient picker has a flag, includeRecipesInResults: false, that filters recipes out of the search results whenever someone is picking an ingredient for a new recipe. A recipe can only contain remote foods and custom meals as ingredients, which means the recursion question simply does not have to be answered yet, because the recursion cannot happen in the first place.
This is the kind of decision that I find hard to write down, because it feels, on the page, like a limitation, and limitations are the part of a feature that I find emotionally hardest to ship. It is a limitation, and I want to be honest about that here rather than dress it up. It is also, even so, the right call for v1, because the design questions it dodges are big enough on their own to be a separate piece of work in their own right, and shipping the simpler version sooner means real people in real kitchens get to tell us whether they actually wanted the recursive case at all before we commit to any particular answer for it.
The “1 ml ≈ 1 g” approximation in the recipe builder is the same shape of decision, taken for the same reasons. For most things somebody is likely to enter into a recipe, water and milk and juice, the approximation is exact or near enough that nobody will ever notice the difference. For oil it is off by about 8%, and that gap is documented in the helper text underneath the field, where anyone who cares can find it. Documenting the approximation and shipping the feature is closer to what users actually need on a Tuesday evening than postponing the whole thing until a full density table has been implemented and tested. v1 simplifications are not technical debt when they are documented and bounded; they are the price you pay for shipping anything at all, and the price is usually worth paying.
Logging a recipe by serving
The smaller decision worth being explicit about is how somebody logs “one slice of cake” rather than “138 grams of cake,” because the difference between those two units of measurement is most of the difference between an app that gets used and an app that gets quietly abandoned.
A recipe optionally carries a servingsCount field, and if the user fills it in with the number 8, the converted MealEntity that ends up getting logged to the diary exposes a “serving” unit alongside the existing gram unit. The existing meal-detail screen already has a unit dropdown that handles arbitrary units, which meant that logging “1 of 8 servings” worked correctly without anybody needing to write a new UI for it; the dropdown picked the new unit up automatically and computed the right gram amount on the user’s behalf, and I love when a feature lands like that, where the existing primitives quietly do the new work for you.
This is the kind of small detail that makes the difference between a feature that technically does the thing and a feature that does the thing in the way the people using it actually want to do it. Nobody measures her own dinner in grams when she sits down to eat it. I do not, and you almost certainly do not either. We measure it in slices and bowls and plates and halves and thirds, in whatever units feel natural for the food we happen to be eating that night. The conversion down to grams happens in the data layer where the formula needs it, but the UX has to let her think in the units she actually uses in her own kitchen, or she will stop logging altogether and the app’s whole purpose for her quietly collapses.
What the work was actually like
The thing I keep coming back to about this PR, when I am at my desk late and reading back over the diff, is how much of it was old work being reused rather than new code being written from scratch. The QR sharing path already existed in the codebase, as did the food search, the meal-detail screen, and the custom-meal data layer underneath it. Most of what shipped in the recipes PR was not really new code at all; it was new code that knew how to compose the existing pieces in a slightly different shape from before. The genuinely new code that did get written, particularly the nutrition aggregation logic and the multi-source routing, is small relative to the surface area of the feature as a whole.
That ratio is one of the things I find most quietly beautiful about working in a codebase whose primitives are already in good shape. The work feels disproportionately productive, because most of it ends up being composition rather than construction, which is a different and gentler relationship with the codebase than starting from a blank file. The QR sharing post talked about how the QR size constraint kept pushing every design decision into a sharper shape; the recipes work was the inverse case, where there was a great deal of headroom because the constraints had already been worked out elsewhere in the project. Both feel productive in their own ways, and both depend on somebody having done the slow patient work of building the primitives in the first place. That work, in the case of OpenNutriTracker, is Simon’s, and most of the difference between this PR being possible and not possible is what he chose to build first when the project was still small. I am genuinely grateful for that, and I hope this post counts as a quiet thank you.
The decisions worth recording from the work, the ones I want a future contributor to be able to find again on the evening she picks this code up, are the ones that turned out to last across the whole feature. Snapshot semantics on both edit and delete, multi-source routing with a typed source tag on every item in a shared payload, recursion deliberately deferred to v2 with the door deliberately left open behind it, and backward compatibility on the wire format that survives version skew in both directions of the share. Those are the decisions a future contributor will inherit when she picks this code up, long after the screenshots have gone out of date and the demo video has been replaced by something newer. I am still thinking about which of these decisions will hold up once people are using recipes in ways neither Simon nor I have anticipated yet, and I do not know the answer to that, but I wanted to put the thinking down somewhere while it was fresh. The screenshots are what someone sees the day she installs the app for the first time. These are what the code is for everyone who comes to it afterwards, including the contributor who will eventually need to extend it, and including, on a quiet weekday evening, you.