MacroTracker
I built a food tracker where I log a meal by typing or saying one sentence, and a worker on my Mac turns it into entries with Apple's on-device model.
- Year
- 2026
- Built with
- Next.js, Supabase, Python, on-device AI
Overview
MacroTracker is a web app I built for myself to track what I eat. I wanted logging a meal to take a few seconds, so I made it possible to type or say what I ate in plain words and let the app work out the rest.
The web app uses Next.js and Supabase. It stores my own foods with their values per 100 g, logs each item in grams or in servings, and shows the day's totals against my targets. It also has meals made of several foods, a barcode scanner, a food search that pulls from Open Food Facts and USDA, and a CSV export.
The newer part is Quick Log. I type a sentence like 2 eggs and 80 g rice in the box on the Today page, or I say it to Siri through a "Log Food" Shortcut on my iPhone. A small worker on my Mac reads the sentence with Apple's on-device Foundation Models, finds each food, works out the grams and writes the log entries. A second Shortcut, "Scan Label", reads a nutrition label with the phone's camera and adds the product as a new food.
How it works
The phone and the Today box both add a row to an inbox table in Supabase. On the Mac, a launchd job runs food_worker.py every 30 seconds. It claims up to five waiting rows and handles each one in four steps.
- Parse. A short shell script,
foodparse, sends the sentence to Apple's model with a JSON schema. The model returns a list of items, each with the words I used, a food name in English, a quantity, a unit and a meal. It also knows the Québec meal names, sosouperbecomes dinner. - Find the food. The worker scores every food in my own table against the item's words. If nothing scores high enough, it searches USDA and saves the best match as a new food, with USDA's portion weights as its servings.
- Work out the grams. Grams are used as written. Otherwise the worker looks for a matching serving, such as "1 large" for an egg. If there is none, it uses a default weight and marks the entry as estimated.
- Write the logs. The row ends as
done,needs_revieworerror, with a one line summary that shows on the Today page.
A label message starts with label:. The phone has already read the text, so the model only puts the printed numbers into fields, and the worker converts them to values per 100 g.
Design decisions
- The model never gives calories or macros. It only reads words, and every number comes from my foods table or from USDA. A wrong guess then shows up as the wrong food, which I can see and fix, and not as a made up calorie count.
- No service role key. The phone and the worker each use a long random token, and the database keeps only its SHA-256 hash. All their access goes through a few
SECURITY DEFINERSQL functions that act only on the data of the token's owner. I create and revoke tokens in the app's settings. - A queue between the phone and the Mac. The phone only adds a row, so logging still works while the Mac is asleep, and the worker catches up when it wakes. A row stuck in processing for 10 minutes is tried again, and after 3 attempts it is marked as an error with a retry button.
- Text recognition on the phone. I first read labels with Tesseract in the browser, then built a Python OCR service with OpenCV and Tesseract. Moving that step into the Shortcut left nothing extra to host, and the Mac only has to sort the text into fields.
Problems I ran into
Most of the problems came from picking the right food from my words. None of them caused an error. Each one showed up as a wrong entry.
- My foods have a caption, such as "1 egg = 50 g assumed" or the USDA entry they came from. At first I scored the name and the caption together, so the extra words lowered the score of an exact name match. The worker now scores the name on its own.
- A USDA search for egg also returns "Bagels, egg". USDA names the main food first, so the worker now gives a bonus when the first word of an entry is one of mine. Common staples like egg and rice also get a preferred search phrase.
- "Slices of toast" matched pita bread. The worker now prefers a food that has a serving for the unit I asked for.
- The serving search for "slice" also matched "sliced", and "1 large" lost to "1 cup (4.86 large eggs)". A portion word now has to match a whole word, and a word right after the amount wins.
Brand names were another case. When my words say more than the generic food name, as with "big mac", the worker searches my exact words first.
Results
- Logging a meal is now one sentence, typed on the Today page or said to Siri, without opening the app on the phone.
- On the 9 label photos in my test set, Scan Label got every serving size and every calorie, protein, carb and fat value exactly right.
- A one off script imported my old food list as 37 foods with their servings. Where the old list had a piece with no weight, the script assumed a typical weight and wrote it in the food's caption.
- When a food is not found or a portion is a guess, the entry is marked for review instead of being logged silently. A
--drymode shows what a sentence would log without writing anything. - While a message is waiting, the Today page refreshes itself every 6 seconds, so I can watch it turn into entries.
What I'd do next
- The worker depends on my Mac and on Apple Intelligence being turned on. If the Mac stays off, the queue waits. Parsing on the phone would remove that, but the Shortcut would then also have to do the food search.
- Matching relies on word scores and a short list of French to English synonyms. A food I log under a new name still goes to USDA the first time. Learning names from the corrections I make on the Today page would fix that.
- The matcher has no automated tests yet. The cases from the problems above would make a good first set.