Status: built (tickets 01–10) · Last updated: 2026-10-19 to match the code Builds on: project.md
A read-only web interface showing which snacks are being taken from the office pantry. The aim is to inform the next grocery list and give office-goers visibility.
A Python + Polars build step turns raw scanner events and OpenFoodFacts data into static JSON files. A React frontend renders those files. No server runs at request time.
- Show scan volume over time, the most recent scans and the top scans per period, filterable by location.
- Show per-product detail: product info, scans over time, scans by hour of day, recent scans.
- Show whether each scanner is alive (last heartbeat, last scan, app version).
- Support more offices than Bangkok without code changes beyond configuration.
- Keep the backend easy to lift into a Dagster pipeline later.
- Authentication, user accounts, or attributing scans to people.
- Writing or editing data from the UI.
- Scanner telemetry beyond last-seen and version (RSSI, memory, uptime, reset cause).
- Nutrients and other nutrition data beyond the grades (R5).
- Frontend automated tests.
- Office management: reads trends and popular products to plan purchasing.
- Office-goers: browse what's being taken.
- Maintainer: checks scanners are online.
All raw data is JSON wrapped in an AWS EventBridge envelope. A file can hold one or more events, one per line. In production the data lives on S3. For development it has been copied to data/raw/data/scanner/.
Profiled 2026-10-19: 8,084 events, 854 distinct codes.
| Backfill | Live | |
|---|---|---|
| Period | 2019-10 → 2023-07 | 2026-09 → present |
| Count | 8,016 | 68 |
source |
from-backup |
scanner |
detail-type |
/scans |
scans |
| Location seen | thailand:bangkok |
thailand:bangkok:main-kitchen |
payload.scanned_at |
present | absent |
apiKeyId |
null | set |
There is also one scan at thailand:test. About 204 scans (130 codes) are not product barcodes: they are QR-code URLs (up to 88 characters) or short codes such as 005.
About 11k events since 2026-09-04, sent every few minutes, all from one scanner (rpi-pico-w-01, thailand:bangkok:main-kitchen). The payload comes in three shapes:
| Shape | Name | Location | App version |
|---|---|---|---|
| Unversioned, flat | device_name |
location |
version |
| Unversioned, flat with telemetry | device_name |
location |
version |
schema_version: 2 |
device.name |
device.location |
app.version |
Known anomaly: one v2 event reports a negative telemetry.uptime, which looks like a firmware bug. It doesn't affect this product.
- The file is 8 GB, with 111 columns.
- 508 of the 854 codes match, which covers about 80% of scans.
- There is no
brandcolumn. Usebrands. product_nameis a list of{lang, text}entries.
- The previous Snacker Tracker's product records (GLOSSARY.md), pulled from shop websites or entered by hand. Frozen: nothing is added or corrected.
- 494 rows with
code,name,created_at,updated_at,url,categories. Onlynameis used; it is trimmed. - Codes match scanned codes exactly, odd ones like
8 858985 300118andubincluded. - Codes
5and78are recorded twice with different products. A code recorded more than once gets no archive name. - Some names are wrong for their barcode (8886467100017 is "Pringles original" on OpenFoodFacts but ladies' hot pants in the archive), so an OpenFoodFacts name always wins.
- On the current data it names 97 unknown products and 10 known products OpenFoodFacts has no name for.
Both eras are normalised into one table, which the UI treats identically:
| Column | Notes |
|---|---|
event_id |
Envelope id |
code |
Always a string |
location |
|
scanned_at |
UTC. payload.scanned_at if present, otherwise the envelope time |
scanned_at_source |
device or reported |
origin |
backfill or live; for debugging only |
api_key |
The scanner's API key (envelope apiKeyId); null for backfill. Ties scans to scanners (R6) |
Duplicate event_ids are dropped.
- A location is a colon-separated path such as
thailand:bangkok:main-kitchen. - Selecting a location includes all locations beneath it, matched on whole segments:
thailand:bangmatches nothing. - The selectable set is
allplus every prefix of every observed location:thailand,thailand:bangkok,thailand:bangkok:main-kitchen, and so on. thailand:testis kept and treated like any other location.
Every scan counts in every chart and list, whether or not the code is a barcode or has product info.
- A code OpenFoodFacts doesn't have is an unknown product (GLOSSARY.md), even when the Snacker archive names it.
- A product's name comes from OpenFoodFacts when it has one, otherwise from the Snacker archive (§4.4). An archive name is always tagged "Snacker archive"; OpenFoodFacts names aren't tagged.
- In the scan lists (Top scans, the healthiest and least healthy products, the latest scans and a scanner's last scan), each row shows its scanned product (GLOSSARY.md) the same way: a product with a name shows its name and brands, with its code only as the name's tooltip. An unknown product with an archive name also links to "Add it to OpenFoodFacts". A product without a name shows its raw code instead, with a link to OpenFoodFacts if the code is a barcode: "Add it to OpenFoodFacts" (
https://world.openfoodfacts.org/cgi/product.pl?type=search_or_add&action=process&code={code}) for an unknown product, "On OpenFoodFacts" (https://world.openfoodfacts.org/product/{code}) for a known one without a name. - In the same lists, a product with a Nutri-Score shows it last, as a compact form of the code page's badge with the same tooltip (R5), whether or not it has a name. The NOVA group and Green-Score are shown only on the code page.
- The code page shows "Unknown product" and the raw code for a product without a name.
- Timestamps are stored in UTC.
- Day and hour buckets use the office time zone of the location the scan came from (see GLOSSARY.md).
- Displayed times are shown in the scan's office time, with the time zone indicated.
- Time zones come from a prefix map in backend config, e.g.
{"thailand": "Asia/Bangkok"}. The longest matching prefix wins. An unmapped location fails the build. all, and parent locations spanning several time zones, bucket each scan in its own office time zone.
- Name: the
mainentry ofproduct_name, falling back to the first available entry. Without one, the Snacker archive's name, tagged "Snacker archive" (R3). - Shown fields: name,
brands,quantity, grades, image, categories (as tags), and a link to the product's OpenFoodFacts page. - Grades (GLOSSARY.md): the Nutri-Score (A–E), NOVA group (1–4) and Green-Score (A+–F) from OpenFoodFacts's
nutriscore_grade,nova_groupandenvironmental_score_grade. Each is a text badge on its grade's colour, e.g. "Nutri-Score B", with a tooltip saying what the grade means. A grade OpenFoodFacts lacks, or gives as "unknown" or "not-applicable", is left out. Raw scores aren't shown. - Archive only: a product only the Snacker archive has shows its tagged name and the code, then "Not on OpenFoodFacts" and "Add it to OpenFoodFacts" for a barcode. It has no image, brands, quantity, grades or categories.
- Heartbeats from all three shapes are normalised to one row each: scanner name, location, app version, sent time and API key.
- A scanner is known once it has sent a heartbeat. Its status comes from its latest heartbeat:
{name, location, app_version, last_seen}. - A scanner's last scan is the latest scan sent with the API key of its latest heartbeat (docs/adr/0002). Backfill scans have no key and never count, and each scanner must have its own key.
- A scanner is offline if
now − last_seen > 1 hour, evaluated in the browser. Scans don't count. - Because data is pre-built, the UI also shows when the data was generated, so stale data is visible.
- Top scans rank codes by number of scans within a period, most first (GLOSSARY.md). Unknown products are included.
- The periods, in order:
today,this-work-week,last-7-days,this-month,last-30-days,this-year,last-365-days,all-time. - Every period ends at the build time. Scans after the build time fall in no period.
- Calendar periods (today, this work week from Monday 00:00 with weekends included, this month, this year) start at midnight in each scan's own office time zone.
- Rolling periods are exact durations back from the build time, and a scan exactly at the cutoff counts.
- Each list holds at most N codes (N = 10, in config), with ties going to the most recently scanned. Each row has the code's scanned product (GLOSSARY.md: the code, name and where the name came from, brands, whether the product is known, and its Nutri-Score), the count, and its share of the period's scans.
- A second set of lists ranks unknown products only (ticket 11): every unknown code is ranked, not just those in the overall top N, and shares are still of all scans in the period. Unnamed products OpenFoodFacts has aren't included.
- For every period of R7, the healthiest and least healthy products (GLOSSARY.md): the products with a Nutri-Score scanned within it. A product without a Nutri-Score is left out.
- Ranking: they are ranked by Nutri-Score grade, then by scans in the period, with ties going to the most recently scanned. The healthiest are best grade first, and the least healthy are worst grade first. Within a grade, the most scanned come first.
- No overlap: the two lists never share a product.
- The graded products are ranked best first and split in halves. The better half, up to N (R7's N), is the healthiest, and an odd count's extra product goes to it.
- The least healthy are the worst of the rest, up to N.
- So with 2N or more graded products, they are simply the N best and the N worst.
- Coverage: each period also carries how many of its scans are of products with a Nutri-Score, and how many scans it has in all.
- G1: A prominent location selector appears in the header on every page. It lists
allplus every hierarchy level from R2. - G2: The selected location is held in the browser URL as
?location=<location>. If absent, it defaults toall. Changing the selection keeps the user on the current page. - G3: The header links to the scanners page (§6.4), keeping
?location. The link reads "Scanners", or "✕ N offline" when any scanner within the selected location is offline (R6). (This replaced a header widget, in ticket 10.)
-
I1: Bar chart of scans per local day across all history, with brush-to-zoom.
- It opens zoomed to the selected period (I0): the office days the period touches, from its first day to the build's day. A rolling period's partial first day counts, so "Last 7 days" shows 8 bars. All time isn't zoomed.
- The overview runs to the build's day, so its brush can be placed on those days even when the last scan came earlier. Brushing by hand still works but doesn't change
?period=. Choosing a period, even the selected one, zooms back to it. - The heading stays "Scans per day", and the hint reads "Zoomed to the period; drag across the lower chart to look elsewhere."
-
I2: List of the last 25 scans: local date and time, location, and the product per R3. Each row links to its code page, keeping
?location. -
I0: A period selector at the top of the page, above the chart: a button per period (R7), the selected one shown pressed. The chosen period is kept in
?period=(defaultlast-30-days; unknown values fall back to it). Top scans (I3) and the healthiest and least healthy products (I4) follow it and name it in their headings, e.g. "Top scans · Last 30 days". Latest scans (I2) doesn't follow it. -
I3: A Top scans panel (R7), below the chart, for the selected period (I0). An "Unknown only" toggle, kept in
?unknown=1, switches to the unknown-products lists. Each row shows rank, the product per R3, count and share, and links to its code page. An empty period says so. -
I4: A Healthiest and least healthy products panel (R8), below Top scans.
- It follows the selected period (I0). "Unknown only" doesn't affect it.
- The two lists sit side by side and stack on narrow screens. Each row shows rank, the product per R3 with its Nutri-Score badge, and the count.
- A coverage line says e.g. "Based on the 1,204 of 2,310 scans with a Nutri-Score."
- A period without graded products says e.g. "No products with a Nutri-Score scanned today." With only one, Least healthy says "Nothing else to compare."
Page order: period selector (I0), scans per day (I1), top scans (I3), healthiest and least healthy products (I4), latest scans (I2).
- C1: Product info per R5, or "Unknown product" and the raw code if neither OpenFoodFacts nor the Snacker archive has the code.
- C2: Bar chart of scans per local day for this code.
- C3: Bar chart of scans per local hour of day (0–23), always 24 bars.
- C4: List of the last 25 scans of this code: local date and time, location.
- C5: C2–C4 are filtered by the selected location. C1 is global.
- C6:
:codeis the URL-encoded code (see §7.3).
- S1: Lists every known scanner within the selected location (
allshows every one), offline scanners first, then by name. - S2: Each row shows:
- status: ✓ Online / ✕ Offline, with icon and label,
- name,
- location,
- last heartbeat ("X ago" with the exact time),
- last scan ("X ago", then its scanned product shown as in the other scan lists (R3): name and brands or the code, linking to its code page, the Snacker archive tag, the OpenFoodFacts link and the Nutri-Score badge; or "Never"),
- app version.
- S3: It shows when the data was built. A location with no scanners says so; a missing or failed file says scanner status is unavailable.
The build writes everything under public-data/data/. No file needs query parameters to be served.
data/meta.json { generated_at }
data/locations.json all selectable locations
data/index/by-location/{location}.json index page data
data/scanners/by-location/{location}.json scanners page and header link data
data/codes/{code}/product.json product info (global)
data/codes/{code}/by-location/{location}.json code page scan data
These rules decide which files exist:
meta.jsonandlocations.jsonalways exist.locations.jsonincludesall.- An index file and a scanners file are written for every entry in
locations.json, includingall. codes/{code}/by-location/{location}.jsonis only written for locations where that code was scanned. If the file is missing, the frontend shows zero scans.- Every scanned code has a scans file for
all. product.jsonis written for every scanned code, with"product": nullwhen neither OpenFoodFacts nor the Snacker archive has the code, andknownsaying whether OpenFoodFacts has it. Every code named in any file has one, including the codes in the latest scans, both top-scan lists, the healthiest and least healthy products, and last scans.- Each file sits at the path its own
codeandlocationgive, encoded as in §7.3. - No other files are written.
The shapes are defined by the JSON Schema at contract/static-json.schema.json. It has one entry in $defs per file, and the rows files share are also defined once there:
| File | Document |
|---|---|
meta.json |
MetaFile |
locations.json |
LocationsFile |
index/by-location/{location}.json |
IndexFile |
scanners/by-location/{location}.json |
ScannersFile |
codes/{code}/product.json |
ProductFile |
codes/{code}/by-location/{location}.json |
CodeScansFile |
- Generated, not hand-edited: the schema comes from the backend's contract module (
snacker schema), and a test fails when the committed copy is out of date. - Frontend types: the frontend's types for these files (
frontend/src/contract.ts) are generated from the schema bynpm run contract. The frontend build and lint fail when they're out of date. - Strict: every field is required, even one that may be null, and fields the schema doesn't list are rejected.
- Versioned:
meta.jsoncarriescontract_version, a single integer (now 3: version 2 added where names come from; version 3 the grades, the healthiest and least healthy products, period days, and rows nesting their scanned product, all shipped together). Bump it on any breaking change: removing, renaming or retyping a field, a field becoming nullable, a file moving, adding a field, or changing a rule in §7.1 or §7.4. Adding a field counts because the schema rejects fields it doesn't list; only a whole new file doesn't. The frontend readsmeta.jsonbefore any other file. When the version differs from the one it was built for, every page says the data and interface are out of step instead of rendering. - What the schema can't express: which files exist (§7.1) and the rules that hold across fields and files (§7.4).
{code}and{location}are file names percent-encoded likeencodeURIComponent, except that:stays literal. So barcodes andthailand:bangkokstay readable, while a URL code becomeshttp:%2F%2Fwww.example.com%2F…. (Decided 2026-10-19. The cost is that:isn't a valid file-name character on Windows.)- Static servers decode
%escapes before looking up a file, so the frontend applies the same encoding again when fetching, still leaving:literal. For example,thailand:staff roomis stored asthailand:staff%20room.jsonand requested asthailand:staff%2520room.json. Vite's dev server doesn't match%3Ato a literal:, which is why:stays literal in requests too. - Browser routes use single encoding.
These hold in every build, alongside the schema (§7.2) and the file rules (§7.1):
- Locations:
- Every file's
locationis selectable. - Every scan row is from a selectable location within the file's location.
- Every scanner is within its file's location.
- Every file's
daily:- Office dates, unique and oldest first.
- Every count is positive.
hourly:- Exactly hours 0–23, in order.
- In a code scans file,
hourlyanddailyadd up to the same total, and that total isn't zero.
recent:- Newest first.
- Holds the most recent
min(total, 25)scans. - In a code scans file, only that code's scans.
- Top scans:
- All eight periods are present, in both
top_scansandtop_unknown_scans. - Each list has at most 10 codes, each code once, with counts positive and most first.
- Each share is in (0, 1], and a list's shares add up to at most 1, allowing for rounding.
top_unknown_scanslists only unknown products.
- All eight periods are present, in both
- Period days (I1): for every period,
first_dayis no later thanlast_day. Every period ends on the same day, within a day of the build's UTC date. All time starts on the first scanned day. The days are null only in an index file without scans. - Healthiest and least healthy products (R8): for every period,
- each list has at most 10 products, every one with a Nutri-Score and a positive count, and no product is in both;
- the healthiest are split from the least healthy in halves: the healthiest hold as many products as the least healthy, or one more;
- the healthiest are best grade first and the least healthy worst grade first, the most scanned first within a grade, and no least healthy product is graded better than a healthiest one;
- graded scans don't exceed the period's scans, and the listed counts don't exceed the graded scans.
- Scanners: unique, ordered by name.
- Scanned products: scan rows, top scans, last scans and the healthiest and least healthy products' rows each nest one
product(ScannedProduct). Wherever it's listed, itsname_sourceis null exactly when its name is,openfoodfactsonly for a known product, and it has a Nutri-Score only when it's known. A product file's product follows the same name rule. - Grades: only a known product's product file has grades.
- Times:
generated_at,scanned_at,last_seenand a last scan'sscanned_atare UTC, written like2026-10-19T12:00:00Z.- A scan row's
office_timeis the same moment as itsscanned_at, written with the UTC offset itsoffice_time_zone(an IANA name) had then.
The backend's check module tests a built output against all of §7, and names the file behind each violation.
The backend is a CLI with no running server and these commands:
snacker extract-products --raw … --food … --products … [--archive …] [--force]:--archiveis the Snacker archive CSV. It is merged whenever the products table is written; without it, names come from OpenFoodFacts only. Codes the archive records twice are reported.snacker build --raw … --products … --out …: it publishes only output that passes the contract check (§7.4). Otherwise it lists the violations and leaves the previous data in place.snacker check --out …: runs the contract check on its own.snacker schema --out …: regeneratescontract/static-json.schema.json.
Inside, each step is a named function with explicit inputs and outputs, so it can become a Dagster asset later:
| Step (module) | Input | Output |
|---|---|---|
Product extraction (products) |
food.parquet, scanned codes, Snacker archive (optional) |
products.parquet: one row per scanned code, with known false for unknown products, name_source naming where each name came from, and the product's grades |
Scan normalisation (scans) |
raw scan files | scans table (R1) |
Heartbeat normalisation (heartbeats) |
raw heartbeat files | heartbeats table, then scanner statuses (R6) |
Locations (locations) |
scans, time zone map | selectable locations, scans within a location, office time zones |
Scan summary (scan_summary) |
scans with office time zones and products | daily, hourly and recent (summarise); top scans (top_scans, R7); healthiest and least healthy products (healthiest_and_least_healthy, R8); period days (period_days, I1) |
Build (build) |
raw files, products table, config, build time | JSON files (§7) |
extract-productsreruns only when there are unseen codes, when the table predates a column change, or with--force. This keeps normal builds off the 8 GB file.buildruns the normalisation steps itself and never readsfood.parquet. Codes scanned since the last extraction count as unknown products.- Configuration (
config.py) holds the time zone prefix map, the recent-list size (25) and the top-scans size (10). Input and output paths are CLI arguments. - Product images are hotlinked from OpenFoodFacts (docs/adr/0001).
- Vite, React, TypeScript, React Router (
/,/codes/:code,/scanners). - Charts use Vega-Lite via
react-vega. - Node 22 LTS, pinned in
.nvmrc. - In development, Vite serves
public-data/as static files.
- A monorepo initialised with
git init, containingbackend/,frontend/,PRD.mdandproject.md. data/andpublic-data/are git-ignored.
Backend tests run against small fixture files, never the real data. They sit at seven seams:
- the end-to-end build (fixture raw files → the JSON tree)
- product extraction (a fixture food parquet → the products table)
- Scan summary (
summarise) - top scans (
top_scans) - healthiest and least healthy products (
healthiest_and_least_healthy) - the output contract check (
contract_violations) - Locations
They cover:
- Parsing and normalising
- Both scan eras.
- The
scanned_atfallback. - All three heartbeat shapes.
- Duplicate event IDs.
- Aggregations
- Location prefix matching on whole segments.
- Local-time day and hour bucketing, including scans near UTC and local midnight.
- An unmapped location failing the build.
- Last-N ordering.
- Product name selection, including no match.
- Top scans: each period's boundaries (including zones that disagree about the day), ties, the size limit and share.
- Healthiest and least healthy products: grade before scans, the halves split (odd counts, a single product, more than fit), products without a Nutri-Score left out but counted for coverage, and empty periods.
- Period days: calendar and rolling periods (a partial first day counts), all time, a location quiet in a period, scans after the build, and a build without scans. Tested through the end-to-end build.
- A scanner's last scan by API key.
- Output contract
- A fixture build writes the expected paths, including URL-encoded codes and locations.
- The JSON matches the expected shapes, including
product: nulland 24-entryhourly.
Frontend tests are out of scope for this phase.
- Range toggle or weekly/monthly roll-ups on the index chart, if the all-history daily chart proves hard to read.
- Telemetry (wifi RSSI, uptime, free memory, reset cause) on the scanners page.
- The scanner name in the scan payload (a firmware change), which would replace the API-key link (docs/adr/0002).
- Nutrients on code pages.
- Frontend component tests.
- Moving the build into Dagster, reading from S3.