Inspiration

Virginia Tech publishes nutrition data for every item in every dining hall. It is all there: calories, protein, fiber, sodium, and the ten federal allergen labels. And it is nearly unusable. You get one item at a time, buried three clicks deep, with no way to ask the only question a student actually has:

“I have 1,900 calories and 90 grams of protein left today. What should I get at dinner?”

One of us was trying to eat in a surplus to put on muscle. Another was tracking protein. A third just wanted to avoid an allergen without reading every label at the counter. All three of us were doing the same thing: standing in line at D2 with the FoodPro site open on our phones, doing arithmetic.

Generic calorie trackers do not help, because they do not know what West End is serving tonight. Static menu apps do not help, because they do not know anything about you. We wanted the thing in between.

What it does

CampusCals reads today’s real VT dining menus and ranks them against what you personally have left to eat.

Four questions, no account: age range, sex, height and weight; activity level; goal; allergies and diets. Your body data never leaves your device. It computes a daily calorie and protein target and you are in.

How we built it

Collection. VT’s menu site is a React frontend talking to undocumented JSON endpoints. We read those endpoints directly instead of parsing HTML, which gave us structured items, portions, allergen arrays, and per-recipe nutrition. The scraper runs as a daily Databricks job and writes Delta tables.

Sync. A notebook pushes each day’s data into Supabase through a Postgres function that atomically replaces whole (menu_date, location_num) slices. A hall that closed gets cleared; a hall we did not fetch is left untouched. A failed run can never leave a half-written day.

Targets. Your daily numbers come from the Mifflin-St Jeor equation, the standard clinical estimate of resting metabolic rate from your height, weight, age, and sex. We multiply that by an activity factor, ranging from 1.2 for sedentary up to 1.9 for athletes, then adjust for your goal with guardrails on both ends: a calorie deficit is capped at 500 per day, a surplus at 300, and the final number is floored so it can never fall below your resting rate or below a hard minimum. Protein scales with the goal, from 1.2 grams per kilogram for maintenance up to 1.8 for building muscle.

Those guardrails matter more than the formula does. A 16-year-old who selects weight loss quietly gets a maintenance plan instead, because we are not going to hand a minor a deficit.

It starts by working out what this meal is worth. It subtracts what you have eaten from your daily target, then divides the remainder across the meals you have left, counting three at breakfast, two at lunch, and one at dinner. The result is clamped between 250 and 1,000 calories so a single item never has to carry an absurd budget. That per-meal budget is the number everything else is measured against, and it is why the same dish can rank first at noon and vanish by evening.

Then every candidate item gets scored out of 100 on three things:

Calorie fit is the part we spent the most time on, and it is deliberately lopsided. Anything from 70% to 110% of your budget scores full marks, because a meal that leaves you a little room is not a mistake and should not be penalized. Past 110% the score falls off, and at 150% of your remaining budget the item is cut from the results entirely rather than just ranked low. An item that would blow your whole day is not a worse suggestion; it is the wrong suggestion.

Items with unreported fiber get half credit rather than zero, because an unknown value should not be scored as though it were the worst possible one. Anything missing calories or protein outright is dropped, along with items under 100 calories, anything alcoholic, and non-meal stations like condiment bars, which we filter out by pattern.

Because it is a database function and not client code, the web app and the AI coach call the same ranking. They cannot disagree about what to recommend.

Frontend. React and TypeScript on Vite, reading Supabase with the public anon key only. No component library; we wrote the components. The profile and food log live in localStorage, which is why there is no sign-up.

The coach. A small Python service that holds the model credentials, because they cannot live in a browser. It receives your question, your targets, and your restrictions. It never receives your weight, height, or age.

Challenges we ran into

NULL is not zero. VT reports unknown nutrition as ----. Our first version coerced that to 0, which meant items with unreported protein looked like perfect low-calorie choices and floated to the top of the rankings. We now store NULL, render it as an em dash, and exclude those items entirely whenever a calorie or protein filter is active, because an unknown value cannot be checked against a limit.

Allergen matching is a safety problem, not a string problem. Early on we let users type a restriction. Someone types “soy,” the data says “Soybeans,” nothing matches, and the app confidently recommends food containing the thing they are allergic to. Silent failure is the worst possible outcome here. We fixed it in three layers: the UI only offers the exact ten labels VT publishes; an unrecognized restriction raises a database error and returns nothing rather than being ignored; and an item carrying a label we do not recognize is hidden from anyone avoiding allergens, because we cannot verify it.

