DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three feelings, 140 characters, and a food log that computes nothing

Munchable scans a packaged food and tells you whether it suits the gut conditions you manage. Underneath that is a log: every product you have checked, by day, with an optional note on how you felt afterwards.

That log is the one place in the app where the user writes about themselves. Everything about how it is built follows from that single fact, and most of those decisions are decisions not to do something.

It never leaves the device, and the export says so

The log is health data by any reading. So it is not a table on our server with a user id on it. It is a persisted store on the phone, and nothing synchronises it:

/**
 * The scan history and food log. Health data by any reading, so it lives here
 * and nowhere else: never synced, never exported by the server's account
 * export, cleared with the rest of the device on account deletion.
 */
Enter fullscreen mode Exit fullscreen mode

The interesting consequence is what the GDPR endpoints have to say. Our data export returns every row we hold for the signed-in account: entitlement, consent record, contributions, feedback. It also carries a field whose only job is to explain an absence:

note: 'Your conditions, sensitivities, and scan history are stored only on your device and are never sent to our servers, so they are not part of this export.',
Enter fullscreen mode Exit fullscreen mode

I have written before about why a data export needs a field explaining what is not in it. The log is the reason that field exists. An export that silently omits the most sensitive thing a user wrote reads like a lie, even when the omission is the privacy feature.

Account deletion is the mirror image. There is no server-side cascade for the log, because there is nothing to cascade; there is a list of storage keys, including ones that builds we no longer ship used to write:

await AsyncStorage.multiRemove([
  'munchable-profile',      // conditions, sensitivities, preferences
  'munchable-history',      // scan history + food log
  'munchable-products-v1',  // productCache (pre-v2 key, still cleared)
  'munchable-products-v2',
  'hints:sent',             // legacy: no longer written
  'taxonomy:overlay',
  'taxonomy:meta',
]);
Enter fullscreen mode Exit fullscreen mode

Two of those keys are dead. They stay in the list because a user who installed the app a year ago still has data under them, and "we stopped writing that key" is not the same statement as "that key is empty on your phone".

The vocabulary is three words and one line

A symptom tracker will happily grow into a medical form: severity sliders, body maps, bowel scales, time of onset. We have three options and a short text field.

export type Feeling = 'fine' | 'off' | 'rough';

export const FEELINGS = [
  { id: 'fine', label: 'Fine' },
  { id: 'off', label: 'A bit off' },
  { id: 'rough', label: 'Rough' },
];

/** The note is one line, not a diary page. */
export const NOTE_MAX_CHARS = 140;
Enter fullscreen mode Exit fullscreen mode

Three chips can be tapped in the time it takes to put a wrapper in a bin, which is the only moment this ever gets filled in. A seven point scale would be filled in more carefully and much less often, and a log with three entries in it is worth nothing to the person keeping it.

The 140 character limit is the same argument from the other side. It is not a storage constraint. It is a statement that this field is "garlic bread at lunch" rather than a diary, so the row stays readable in a list and nobody feels they have failed to write it properly.

The stored verdict is the verdict as it was

Each entry keeps the verdict that was shown at the time, rather than the product's barcode alone:

export interface HistoryEntry {
  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

Re-deriving it on render would have been less code and slightly less storage, and it would have been wrong. Our curation moves: rules get added, an ingredient gets reclassified, a product's label data gets corrected by someone who photographed the pack. A log that recomputes would therefore quietly rewrite the user's own record, and the row that says "Rough" next to a product that now reads Good fit is exactly the row they need to be able to look at.

This is the same reason the log is a record and not an engine. Nothing in it interprets anything:

The log is a plain record: the verdict says what the product was, the feeling says how the day went, and the screen draws no conclusion from the two. Whatever pattern is there is the reader's to see.

The features we did not build on top of our own data

Every obvious next step here is a claim we cannot support.

  • A "foods that suit you" list is a correlation drawn from a handful of self-reported feelings, confounded by portion, timing, stress, sleep and everything else a person ate that day.
  • A streak turns a medical log into a game, and the day somebody feels rough is the day the streak punishes them for being honest.
  • A chart of rough days per week looks like a trend and is noise at this sample size.

So the history screen groups by day, labels today and yesterday, and stops. The only computed thing on it is a date header. The product does the reasoning where it has actual evidence, which is the label in your hand and the rule sets behind each condition guide, and it refuses to do any reasoning where its evidence is three taps on a smiley face.

Two small mechanics worth stealing

A rescan is the same check. Scanning one pack twice in an aisle folds into the existing entry and keeps any feeling already attached to it, which I wrote up separately in scanning the same pack twice in one aisle is one entry, not two. A log that counts the same packet three times because the scanner caught it three times is a log the user stops trusting.

Rehydration treats stored rows as untrusted input. The store's merge step drops any entry that does not have the fields this build knows how to render, and any feeling value this build does not recognise:

const entries = Array.isArray(p.entries)
  ? p.entries
      .filter((e) => !!e && typeof e.id === 'string' && typeof e.barcode === 'string' && typeof e.at === 'number')
      .map((e) => ({ ...e, feeling: isFeeling(e.feeling) ? e.feeling : undefined }))
  : [];
Enter fullscreen mode Exit fullscreen mode

An entry written by last year's build is data from a different program. The cap on the whole list is 500 entries, which is a year of daily scanning, and the comment says what that number is actually about: it is the cap on a scroll, not on a dataset.

Try it

The app runs in the browser with no account: app.munchable.app. Walk the onboarding, check a couple of products, then open History and tap the feeling row. Then read munchable.app/privacy, which says the same thing in the language we are held to: your conditions, your scans and your notes stay on the device, and there is nothing in them for us to hand over.

Erasure was easy to implement because the sensitive data was never ours. That is the whole trick, and it is a design decision long before it is a privacy policy.

Top comments (0)