DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The same 38 recipes are a catalogue on our website and a silent filter in our app

Munchable scores food against gut conditions. Most of it is a barcode scanner, but it also ships a recipe library, and that library is the clearest example I have of one dataset having to behave like two completely different products depending on where it is rendered.

On the website it is a catalogue. Every recipe is a public page, and each one names the conditions it suits: munchable.app/recipes, and a single one such as the ginger chicken rice bowl, which says out loud that the rules clear it for all seven conditions.

In the app the same 38 recipes are a filter. There are no labels, no verdicts, no counts of what was left out and no "show me the ones that do not fit" control. A recipe either suits you and appears, or it does not and is simply absent. You can try it yourself at app.munchable.app: pick a couple of conditions during onboarding, then open Recipes and notice what the screen is not telling you.

Same package. Same engine. Opposite rules about what the reader gets told. That difference is deliberate and it took a while to articulate why.

Why the app says nothing

The scan screen is generous with reasoning. You are standing in a shop holding a jar, you asked a specific question about that jar, and the app owes you the verdict and the reason behind it. Withholding the reasoning there would be insulting.

A recipe list is the other situation. Nobody asked about any particular recipe. It is a suggestion, and a suggestion the reader has to evaluate and reject is worse than one that was never shown. Putting "avoid, high FODMAP" cards in a list of dinner ideas turns browsing into triage.

So the filter is a boolean and the reasoning is thrown away:

function suits({ fit }: ScoredRecipe): boolean {
  if (fit.verdict !== 'good') return false;
  return !(fit.allergens?.hits ?? []).some((h) => h.level === 'contains');
}
Enter fullscreen mode Exit fullscreen mode

That second line looks redundant. A declared allergen already forces the verdict to avoid, so the first line has caught it. It is there anyway, because that coupling lives in the engine and could be loosened there by someone with a good reason, and a recipe naming a declared allergy must never reach a list whatever any other layer decides. The allergen layer in this codebase can only ever add warnings, never clear one, so a second check can only ever remove more. It costs one line and it is the one gate I want belt and braces on.

There is no third gate for the healthy-shopping preferences, and the comment explaining that is my favourite in the file:

// Healthy shopping needs no third gate here. A hit keeps a verdict off `good`,
// and `good` is already what this function requires, so a recipe carrying
// something the reader asked not to buy is absent for the same reason a
// high-FODMAP one is. That it has never happened is the point: these recipes
// are written from whole ingredients, so the filter finds nothing in them, and
// if one ever did call for glucose syrup it would drop out of the list for the
// reader who said so without anybody remembering to add a check.
Enter fullscreen mode Exit fullscreen mode

Why the website says everything

A web visitor has no profile. There is nothing to filter against, so filtering is not on the table, and "suits a low FODMAP pattern" stops being a judgement about the reader and becomes an index label. It is also, bluntly, the thing they typed into a search engine.

But a public label is a claim, and claims have to survive contact with the app. Somebody reads the page, installs, opens the same recipe and sees it excluded from their list. That is a bug report, and rightly.

The fix is one argument:

function suits(recipe: Recipe, condition: ConditionId): boolean {
  const fit = fitCheck(toProduct(recipe), {
    conditions: [condition],
    settings: { lactose: { sensitivity: 'high' } },
  });
  return fit.verdict === 'good';
}
Enter fullscreen mode Exit fullscreen mode

Suitability on the public page is read at the strictest setting the app offers. The public claim therefore can never be softer than what the app will do for the person who acts on it. The asymmetry is the safety property: the site can undersell a recipe, and that is fine, but it cannot oversell one.

No server is allowed to know

There is no recipes endpoint and there must never be one. The recipes ship in the app bundle and are scored on the device, against a profile that never leaves the phone.

This is not an optimisation, it is the architecture. Generating or filtering meals server side means sending the condition list to a server, and a list of someone's gut conditions leaving their phone and arriving next to their account is exactly the linkage the product is built to avoid. Keeping the scoring local removes the question entirely rather than answering it in a privacy policy.

The happy consequence is that the whole feature works on a plane and costs nothing to serve.

The cost is that scoring happens on the UI thread, every render, on a phone:

/**
 * Call inside a `useMemo` keyed on the profile, the way the menu screen does
 * for dishes: the screens re-render on every filter chip and every search
 * keystroke, and re-scoring the library to get the same answer back is work the
 * user waits for on the UI thread.
 */
Enter fullscreen mode Exit fullscreen mode

And there is an empty-state trap worth knowing about if you ever filter by "the engine approves". A profile with no conditions selected scores every recipe unknown, because the engine has nothing to evaluate, and unknown is not good, so the perfectly reasonable filter above returns an empty library to a brand new user. The screen picks between suitableRecipes and allRecipes explicitly, and when it shows everything it says so rather than implying a filter ran.

The numbers are computed, and that was a change of plan

Two of the seven rule sets read fat, and one of them also reads fibre. With no numbers they return unknown, which means a recipe with no nutrition data shows "cannot assess" to a whole class of users on every single recipe.

The original plan was for recipe authors to type per-serving fat and fibre onto each recipe. We compute them from the ingredient quantities instead:

/**
 * Values are per 100 g of the ingredient **as weighed into the recipe**: dry
 * for rice, oats and pasta, raw for meat and vegetables.
 */
export const NUTRITION: Record<string, NutritionEntry> = {
  'en:chicken-breast': { fat: 3.6, fiber: 0 },
  'en:cod': { fat: 0.7, fiber: 0 },
  'en:salmon': { fat: 13.4, fiber: 0 },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

A hand-typed per-100 g figure is a number nobody can check, and one an author silently invalidates the moment they change a quantity. A derived one cannot disagree with the ingredient list, because it is the ingredient list. The page you land on says "About 342 g a serving, with 8 g of fat and 4 g of fibre in it", and every one of those numbers came out of the grams in the recipe.

Cooking water is not an ingredient line, for the same family of reason. It goes in the steps. If it were a line it would be weight in the dish that dilutes every percentage, and a trigger ingredient would quietly drop below the threshold that makes it a main ingredient. The rule is enforced by the library's own test file rather than by author discipline.

The SEO decision I talked myself out of

The obvious growth move here is seven collection pages: low FODMAP recipes, reflux friendly recipes, and so on. Seven head terms, seven pages, internal links from the condition guides that already exist. It writes itself.

It would not work yet, and the numbers say why. Thirty of the thirty-eight recipes suit reflux. Thirty-seven suit low FODMAP. The thinnest condition still has thirty. Those seven pages would be near-duplicates of each other and of the index, competing for the same crawl budget, and a search engine would be right to pick one and ignore the rest.

So the index is grouped by meal instead, which is how someone browsing at six in the evening actually thinks, and the per-condition traffic goes to the condition guides, which have genuinely different content and link into the library. Collections become worth building when the library is large enough that the lists genuinely diverge. That is a content problem, not a template problem, and shipping the template first would have buried it.

What I would take to another project

The reusable idea is not "share a package between web and native". Everybody does that. It is that the same computation can be an answer or a filter, and which one it is depends on whether the reader asked a question.

A scan is a question, so it gets a full answer. A list is an offer, so it gets silence and a shorter list. Getting that backwards gives you either a shop-floor app that hides its reasoning or a browsing screen that argues with you, and both feel bad in ways that are hard to trace back to a single decision.

If you want to see both halves next to each other: the catalogue is at munchable.app/recipes with every page public, and the filter is in the app at app.munchable.app.

Top comments (0)