Health Scanner
Scan a supermarket barcode with your phone and get a 0-100 health score that shows its working. Portuguese-first, built on Open Food Facts, and early enough that the methodology is still the product.

You are standing in a supermarket aisle holding two jars of chocolate spread. One of them is meaningfully worse than the other and nothing on the label tells you which. Health Scanner is a web app that answers that in about two seconds: point the phone at the barcode, get a number out of 100, and - this is the part I care about - see exactly how that number was arrived at.
It is Portuguese-first, it runs in the browser as a PWA with no framework and no build step, and it is new enough to still be counted in days: the first commit is dated 19 August 2026. Everything below is real and working; none of it is open to the public yet, for a reason I will get to.
Where it is right now
Five of the six milestones are built and working end to end against live data:
| Milestone | What it is | State |
|---|---|---|
| M0 | The walking skeleton - camera scan, barcode lookup, raw fields on screen | built |
| M1 | Scoring: the composite, the refusals, the additive resolution, the PT-PT copy | built |
| M2 | Alternatives: what should I buy instead?, answered live | built |
| M2.5 | The scan record - every lookup written down, so coverage becomes a number | built |
| M2.6 | The verdict - a thumb on each suggestion, and a desk to review them from | built |
| M3 | The mirror - a database underneath, so it can have more than one user | next |
M3 is the reason there is no public link on this page. Open Food Facts allows 15 reads and 10 searches per minute per IP, and on a serverless host every user shares one IP - so that ceiling is the whole app's, not each person's. One person scanning a pantry is nowhere near it. Thirty people are. A read-through mirror is the gate on letting anyone else in.
What is missing now is not code. The premise this whole project rests on is that I do not need to build a food database, because an open one already exists - and that premise is still an assumption. The instrument that tests it is finished: every lookup is written down, found, found-but-too-thin and not-found alike, and one read-only script turns a shop into a table. The shop has not happened yet. When it does, either Open Food Facts knows the own-brand products people here actually buy, or this is partly a data-contribution app and the plan changes shape. I would rather find that out from a supermarket than from a spreadsheet.

Why another food scanner
Because the interesting problem is not the scanning, it is being willing to show your work. An app that tells you a named brand's product is bad has to be able to say why, in public, in a way that survives being challenged. So the methodology is a written document first and code second, and every rule in it has a reason attached.
A few of the decisions that fell out of that:
- The score refuses to exist when it would be a guess. No Nutri-Score grade means no number at all - that grade is 65 % of the answer, and the rest would be arithmetic wearing a lab coat. Refusing is more honest and more annoying, and I chose honest.
- A missing input is never a zero. Treating "we don't know" as "it's fine" flatters incomplete records precisely because they are incomplete.
- Additives are a deduction, not a component. As a weighted component, a product with no flagged additives earned a free 100 on it, which quietly propped up ultra-processed food. Not containing a concerning additive is the normal case, and normal should not score points.
- Only hard regulatory action is penalised. Eleven additives, each because a regulator acted - not because a study exists. Every citation was verified against current EU law, and a script re-checks each one against Crossref: the DOI must resolve and its title must name the E-number it is attached to.
- The letter is mapped, never the number. Coca-Cola Zero's raw 12 is an E, because beverages are graded on their own scale; mapping the number would systematically flatter sugary drinks.
Nutri-Score and NOVA are shown beside the composite, attributed, as the third-party classifications they are. The strongest claims on the screen are theirs. Mine is the labelled convenience on top.
The part that took the longest
"Same category, better score" sounds like an afternoon's work and is not. Query the narrowest category and every alternative to a diet cola is another diet cola. Query the broadest and the app suggests water - true, useless, and slightly insulting.
So it walks a category ladder narrow to broad, stops at the first rung with three products scoring materially better, and refuses rungs that name a department, a process or an ingredient rather than a kind of food. Every entry in that refusal list is a suggestion the app actually made before it was added: bread answered with penne, fiambre answered with tuna spread, sliced chicken answered with raw chicken.

The comparison is per 100 g, both columns from the same source, and the app says so. A partial score is not comparable, so it is not allowed to be a candidate - the first run put a Pepsi Zero above the Coca-Cola Zero that had just been scanned. The same drink, ranked higher for having less data behind it.
Whether the suggestions are any good
The log can tell me a great deal about a suggestion: which rung of the ladder it came from, how many candidates the search returned, what each of them scored, how long the walk took. None of that says whether a human would have put the thing in a trolley. Counting the structure of a suggestion and calling it quality is how you end up with confident numbers measuring nothing, so the app now collects the judgement separately, from both ends of the same day.
In the aisle: a thumb up and a thumb down on each suggestion, at the foot of the comparison you already opened. No count, no aggregate, no thanks-for-your-feedback, and no effect on the order of the cards - the moment it looks like a rating widget it starts collecting the answer people give widgets.

