← Back to Blog

Building in the Fog, Part 4: 1,200 Calories a Day

March 9, 2026

Adding nutrition analysis to the provisioning engine revealed the original menu provides half the calories teenagers need on an expedition. The number wasn't wrong — but the way we averaged it was, because not every day is a full day.

Part 4 of "Building in the Fog" — a series about building Ridgeline, a provisioning decision engine for Outward Bound Hong Kong. Part 3 covered the $3,323 chicken sausage — a unit conversion bug that existed in three code paths, and the decision to make grams the universal language.

The question nobody asked

Once scaling worked and units were sane, I had a system that could tell you how much of each ingredient to buy for any group size. What it couldn't tell you was whether that food was enough.

The spreadsheet never asked about calories. The menu was designed around shelf-life sequencing and field cooking constraints — fresh ingredients early, instant carbs late. Whether the total adds up to adequate nutrition for teenagers hiking in Hong Kong's heat was a question the spreadsheet wasn't built to answer.

So I added nutrition data. A one-off script (8e7b4cb) hit the USDA FoodData Central API for all 73 ingredients, pulling calories and four macronutrients (protein, carbs, fat, fiber) per 100g. Manual overrides for items USDA wouldn't find by name — "Wa Wa Choi" maps to "bok choy," "Crab Stick" to "imitation crab surimi," "Lemon Cordial" to "lemonade concentrate."

The workbench now showed a nutrition panel per day. Day 1 dinner: some number of calories. Day 2: a bigger number. Day 3, Day 4. An average across the trip.

The average said roughly 1,200–1,600 kcal per person per day.

A teenager on a multi-day hiking expedition needs 2,500–3,500 kcal depending on intensity. The menu was providing about half.

Three Bugs Between Me and the Real Number

Before I could trust the 1,200 figure, I had to fix three things that were distorting it.

Bug 1: The 8x tortilla

The tortilla was showing 8 times its actual calories.

The nutrition calculation for non-weight items (pcs, can, pack) converts quantity to grams using the variant's weight_g. Mission Tortillas: weight_g = 320g, pack_qty = 8. The code was multiplying quantity by weight_g directly — treating each tortilla as 320g instead of 40g.

// Before (d6c1d5f)
totalGrams = item.scaled_qty * item.variant.weight_g

// After — weight_g is total pack weight, divide by pack_qty
const perUnitGrams = item.variant.weight_g / (item.variant.pack_qty || 1)
totalGrams = item.scaled_qty * perUnitGrams

A two-line fix. The tortilla went from 960 kcal to 120 kcal. The same bug inflated every multi-unit item — fish balls (10 per pack), glucose candies, biscuits.

Bug 2: Dividing by the wrong number of days

The OBHK menu is 5 days, 4 nights. The schedule:

DayMealsFraction of a full day
1Dinner only35%
2Breakfast + Lunch + Dinner100%
3Breakfast + Lunch + Dinner100%
4Breakfast + Lunch + Dinner100%
5Breakfast only25%

Day 1 starts at dinner — participants arrive with lunch already eaten. Day 5 ends after breakfast — the trip is over by midday. The initial code divided total calories by 5. But "5 days" is 3 full days, a dinner, and a breakfast. That's 3.6 effective days.

Dividing by 5 instead of 3.6 understates the daily average by 28%.

The fix (15f550c) weights each day by the fraction of meals it covers:

const MEAL_FRACTION: Record<string, number> = {
  breakfast: 0.25, lunch: 0.30, dinner: 0.35, snack: 0.10
}

const dayFractionOf = (dn: DayNutrition): number => {
  const types = new Set(dn.meals.map(m => {
    const code = m.meal_code.charAt(0).toUpperCase()
    if (code === 'B') return 'breakfast'
    if (code === 'L') return 'lunch'
    if (code === 'D') return 'dinner'
    return 'snack'
  }))
  const mainMeals = [...types].filter(t => t !== 'snack')
  if (mainMeals.length >= 3) return 1 // full day
  let fraction = 0
  for (const tp of types) fraction += MEAL_FRACTION[tp] ?? 0
  return fraction > 0 ? fraction : 1
}

