Munchable's on-device profile has two layers that look similar and are not.
Conditions are the eating patterns you want checked: IBS and low FODMAP, reflux, lactose, and so on. Each one has its own rule set, and the engine grades what it finds and explains the reason.
Allergies are declared, and they are checked against rather than eaten for. That layer has one hard property: it only ever adds a warning. It cannot clear anything, and it can never report an allergen as absent. If it finds nothing, that means the data we hold did not mention it, and the result screen says exactly that rather than saying you are fine.
They are two arrays on the store, deliberately, and for most of the app's life they did not interact at all. Then we added coeliac disease, which is the one condition that asks a label the same question an allergen asks.
The problem
Someone with coeliac disease ticks the condition. They do not think to also go to the allergies screen and tick gluten, because why would they: they just told the app they have coeliac disease.
That person is now getting the more forgiving of the two readings. The condition layer grades, explains, and weighs what it finds. The allergen layer is the blunt one that only ever warns. For most conditions the gentler reading is the right product. For the one condition where there is no safe amount, the blunt layer is the one you want switched on, and it was off.
The fix is four lines and one of them is a type
/**
* Allergens a selected condition implies.
*
* So selecting the condition switches the allergen on. Removing the condition
* deliberately does NOT switch it back off: by then it is a declared allergy
* sitting visibly on the allergies screen, and silently dropping one is not a
* thing this app should ever do.
*/
const IMPLIED_ALLERGENS: Partial<Record<ConditionId, AllergenId>> = {
coeliac: 'gluten',
};
function withImpliedAllergens(
conditions: readonly ConditionId[],
allergens: readonly AllergenId[],
): AllergenId[] {
const out = [...allergens];
for (const condition of conditions) {
const implied = IMPLIED_ALLERGENS[condition];
if (implied && !out.includes(implied)) out.push(implied);
}
return out;
}
Two decisions in there are worth more than the code.
It is written down, not derived
The obvious implementation is a selector: keep the stored arrays exactly as the user set them, and union in the implied allergens wherever the engine is called. Fewer writes, no redundant state, and the derivation lives in one place.
It is also wrong for this app, because the allergies screen would then show gluten unticked while the engine behaved as though it were ticked. The profile screen's chips would not list it. A user could read every screen we have and come away believing gluten is not being checked, while it is.
The persisted profile is not just input to the engine. It is the thing we show the user as the account of what we are doing on their behalf. If the engine acts on something, it has to be visible as a selection, which means the implication belongs in the writer:
setConditions: (conditions) =>
set({ conditions, allergens: withImpliedAllergens(conditions, get().allergens) }),
toggleCondition: (condition) => {
const has = get().conditions.includes(condition);
const conditions = has
? get().conditions.filter((c) => c !== condition)
: [...get().conditions, condition];
set({ conditions, allergens: withImpliedAllergens(conditions, get().allergens) });
},
Both writers, not one. A map like this is the sort of thing that gets applied on the toggle and forgotten on the bulk setter, and the bulk setter is the one onboarding uses.
The toggle is not an involution, on purpose
toggleCondition('coeliac') twice does not restore the profile you started with. The condition comes back off; the gluten allergy stays on.
Normally that is a defect. A toggle that does not round-trip is the kind of thing that shows up as a weird bug report six months later. Here it is the entire intent, and the asymmetry falls out of what the two layers mean:
- Adding a warning because the user told us something is a reasonable inference.
- Removing a declared allergy because the user changed an unrelated setting is not an inference, it is a silent downgrade of a safety warning.
By the time the user unticks the condition, gluten is a chip on their profile screen and a selected row on the allergies screen. It is theirs. Removing it is one tap, in the place that exists for exactly that, and that tap is explicit. We do not get to do it for them as a side effect of something else.
The general shape, which I think holds beyond this app: when two settings overlap and one of them is the cautious one, the union is the only defensible default, and the direction you refuse to automate is the removal.
The other half of the same file
While I was in there I noticed the store has a second rule about storage being the source of truth, written a while earlier, for the opposite failure.
Zustand's default merge on rehydration is almost exactly right: a profile saved before the allergies feature existed simply picks up the empty default. What it does not do is drop ids that this build no longer knows about. So:
merge: (persisted, current) => {
const p = (persisted ?? {}) as Partial<ProfileState>;
return {
...current,
...p,
conditions: Array.isArray(p.conditions) ? p.conditions.filter(isConditionId) : [],
allergens: Array.isArray(p.allergens) ? p.allergens.filter(isAllergenId) : [],
healthy: Array.isArray(p.healthy) ? p.healthy.filter(isHealthPreferenceId) : [],
};
},
An allergen id renamed or retired in a later release would be ignored by the engine, so a profile listing it would silently render no allergy row at all. Worse, the pickers look every id up by name to draw its label, and would crash on one they do not have. Three type guards on the way in, and a stored id that no longer exists disappears from the UI as well as from the check, instead of the two disagreeing.
Same principle as the implied allergen, pointed the other way: what is stored and what is checked have to be the same set, because the stored set is what the user is shown.
See it from the outside
The web side says the same thing in prose, which is how I know the rule is stable enough to write down: the note on hidden gluten explains that the allergen layer only ever adds a warning and never clears one, "which is why turning on coeliac disease also turns on the gluten allergen", and the coeliac disease guide covers what the condition reads and what it refuses to claim. The eight conditions have a guide each, and coeliac is the only one with an implied allergen so far.
Top comments (0)