Most of Munchable keeps something. Scan a barcode and a row goes into the history list on your phone, with the verdict frozen as it was at the time.
Menu mode keeps nothing. You point the phone at a restaurant menu, drag a box around a dish, and the app tells you what that dish declares against the conditions you manage. Then there is no record: no history row, no cached product, no photo, nothing in a database, nothing on disk.
That constraint turns out to shape almost every line of the feature. Here is what it looks like in practice.
The photo crosses two screens in a module variable
The capture happens on one screen and the result renders on another. The usual options for getting a photo between them are a route parameter, a file path, or a store. All three are wrong here:
// The menu photo handed from Home to the Menu screen. Held in memory only and
// overwritten by the next snap; it is never written to storage.
let pending: Shot | null = null;
export function setPendingMenuShot(shot: Shot | null): void {
pending = shot;
}
export function peekPendingMenuShot(): Shot | null {
return pending;
}
export function clearPendingMenuShot(): void {
pending = null;
}
A module-level variable, which a certain kind of code review would flag on sight, and which is the correct answer to the question being asked.
A route parameter cannot carry a base64 image, and the truncated version of that sentence is that it can, badly, in a URL that then shows up in navigation state. A persisted store is the exact thing the feature promises not to do. A file path means a file, and a file means a deletion you now have to get right on every exit path, including the ones where the process is killed. The cheapest way to guarantee a photo is never persisted is to never give it a name that outlives the request.
Three functions, and the verbs matter. peek rather than get, because reading it is not meant to consume it: the screen reads it twice, once to display what was boxed and once to send it.
// The bit of menu the user boxed, kept only as a cache file URI so the screen
// can show what was read. The base64 payload itself is dropped after the read.
const [selection] = useState(() => {
const shot = peekPendingMenuShot();
return shot ? { uri: shot.uri, width: shot.width, height: shot.height } : null;
});
useState with an initialiser rather than a useEffect, so the thumbnail exists on the first render, and the destructuring takes only the getter because nothing is ever allowed to set it. What survives into component state is a URI into the OS image cache and two dimensions. The bytes do not.
The clear happens before the cancellation check
void ocrMenu([{ base64: shot.base64, kind: 'menu' }]).then((res) => {
// The photo has done its job either way: drop it from memory.
clearPendingMenuShot();
if (cancelled) return;
if (res.ok) {
countScan();
setStatus({ kind: 'done', extraction: res.extraction });
} else {
setStatus({ kind: 'failed', reason: res.reason });
}
});
The order of those two lines is the whole point of showing you this. cancelled is the usual effect-cleanup flag, set when the screen unmounts, and the usual pattern is to check it first and return early.
Return early here and the photo stays in the module variable, held by a screen that no longer exists, until something else overwrites it. Navigating away from a result is the single most likely thing a person does with it, so the most common path would be the one that leaks. Clear unconditionally, then decide whether there is still a component to tell.
The status itself is a union rather than a pile of booleans:
type Status =
| { kind: 'reading' }
| { kind: 'no_photo' }
| { kind: 'failed'; reason: OcrFailure }
| { kind: 'done'; extraction: MenuExtraction };
no_photo is a real state, not an error, and it is what you get when the screen is reached without a pending shot: cold start from a notification, a deep link, a reload in development. The feature cannot recover that photo, so it says so plainly instead of rendering an empty result that looks like a dish with nothing in it.
Two verdicts per dish, and the code remembers both
A menu is a much worse input than a barcode. It prints a dish name, maybe a description, sometimes a list of allergen letters. It does not print an ingredients list. So the feature has a hard rule, and the rule is enforced at the display layer:
/** MENU CAP: menu mode can never show green. Applied for display only. */
export function capForMenu(v: VerdictValue): VerdictValue {
return v === 'good' ? 'caution' : v;
}
The interesting part is that the uncapped answer is kept next to the capped one:
const fit = runFitCheck(dishToProduct(dish, i), conditions, lactose, allergens, healthy);
return {
dish,
fit,
shown: capForMenu(fit.verdict),
capped: fit.verdict === 'good',
// ...
};
Because the two are needed for different things. shown is what the row renders. capped is what stops the summary from lying in the other direction:
const flaggedCount = fits.filter(
(f) => f.shown === 'avoid' || (f.shown === 'caution' && !f.capped),
).length;
Without capped, a menu with nothing worrying on it would summarise as "nine dishes to be careful about", because every clean dish had been capped to caution. The cap exists to avoid overclaiming safety, and without that !f.capped it would have overclaimed danger instead, which is the same bug wearing a different coat. A clean dish is labelled "Nothing flagged" rather than "Good fit", and it is counted as nothing flagged too.
The copy next to it says the same thing in a sentence, once, and it is a constant rather than a string in a JSX file so that every surface quotes it identically:
export const MENU_NOTE =
'Menus rarely print full ingredients, so we only check what this menu declares. A clean result here is never a green light. Ask the staff when you are unsure.';
One more thing that is easy to miss: the per-dish evaluation is memoised on the extraction and the profile, not recomputed on render. The screen re-renders every time a dish row expands, and running the engine over every dish again to get an identical answer is work the user waits for on the UI thread for no reason.
Seven failures, and a type that will not compile without copy
The network layer collapses HTTP statuses into a small union, in one function shared by both photo modes:
function ocrFailure(status: number, retryAfter?: number) {
if (status === 401) return { ok: false, reason: 'not_signed_in' };
if (status === 501) return { ok: false, reason: 'not_configured' };
if (status === 429 || status === 503) return { ok: false, reason: 'over_limit', retryAfter };
if (status === 413) return { ok: false, reason: 'too_large' };
if (status === 422) return { ok: false, reason: 'unreadable' };
if (status === 0) return { ok: false, reason: 'network' };
return { ok: false, reason: 'unavailable' };
}
And the screen maps every member of that union to a sentence:
const FAIL_COPY: Record<OcrFailure, string> = {
not_signed_in: 'Sign in to read menus.',
not_configured: 'Menu reading is not enabled on this build yet.',
over_limit: 'You have hit the reading limit for now. Try again in a little while.',
unreadable:
'We could not find a dish in that box. Try again and draw the box around the dish name and what is printed under it.',
too_large: 'That photo was too large to send. Try again.',
network: 'We could not reach Munchable. Check your connection and try again.',
unavailable: 'Menu reading is not available right now. Try again later.',
};
Record<OcrFailure, string> rather than Partial<Record<...>> or an object with a fallback. Add an eighth failure reason to the union and this file stops compiling until somebody writes the sentence for it. That is the cheapest possible enforcement of "every failure state has copy", and it costs one type annotation.
Read the sentences themselves and notice what is not there: a single "something went wrong". Each one names the thing that happened and what to do next, and unreadable teaches the gesture, because by far the most likely reason a read failed is a box drawn around a price rather than a dish. An error message that explains the input is worth more than a retry button, and this screen has both.
status === 0 as the network case is the small ugly truth of the layer underneath: a fetch that never reached a server has no status, so the client assigns it zero before it gets here. Better that one lie lives in one function than that every caller checks for a thrown exception.
Look at it yourself
- munchable.app/about states the rule this feature is built around: photos are read and then discarded, never stored. Everything above is what that sentence costs in code, and the module variable is the cheapest honest implementation of it I could find.
- munchable.app/privacy is where the same claim is made in the form that actually binds us, with the processors listed. It is also worth reading next to the cap: the notice explains that verdicts are computed on the device, which is why a menu dish can be evaluated at all without the menu leaving your phone as data.
- munchable.app/conditions lists the seven conditions a dish gets checked against, each with its own guide. A restaurant menu is the worst input any of them will ever see, and the guides are where the app explains what it does and does not know.
Top comments (0)