DEV Community

Akbo Ichou
Akbo Ichou

Posted on

Mapping Free-Text Meals to a Food Table (and Refusing to Guess): Building Race-Week Nutrition Tools for Runners

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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)