DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A rescan in the aisle is not a second scan, and three pure functions are the whole of our history screen

Munchable checks a food label against digestive conditions you have picked, and the verdict is computed on your phone. The conditions never reach us, which means the record of what you have scanned cannot reach us either: a list of products plus how you felt afterwards is health data by any reading, and the one thing we are sure about is that we do not want to hold it.

So the history screen has no server behind it. What it has instead is a module of pure functions, a zustand store persisted to AsyncStorage, and three decisions that took more argument than the code suggests.

No storage in the storage module

src/lib/history.ts imports exactly one thing: a type. Everything in it is a function from a list of entries to a list of entries, which means every rule below is a unit test with no device, no mock and no clock in it.

export interface HistoryEntry {
  /** Stable per check, so a feeling attaches to the right one of two scans of one pack. */
  id: string;
  barcode: string;
  name: string;
  brand?: string;
  /** The verdict at the time of the check, so the log reads as it was, not as it would be now. */
  verdict: VerdictValue;
  at: number;
  feeling?: Feeling;
  note?: string;
}
Enter fullscreen mode Exit fullscreen mode

The store on top of it is thin by design: add, setFeeling, setNote, remove, clear. All the thinking is in the functions.

Decision one: the verdict is frozen at the time of the check

That comment on verdict is the one I would defend hardest. The obvious implementation stores a barcode and recomputes the verdict when the row is drawn, which gives you a list that is always up to date with the current rule sets.

It also gives you a list that silently rewrites your own history. We improve rule sets and ingredient data every week. A product that said Caution in September can say Avoid in October, and if the log recomputes, then the entry next to the note "felt rough all evening" changes its mind about what you ate, after the fact, with no indication that anything moved.

A log is a record of what happened, including what the app told you at the time. If the answer has since changed, the honest place to find that out is to scan the pack again.

Decision two: a rescan inside an hour is the same check

Scanning is physical, and people repeat it. You scan a pack in the aisle, put it in the trolley, pull it out again at home to check, tap the product in the recent list, or photograph a label because the first read missed. Treated naively, one shopping trip produces a log that looks like an anxiety disorder.

export const REPEAT_WINDOW_MS = 60 * 60 * 1000;

export function appendEntry(entries: readonly HistoryEntry[], scan: ScanEvent): HistoryEntry[] {
  const head = entries[0];
  if (head && head.barcode === scan.barcode && scan.at - head.at < REPEAT_WINDOW_MS) {
    const refreshed: HistoryEntry = {
      ...head,
      name: scan.name,
      brand: scan.brand,
      verdict: scan.verdict,
      at: scan.at,
    };
    return [refreshed, ...entries.slice(1)];
  }
  return [{ ...scan, id: entryId(scan) }, ...entries].slice(0, HISTORY_CAP);
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice.

It only folds against the head of the list, not against any entry within the window. The list is newest first, so the head is the only candidate for "this is the same check continuing", and comparing against more than that would merge two genuinely separate checks of the same product with something else in between.

The refreshed entry keeps its id, and therefore keeps whatever feeling and note were already attached to it. A rescan updates the facts about the product and does not disturb what the person wrote about themselves. That asymmetry is the rule: the app owns the product fields, the user owns the two fields underneath.

Decision three: days are local, and the cap is a scroll limit

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')}`;
}
Enter fullscreen mode Exit fullscreen mode

Hand-built from local parts, not toISOString().slice(0, 10). A check at 23:30 in a positive offset would file under tomorrow with the ISO version, and a log whose evening lands on the wrong day is a log you stop trusting at exactly the moment you are trying to connect a meal to how you slept.

The grouping function then walks the already-sorted list and buckets neighbours, rather than building a map and sorting its keys. Today and yesterday get words instead of dates, and a date from another year gets the year, which is the whole of the labelling logic.

The cap is 500 entries:

/**
 * How many checks the log keeps. A year of daily scanning fits, and the list
 * screen renders it all without paging, so this is the cap on a scroll, not
 * on a dataset.
 */
export const HISTORY_CAP = 500;
Enter fullscreen mode Exit fullscreen mode

The hub also shows a short recent list, and that one deduplicates by product rather than by time, so the three things you most recently checked are three different products rather than the same oat drink three times.

The screen computes nothing

The list shows what the verdict was, the time, and your feeling if you left one. Three feelings, one note of up to 140 characters, no scores, no streaks, no "your trigger foods" panel, no chart.

That is a deliberate refusal and it is the most-questioned thing in the app. The reason is that the correlation people want from it is a clinical claim. Food, symptoms and timing are genuinely hard, confounded, and personal; an app that announces "onion seems to be a problem for you" from twelve rows of data is inventing a diagnosis and handing it to somebody who may act on it. The log's job is to be a record good enough to take to a clinician. Whatever pattern is in it is the reader's to see.

Deleting it is a key list, because that is all there is

Because nothing was ever synced, account deletion locally is one call with a literal list of keys:

await AsyncStorage.multiRemove([
  'munchable-profile',   // conditions, sensitivities
  'munchable-history',   // scan history + food log
  'munchable-products-v2',
  // ... cache and taxonomy keys, plus old keys earlier installs left behind
]);
Enter fullscreen mode Exit fullscreen mode

It is best effort and never throws, because a storage error must not be able to block somebody deleting their account. And the server's data export has nothing of this in it, which the privacy notice has to say out loud, since "download everything we hold" would otherwise sound like it includes a log we have never seen.

Look at it yourself

  • The claim is written down in our privacy notice at munchable.app/privacy: the conditions you pick, your sensitivities and your scan history are held on your device and never sent to us, and the rules engine that produces the verdict runs there too. That page also lists every processor we use, so you can check that none of them is a place a food log could be hiding.
  • To use the thing itself, sign in at munchable.app/get-started and choose "Continue in browser". The history screen is reachable from the hub; the same build runs on Android.
  • What the frozen verdict is a snapshot of is public: munchable.app/answers is a page per ingredient and condition, each answered by the same fixed rule sets the app runs, for example is inulin low FODMAP. When those pages change, your old log entries do not.

The general lesson I took from this one: when a feature holds the most sensitive data in your product, having nowhere to put it is a design constraint worth choosing on purpose. It removed the sync, the conflict resolution, the export, the retention policy and the breach surface, and what it left behind was roughly 130 lines of functions that take a list and return a list.

Top comments (0)