DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Scanning the same pack twice in one aisle is one entry, not two

Munchable scans a barcode and answers one question: does this packaged food fit your gut conditions. Every answer goes into a list on the phone, and you can attach how you felt afterwards. It is a log, not a dashboard: munchable.app.

The list looked trivial. Append a row, render newest first. It was not trivial, because a list of user actions is a list of events, and the things people actually do in a supermarket do not map one to one onto rows anybody wants to read.

The aisle problem

Watch someone use a scanner app for two minutes and you will see the same product go through it more than once:

  • The first read was at an angle and they scan again to be sure.
  • They tap the product in the recent list to re-read the reasons.
  • The product was missing, they photographed the label, and now the same barcode resolves properly.

None of those are three separate facts about that person's week. They are one check, viewed three times. Appending blindly gives you a log that reads like a stutter, and worse, it gives you three rows that each need their own answer to "how did you feel after this".

So the log has a notion of "the same check, again":

/**
 * A second check of the same pack inside this window is the same check: a
 * rescan in the aisle, a tap on the recent row, a capture that replaced a
 * miss. It refreshes the entry rather than adding a twin, and keeps whatever
 * feeling was already attached.
 */
export const REPEAT_WINDOW_MS = 60 * 60 * 1000;

/** Newest first, capped, with a quick repeat folded into the entry it repeats. */
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

Three decisions are hiding in those ten lines, and each one had an alternative we rejected.

It only looks at the head, not the whole list. Deduplicating against every entry in the log would mean that scanning the same yoghurt every Tuesday collapses your month into one row. The window is about the aisle, not about the product, so only the most recent entry can absorb a repeat. If you scanned something else in between, the repeat is a genuinely new check and gets its own row.

A repeat refreshes rather than replaces. The spread keeps the existing id, feeling and note, and takes the new name, brand, verdict and timestamp. That ordering matters: a capture that finally resolved a missing product should upgrade "Unknown product" to its real name, without throwing away the note the user typed 40 seconds ago.

An hour, not five minutes. Five minutes covers the double scan. It does not cover the walk to the till, and "I scanned it, put it in the trolley, then came back to it" is an extremely normal shape for this app.

The verdict is frozen, deliberately

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 tempting design is to store the barcode and re-run the rules engine when the list renders. It costs no storage, it is always consistent with the current rules, and it is wrong.

Our ingredient data improves constantly. A product that was "Caution" in March can be "Good fit" today because we learned more about one ingredient, or because the manufacturer reformulated. If the log re-derives, then the row where somebody wrote "felt rough afterwards" silently changes into a row that says the app thought it was fine. Their note now looks like nonsense, and the app has quietly rewritten their own history.

A log is a record of what happened, which includes what the app told you at the time. So the verdict is copied in at write time and never recomputed.

The id is ${at}-${barcode}, which is stable for the life of the entry and derived from nothing mutable. It exists so that setFeeling(id, 'rough') cannot attach a symptom to the wrong one of two scans of the same pack on different days, which is exactly the bug you get if you key by barcode.

Two more small things that were bugs first

The recent strip on the home screen shows the newest entry per product, which is a different list from the newest entries:

/** The newest entry per product, for the hub's short recent list. */
export function recentUnique(entries: readonly HistoryEntry[], limit: number) {
  const seen = new Set<string>();
  const out: HistoryEntry[] = [];
  for (const e of entries) {
    if (seen.has(e.barcode)) continue;
    seen.add(e.barcode);
    out.push(e);
    if (out.length >= limit) break;
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Without the Set, a morning spent comparing three brands of bread fills the entire strip with bread.

And day grouping uses the local calendar day, from getFullYear/getMonth/getDate, not from a UTC date string:

/** Local calendar day, so a check at 23:30 does not file under tomorrow. */
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

toISOString().slice(0, 10) is shorter, and in any timezone east of UTC it files a late-evening snack under Tomorrow. Users do not have a timezone, they have an evening.

Why all of this is a pure module

None of the code above is in a component or in the store. appendEntry, recentUnique, dayKey, dayLabel and groupByDay are exported functions over plain data, and the Zustand store is a thin shell that persists the result:

add: (scan) => set({ entries: appendEntry(get().entries, scan) }),
Enter fullscreen mode Exit fullscreen mode

That split is what makes the interesting behaviour testable without a simulator, a fake clock or a render. The repeat window, the 59-minute case, the 61-minute case, the note surviving a refresh, the midnight boundary: those are assertions about two arrays, and they run in a plain Node test in milliseconds. A React Native test that needs a device to check whether a rescan produced two rows is a test nobody runs twice.

There is also a cap:

/**
 * 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

Naming what a constant is a promise about is more useful than the number. This one promises that the screen never needs pagination.

Where it lives, and what that buys

The whole log is one AsyncStorage key on the device. It is never synced, it is not part of the account export the server can produce, and it is in the list of keys wiped when an account is deleted. That is not a storage optimisation, it is the product's position on health data: the app can say "your conditions and your history stay on your phone" and mean it literally, because there is no server-side copy to qualify the sentence with.

The consequence we accepted is that a new phone starts with an empty log. We would rather explain that than explain a breach.

You can read the full data position here, including what the server does hold and what the export contains: munchable.app/privacy. The verdict vocabulary the log stores is per condition, and each condition's rule set is described on its own page: munchable.app/conditions.

If you have built a local log like this, I am curious where you put your repeat window, and whether you also learned about it from your own list of stutters.

Top comments (0)