Munchable keeps a history of every product you have checked, and lets you attach how you felt afterwards. For a gut-health app that is close to the centre of the product: the whole point of scanning things is working out what is doing it.
It also means the app holds a list of what someone eats and when their stomach hurt. So the first decision about the food log was not a feature decision. It was where the rows live.
They live on the phone. That is the whole design.
The log is a Zustand store persisted to AsyncStorage and nothing else:
export const HISTORY_STORAGE_KEY = 'munchable-history';
Never synced. Not in the server's account data export, because the server has never seen it. Not restorable if you lose the phone.
That last one is a real cost and I want to be honest about it rather than dress it up. New device, empty log. We decided that was the correct trade, because the alternative is a table on our infrastructure whose rows are, read plainly, a symptom diary. Everything else in the app already works this way: your conditions live on the device, a barcode lookup sends the barcode and never the profile, and the server is asked "what is in this product" rather than "what is in this product, for someone with IBS". A synced food log would have been the one place that rule broke, and it would have broken it with the most sensitive data in the app.
Deletion follows from the same place. Account deletion wipes the device, and the storage key is listed in the wipe helper alongside the profile and the caches, so removing the account removes the log without a request to anything:
await AsyncStorage.multiRemove([
'munchable-profile', // health profile
'munchable-history', // scan history + food log
'munchable-products-v2',
'taxonomy:overlay',
'taxonomy:meta',
]);
The log does not have an opinion
The vocabulary is three feelings and one line of free text:
export type Feeling = 'fine' | 'off' | 'rough';
export const NOTE_MAX_CHARS = 140;
Nothing in the module interprets any of it. There is no correlation, no "products you rate rough often contain lactose", no summary card at the top.
There was, briefly. It got deleted. Two reasons, and the second one is the one I would keep.
The first is that the statistics are not there. A handful of scans a week, self-reported comfort on a three-point scale, no control for anything at all, and a confounder in every meal. Any pattern a card could draw from that is a coin flip dressed as a finding.
The second is that a nudge changes what gets logged. Show someone a card that says dairy looks like a problem, and the next time they feel rough after a yoghurt, they log it, and the time they felt rough after a sandwich, they do not. The summary becomes its own evidence. A log that stays quiet is a better instrument, and the person reading it already has context no app has.
So the log is a record. Any pattern in it is the reader's to see.
The verdict is frozen at the moment of the check
export interface HistoryEntry {
id: string;
barcode: string;
name: string;
verdict: VerdictValue; // as it was, not as it would be now
at: number;
feeling?: Feeling;
note?: string;
}
The stored verdict is the one shown at the time, not a foreign key to a product that gets re-evaluated on render.
Our rule data changes. Ingredient lists get corrected, curation fills in a word the engine could not place, a condition's rule set gains an entry. Any of those can flip a product's verdict, and if the log recomputed on open, a row would silently change to disagree with what you remember seeing, in the one screen whose job is to record what happened. Worse, it would change underneath a "felt rough" note that you attached to the thing the app told you at the time.
The cost of freezing is a log that can be out of date relative to the engine. That is fine, because it is a diary. Diaries are allowed to say what you thought last Tuesday.
A rescan in the aisle is one check, not two
export const REPEAT_WINDOW_MS = 60 * 60 * 1000;
export function appendEntry(entries, scan) {
const head = entries[0];
if (head && head.barcode === scan.barcode && scan.at - head.at < REPEAT_WINDOW_MS) {
return [{ ...head, name: scan.name, verdict: scan.verdict, at: scan.at }, ...entries.slice(1)];
}
return [{ ...scan, id: entryId(scan) }, ...entries].slice(0, HISTORY_CAP);
}
Scanning the same pack twice inside an hour is one event with a wobbly camera in front of it, or a tap on the recent row, or a label capture that replaced a failed lookup. It refreshes the entry rather than adding a twin, and it keeps any feeling already attached, because the feeling belongs to the food and not to the camera.
The entry id is ${at}-${barcode}, which is stable per check rather than per product. That matters exactly once: when you check the same pack twice in one day and attach a note to the second one. A product-keyed id would put it on the wrong row.
500 entries, and why it is a scroll cap
export const HISTORY_CAP = 500;
A year of daily scanning fits. The list screen renders all of it, grouped by local day, with no paging.
That number is not protecting a database, because there is no database. It is protecting a FlatList. When a cap is about rendering rather than about storage, the honest thing is to say so in the comment, otherwise the next person to touch it assumes there is a quota somewhere and designs around a constraint that does not exist.
Day grouping uses the local calendar day, which is the one date bug in this file worth naming:
export function dayKey(at: number): string {
const d = new Date(at);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
Build that key out of an ISO string and a check at 23:30 files under tomorrow for anyone east of UTC. "Today" and "Yesterday" are the two labels people actually read on that screen, and getting them wrong is immediately visible.
Migrating local state when there is no migration system
Before the log existed, the profile store kept the last twelve scans for a recent list on the home screen. Those needed to become the first entries of the log, once, on the first run of the new build.
There is no server to run a migration on, and two independent persisted stores that hydrate on their own schedule. So it is a function that runs when either one is ready and gives up until both are:
function migrateLegacyScans(): void {
const run = (): boolean => {
const profile = useProfile.getState();
const history = useHistory.getState();
if (!profile.hydrated || !history.hydrated) return false;
if (profile.recentScans.length > 0) {
if (history.entries.length === 0) {
useHistory.setState({ entries: [...profile.recentScans]
.sort((a, b) => b.at - a.at)
.map((r) => ({ ...r, id: entryId(r) })) });
}
profile.clearLegacyScans(); // so it cannot import twice
}
return true;
};
if (run()) return;
const unsubscribe = useProfile.subscribe((s) => { if (s.hydrated && run()) unsubscribe(); });
}
Clearing the legacy field is what makes it idempotent, and the entries.length === 0 guard is what stops it clobbering a log that already exists.
The other half of surviving a schema change is the rehydrate step, which treats persisted data as untrusted input, because a device may be holding anything an older build wrote:
merge: (persisted, current) => {
const entries = Array.isArray(persisted?.entries)
? persisted.entries
.filter((e) => !!e && typeof e.id === 'string' && typeof e.at === 'number')
.map((e) => ({ ...e, feeling: isFeeling(e.feeling) ? e.feeling : undefined }))
: [];
return { ...current, entries };
},
A retired feeling id becomes undefined rather than rendering as a blank chip. On a server you would write a migration; on a device you write a filter, and it runs forever.
Where to look
The on-device rule is not a blog claim, it is in the policy:
- Our privacy policy sets out what is stored on the device versus on the server, and what an account export contains.
- The FAQ on the landing page has the short version, under "Do you see my health data?".
- The condition guides are what the verdicts in the log were produced against.
The log itself is in the app at munchable.app. Scan a few things, tap one of them and tell it you felt rough. Nothing you type goes anywhere.
Top comments (0)