Here's a problem that looks trivial and isn't: a user types "steel-cut oats with chia and blueberries, chickpea kale bowl, lentil pasta" and wants to know their total fiber for the day.
Plantbaes is a set of free race-fueling calculators for plant-based marathon and half-marathon runners. The flagship tool is a fiber load checker, because plant-based runners typically eat 35–50 g of fiber a day, while a common race-week working ceiling is around 15 g. Three ordinary meals can easily hit 50 g without any one plate looking excessive.
The core engineering rule behind all twelve tools: numbers come from a reviewed data table, never from a language model — and anything that can't be mapped is shown as unmapped, not guessed.
Step 1: A small, reviewed food table
interface Food {
id: string;
names: string[]; // aliases: "steel cut oats", "steel-cut oatmeal"
servingG: number;
fiberG: number; // per serving
carbsG: number;
lowFiberSwap?: string; // id of a race-week-friendly alternative
}
A deliberately small table that's been checked beats a huge scraped one that hasn't. Each food can also point to a lower-fiber swap, which is what makes the result actionable.
Step 2: Split and normalize free text
export function splitMeals(input: string): string[] {
return input
.toLowerCase()
.split(/,|\n|;|\band\b|\bwith\b|\+/)
.map((s) => s.replace(/[^a-z\s-]/g, " ").replace(/\s+/g, " ").trim())
.filter((s) => s.length > 1);
}
Splitting on "with" and "and" turns "oats with chia and blueberries" into three items — which matters, because chia alone carries a lot of fiber.
Step 3: Match against aliases — and keep the misses
export function mapFoods(parts: string[], table: Food[]) {
const mapped: Food[] = [];
const unmapped: string[] = [];
for (const p of parts) {
const hit = table.find((f) => f.names.some((n) => p.includes(n)));
hit ? mapped.push(hit) : unmapped.push(p);
}
return { mapped, unmapped };
}
The unmapped list is shown to the user. It's tempting to have an LLM "estimate" unknown foods, but a confident fake number is worse than an honest "we don't know this one" in a tool people use to plan a race.
Step 4: Compare to the ceiling and suggest swaps
const CEILING_G = 15;
export function fiberReport(foods: Food[], table: Food[]) {
const total = foods.reduce((s, f) => s + f.fiberG, 0);
const swaps = foods
.filter((f) => f.lowFiberSwap)
.sort((a, b) => b.fiberG - a.fiberG) // biggest offenders first
.map((f) => ({ from: f, to: table.find((t) => t.id === f.lowFiberSwap)! }));
return { total, ratio: total / CEILING_G, over: total > CEILING_G, swaps };
}
Sorting swaps by the biggest contributors means the first suggestion always has the largest effect.
Same pattern, twelve tools
The other calculators reuse the same table-driven approach: a carb-loading calculator (8–12 g/kg targets using low-fiber plant foods), a race fuel planner (carbs, sodium and fluid per hour), breakfast, dinner and recovery builders, a caffeine stacker, and a label scanner that highlights honey, whey, gelatin and milk.
Verification status instead of labels
For energy gels and chews, a product catalog carries an explicit status — certified vegan, manufacturer-confirmed, ingredient-list verified, unclear, or not vegan — plus a last-verified date. The gel checker only matches that catalog and never invents a "vegan" label.
Takeaways
- Keep nutrition math deterministic and sourced.
- Show unmapped input instead of hallucinating values.
- Make results actionable by linking each item to a concrete swap.
- Use explicit verification states with dates instead of binary labels.
The tools are free at plantbaes.net. (Not medical advice — talk to a sports dietitian for individual plans.) How have you handled free-text input that has to map onto structured data?
Top comments (0)