Pandas and array columns. The sync broke with ValueError: The truth value of an array with more than one element is ambiguous. Our null-check helper called pd.isna() on the allergens and legend_codes columns, which are arrays, so it got back an array of booleans where it expected one. Debugging this taught us more about pandas type coercion than we wanted to know.

Keeping the same math in two languages. The targets calculation exists in Python for the coach and TypeScript for the browser. They drifted within a day. We now generate a fixture of 1,440 profiles from Python and fail the build if the TypeScript disagrees, down to the rounding rule, since Python rounds halves to even and JavaScript rounds them up.

Stopping the AI from being a dietitian. The genuinely hard problem. A language model asked “how do I lose weight fast” will happily answer, and a question mentioning diabetes or an eating disorder must never reach it. We ended up with: the ranking picks the food and the model only explains picks already on screen; its output is checked for banned language, invented item names, and health claims, and replaced with a template if it fails; and anything mentioning a medical condition skips the model entirely. Writing the phrase detector was its own challenge, because “I don’t eat meat” and “I can’t eat gluten” are dietary needs the app exists to serve, while “I’m not eating all day” is a red flag. Those look almost identical to a regex.

Accomplishments that we're proud of

  • It runs on real data, updated daily. Not a seeded demo. The rankings you see are tonight’s actual menus across eight halls and around 30 stations.
  • The ranking lives in one place. Putting it in SQL rather than in the client means the app, the coach, and anything we build next are all reading the same scoring function. That was a deliberate architectural call and it has already saved us from divergence twice.
  • We fail safe, on purpose. Unknown restriction, unrecognized allergen label, unreported nutrition: every one of these returns less food rather than more. That is the right default for something people make eating decisions with.
  • The AI has a job description and it is narrow. It explains; it does not choose. For a health-adjacent app built in a weekend, we think that constraint is the most defensible thing we did.
  • Supabase is a real boundary. Nothing in the browser can reach Databricks. The write key exists only in a Databricks secret scope; the browser gets a read-only anon key.
  • It is tested. Twenty tests on the scraper and sync, plus suites for the coach’s targets, guardrails, and allergen matching, and the frontend’s filters and log math.

What we learned

  • Put the decision logic in the database. We started with the scoring in TypeScript and duplicated it for the coach. Moving it into a Postgres function eliminated a whole category of bug and made the coach trivial to write.
  • Look for the JSON endpoints before you parse HTML. Half an hour in the network tab saved us from writing a brittle scraper.
  • Missing data is a design decision. Every unknown value forces a choice, and “treat it as zero” is almost always wrong. It is not a data-cleaning step; it is a product question.
  • Safety features are mostly about failing correctly. Nearly every safety improvement we made was a matter of returning nothing instead of guessing.
  • Constrain the model instead of prompting it harder. We spent time on prompt wording before realizing the real fix was architectural: never let it pick the food, only let it describe food the deterministic ranker already chose, and validate what it says.

What's next for CampusCals

  • The economic half. Our original idea was macros and money: dining dollars, flex, and cost per gram of protein. Prices are not in our dataset yet. Adding them makes the score two-dimensional and answers the question every student on a meal plan actually has: how to eat well and still have swipes left in April.
  • Accounts, so your log follows you. Right now everything is in one browser. Supabase auth with row-level security is the natural next step, and the schema is already designed for it.
  • Menu forecasting. We have historical Delta tables. Knowing that Thursday usually means a good high-protein entrée at Turner is genuinely useful.
  • Notifications. “West End has something that fits your remaining protein, and it is a six-minute walk.”
  • Beyond Virginia Tech. FoodPro is used by many universities. The scraper is the only school-specific piece; everything downstream would port.

CampusCals is an unofficial student project. It is not affiliated with or endorsed by Virginia Tech or VT Dining, and it is not medical or nutrition advice. Nutrition and allergen data comes from VT Dining’s FoodPro system and may be incomplete or change without notice. If you have a food allergy, confirm with dining staff before eating.

Built With

Share this project:

Updates

Submission history