At the desk afterwards: the same judgement, from a small backoffice over the log, because half the bad suggestions are only recognisable beside the twenty others the ladder made that day. Both write to the same record, tagged with which of the two they came from, and they are allowed to disagree - a thumb given while holding the packet and a thumb given while reading a table are different acts, and when they contradict each other that is the most interesting row on the page rather than a conflict to resolve.

That screenshot is eight lookups from a kitchen table, not the shop run - which is exactly why the numbers in it are small and why the run is the next thing that happens.
Four rules hold that together, and each of them is one I would have got wrong by default:
- Silence is not a thumb down. Most suggestions will never be rated. So verdict coverage is reported as its own number - the 27.8 % in the screenshot above is the share of suggestions anybody judged at all - and up and down are only ever counted against those, never against the ones nobody looked at.
- A thumb with nowhere to go is worse than no thumb. The buttons do not render until the row they would be written to exists. Otherwise somebody in a supermarket presses one, watches it move, and believes they have answered.
- Pressing the same thumb again withdraws it. Unrated has to stay reachable, or a mis-tap silently becomes a judgement nobody made.
- The backoffice is local-only, and that is the entire security model. It reads the
whole log, so an unprotected admin route on the public deploy would publish every barcode
and store to whoever guessed the URL - and a shared secret in an environment variable is
not worth trusting when the page has no reason to be reachable from a supermarket at all.
It binds to localhost, it is excluded from the deploy, and it refuses to start inside one.
It browses through a database role that can only
SELECT.
Deleting is soft by default: a row is marked excluded with a reason, every analysis query
filters it out, and the report says how many went. A hard DELETE makes a figure I already
published permanently unreproducible, and there is no amount of disk space worth that.
The first version of the per-rung table divided verdicts given by suggestions judged, which meant one product thumbed in the aisle and again at the desk could push a category past 100 %. It was caught by probing the queries against the database before a single real verdict existed, which is roughly the point of building the instrument before the shop.
How it is built
public/ vanilla ES modules - render, scanner, ladder, alternatives
every user-facing string in one PT-PT module, by design
api/ serverless functions: product lookup, category ranking, and three
writes - the scan, what the ladder did, and the verdict
_lib/ score.js is the methodology as code. Pure: no I/O, no clock.
data/ the Open Food Facts additives taxonomy, reduced and committed
scripts/ the coverage report. Read-only: every statement is a select.
backoffice/ never deployed. The log, on one laptop, over a read-only role.
No framework and no build step. There is now a database, and it is not the one you would expect: Postgres holds the log of what was scanned and what was suggested, while the lookups themselves still go straight to Open Food Facts on every request. The premise that makes the whole thing small is that we do not build a food database - Open Food Facts is an open, ODbL-licensed catalogue of roughly four million products with a free API, and this is a scoring layer and a UI on top of it. The mirror that would put a copy of it underneath is M3, and it is infrastructure rather than product: it adds no feature at all.
152 tests under node:test, none of which touch the network, because a test that read live
data would start failing on somebody else's edit.

What comes after that
The roadmap past the mirror is where I think the idea actually gets good:
- Personalisation profiles - reweight salt, sugar, saturated fat and additive tolerance per person. A flat score is actively unhelpful for Portuguese staples like enchidos, bacalhau salgado and queijo curado, and every competitor gives everyone the same number.
- Ingredient-label photo to structured data, as the fallback for products the database does not know.
- Allergen alerts against a profile.
- Non-food categories - cosmetics and household products, where the honest answer may not be a number at all. There is no Nutri-Score for shampoo, and a score implying precision the evidence cannot carry would be worse than a careful per-ingredient scale.
Notes and corrections welcome
This is not a product and I am not selling anything. It is a working prototype and a written argument, and the argument is the part I would like challenged - particularly by anyone who works in nutrition or food policy and thinks one of the rules above is wrong. Tell me which one and why; the methodology is written to be argued with, and being wrong in private is not better than being corrected in public.
Email me or find me on LinkedIn. Screenshots on this page are the real app, taken against live data.
Data from Open Food Facts under ODbL. Nutri-Score is a registered trademark of Santé publique France; the app renders its own letter-and-colour treatment rather than the official logo. Informational, not medical advice.