// Sum: 0.35 + 1 + 1 + 1 + 0.25 = 3.6 effective days
const effectiveDays = realDays.reduce((sum, d) => sum + dayFractionOf(d), 0)

The test captures the math:

// Weighted average: both days are dinner-only (35% each)
// Effective days = 0.35 + 0.35 = 0.70
// Average: (967.5 + 720) / 0.70 = 2411
expect(result.nutrition_per_person_per_day!.calories_kcal).toBe(2411)

Bug 3: Snack day at day zero

Snack items — trail mix, biscuits, glucose candies, Pocari Sweat — don't belong to any specific day. The spreadsheet lists them separately. In the database they sit at day_number = 0.

The initial nutrition code treated day 0 as its own day, which diluted the average further. The fix spreads snack calories across the real trip days before averaging. Day 0 shows up in the breakdown for transparency, but doesn't count as a separate day in the denominator.

The Number After the Fixes

With tortillas at correct per-piece weight, partial days properly weighted, and snacks distributed — the average came up from the initial ~1,200 kcal figure. But it was still below the 2,500 kcal target for low-intensity hiking.

This is not a software bug. It's a menu design fact.

The ADR-006 analysis (from Part 2) already hinted at this: portions at the baseline 14-pax level are modest. Spaghetti is ~107g per person. Rice is ~71g per person. The snack layer is thin — one pack of trail mix, two packs of biscuits, and two packs of glucose candies shared across 14 people for the entire trip.

The menu optimizes for three things that compete with calorie density:

  1. Shelf life. No refrigeration after Day 1. Fresh protein disappears by Day 3. The menu degrades gracefully from full-cook meals to boiling water, which limits calorie-dense options.
  2. Cooking simplicity. Teenagers cook these meals in the field. One-pot dishes with boiling water on Day 4. You can't ask them to make granola bars from scratch.
  3. Pack weight. Everything gets carried. Calorie-dense additions (peanut butter, condensed milk, chocolate, extra nuts) add weight that compounds over 5 days.

The software surfaced this trade-off. It didn't create it. The workbench now shows per-day nutrition with color coding — green for 80–130% of target, amber for 60–80%, red below 60% — so the supply team can see where the gaps are and decide what to supplement.1

Activity-based targets

The calorie target itself needed to be contextual. A summer land course isn't the same as a winter water program.

Rather than adding a separate field, the target derives from scenario properties that already exist (c8ce85c):

ConditionIntensityCalories/day
Water activity or winter seasonHigh3,500 kcal
Portion factor > 1.2High3,500 kcal
Portion factor > 1.0Moderate3,000 kcal
Default (summer land)Low2,500 kcal

These ranges come from real-world expedition data — REI recommends 2,500 for easy day hikes, Greenbelly suggests 3,000–3,500 for standard multi-day, and Andrew Skurka documents up to 5,000 for strenuous winter expeditions. Activity type and season are now toggle badges on the workbench, so the team can see how nutrition targets shift for different programs.

Correct Software, Uncomfortable Answers

This episode is different from the scaling problem or the chicken sausage. Those were bugs — the software was producing wrong numbers. Here, the software is producing correct numbers that reveal a gap nobody had measured.

The spreadsheet calculated quantities. Ridgeline calculates adequacy. The moment you ask "is this enough?" instead of "how much do we need?", you get a different kind of answer — one that invites a conversation about trade-offs rather than a simple number.

Whether to fix the calorie gap is Rocky's call, not mine. The software's job is to make the trade-off visible.

Next: Part 5 — Ahmad Is Both, in which "2 halal, 1 vegetarian" breaks when one person has both restrictions, and anonymous counts give way to named participants.

Footnotes

  1. The USDA lookup uses a DEMO_KEY rate-limited to ~10 requests per hour. With 73 ingredients, a full fetch takes several hours. The data was pulled once and stored as a JSON file, then seeded via migration. The JSONB column on ingredients means adding micronutrients later requires no schema change — just richer data.