Back to home

case study

Expediate: food logging for athletes

UX/UI
Frontend
Backend
DevOps
TypeScript
Nest.js
Next.js
Jest
AWS
Figma

Expediate aims to make food logging easier for athletes following the Diet Quality Score model.

Expediate DQS results cover image

Expediate aims to make logging food simple. Athletes provide a comma-separated list of the food they ate, and the system parses it and provides a Diet Quality Score (DQS).

The goal of this project was to deliver a MVP wherein athletes could log data in a simple way and receive a basic DQS-informed summary. While features such as user accounts, detailed breakdowns and integration with other scoring systems would be useful, these were out-of-scope for the MVP.

Expediate supports a chaotic and sloppy list of food intake, providing the user with useful scoring data.

The key engineering challenge was making DQS reliable from such messy natural-language logs. Logs include mixed objective/subjective units (tbsp, handful, etc) and descriptive adjectives (large, semi-skimmed, etc).

For example, the system accepts inputs such as:

1 portion oats, 250ml semi-skimmed milk, large banana, handful raisins, 1 tbsp peanut butter, bunch of crisps ...

If parsing is inconsistent or the portion maths is wrong, the computed score is not useful.

What is DQS?

Diet Quality Score (DQS) is a rule-of-thumb system introduced by Matt Fitzgerald to help athletes measure diet quality without tracking every calorie.

Fitzgerald's DQS is point-based and depends on two factors:

The scoring table maps each food type/category to points based on its 1st through 6th+ serving in the log (serving count derived from parsed units/portions):

Food Type 1st 2nd 3rd 4th 5th 6th
Fruits222100
Vegetables222100
Lean meats & fish22100-1
Nuts & seeds22100-1
Whole grains22100-1
Dairy1110-1-2
Refined grains-1-1-2-2-2-2
Sweets-2-2-2-2-2-2
Fried foods-2-2-2-2-2-2
Fatty proteins-1-1-2-2-2-2

In this project, that table becomes CategoryScores, and the API computes DQS by counting servings per category and mapping count and converting those into points.

The Challenge

Because DQS is the primary output, the implementation has to:

MVP Goals

Design (Figma)

Expediate was designed from scratch in Figma.

I focused on:

Expediate Figma wireframe of input and results

Figma wireframe of log input and results.

System Architecture

The main architectural goal was separation of concerns and maintainability.

The data logged by the user on the client flows through several stages on the backend (as outlined below), which are intentionally modular.

The API is orchestrated in a clear sequence:

  1. Transformer: log text → { food, quantity, unit, unitType }
  2. Categoriser: categorised food based on DQS categories → FoodCategory
  3. Scorer: category logs → totalScore + per-category breakdown

The main driver for this is that in the future, it may be desirable to add other scoring methods (e.g. Nutri-Score or NOVA).

Such scoring systems would require an alternative implementation of the scorer, whereas the transformer and categoriser may potentially remain the same.

Similarly, with our modular approach, parsing/categorisation improvements don't require changes to the scoring stage.

Orchestration boundary

Pipeline orchestrator: splits the input, parses each item, categorises it, and delegates scoring.

// expediate-api/src/log/log.service.ts
@Injectable()
export class LogService {
  constructor(
    // These services can be easily swapped out
    private transformerService: TransformerService,
    private categoriserService: CategoriserService,
    private scorerService: ScorerService,
  ) {}

  create({ log }: LogDto) {
    const mappedLog = log
      .split(",")
      .map(this.transformerService.parse)
      .map((log) => ({
        category: this.categoriserService.categorise(log.food),
        ...log,
      }));

    return this.scorerService.score(mappedLog);
  }
}

Interfaces for evolution

Categoriser plugin boundary: map a normalised food string to a FoodCategory.

// expediate-api/src/categoriser/categoriser.interface.ts
export interface CategoriserInterface {
  categorise(food: string): FoodCategory;
}

Scoring plugin boundary: compute total DQS and per-category breakdown from scored log items.

// expediate-api/src/scorer/scorer.interface.ts
export interface ScorerInterface {
  score(log: Array<LogItem>): ScoredResults;
}

Key Engineering Decisions

A) Portion accuracy: base conversions and overrides to make scoring consistent

Problem: the portion maths had to be stable across objective units (g, ml, etc.) and subjective units (portion, piece, scoop, etc.).

Approach: layered mappings:

Base serving model: convert objective units to grams and define grams-per-category serving.

// expediate-api/src/transformer/units.map.ts
export const UnitMap: Record<string, number> = {
  g: 1,
  kg: 1000,
  lb: 454,
};

export const CategoryToGramsMap: Record<FoodCategory, number> = {
  fruit: 100,
  vegetables: 100,
  "lean-meat-and-fish": 80,
  "nuts-seeds": 30,
  "whole-grains": 50,
  dairy: 40,
  "refined-grains": 50,
  sweets: 50,
  "fried-foods": 50,
  "fatty-proteins": 80,
  unknown: 50,
};

Food-specific overrides for known mismatches (e.g., foods that don't behave like generic serving sizes).

export const FoodToGramsMap: Record<string, number> = {
  sausage: CategoryToGramsMap["fatty-proteins"] / 2,
  sausages: CategoryToGramsMap["fatty-proteins"] / 2,
  potato: 50,
  potatoes: 50,
  biscuit: CategoryToGramsMap["sweets"] / 2,
  biscuits: CategoryToGramsMap["sweets"] / 2,
};

Servings calculation: map a logged quantity to grams and round up to the next serving.

// expediate-api/src/scorer/scorer.service.ts
calculateTotalServings = (food: string, quantity: number, category: FoodCategory, unit?: Unit) => {
  const unitInGrams = this.convertUnitToGrams(
    food,
    unit || "serving",
    category,
    quantity,
  );

  // Always round UP to the nearest serving.
  return Math.ceil(unitInGrams / CategoryToGramsMap[category]) || 1;
};

convertUnitToGrams = (food: string, unit: Unit, category: FoodCategory, quantity: number) => {
  let unitMappedToGrams = 1;

  if (isObjectiveUnit(unit)) {
    unitMappedToGrams = UnitMap[unit];
  } else {
    unitMappedToGrams =
      FoodToGramsMap[food] ||
      SubjectiveUnitToGramsCategoryMap[category][unit] ||
      SubjectiveUnitFallbackCategoryMap[category];
  }

  return unitMappedToGrams * quantity;
};

Screenshot suggestion: show raw input like 4 biscuits and the derived “servings” calculation.

Servings calculation example screenshot

Portion-to-servings calculation example.

B) Pluralisation and singular normalisation

Problem: atypical plural variants (“berries”, pastries, “potatoes”) could fail matching and lead to unknown categories.

Approach: normalise foods to singular using:

Singular normalisation: irregular plural overrides + trailing s fallback.

// expediate-api/src/categoriser/foods.map.ts
const pluralOverrides: Record<string, string> = {
  berry: "berries",
  cherry: "cherries",
  pastry: "pastries",
  candy: "candies",
  potato: "potatoes",
  // ...
};

export const singularise = (food: string): string => {
  for (const [singular, plural] of Object.entries(pluralOverrides)) {
    if (food === plural) return singular;
  }

  if (food.endsWith("s")) {
    return food.slice(0, -1);
  }

  return food;
};

The categoriser applies singularise() before matching against FoodCategoryMap:

Categoriser loop: singularise input then find a matching entry in FoodCategoryMap.

// expediate-api/src/categoriser/categoriser.service.ts
const FoodCategoriser = (food: string): FoodCategory => {
  for (const [category, foods] of Object.entries(FoodCategoryMap)) {
    food = singularise(food);
    if (foods.includes(food)) {
      return category as FoodCategory;
    }
  }
  return "unknown";
};

C) Handling adjectives gracefully

Problem: adjective handling ("wholemeal", "wild", "refined/regular", etc.) had to be correct without turning the categoriser into fragile conditionals.

Because some categories have the same food, the adjective is critical as it determines whether the food gives a positive or negative score. For example: white rice is a refined grain, where as brown rice is a whole grain.

Approach: build token lists and generate combinations:

Domain vocabulary: supported grains and adjective tokens used to generate category match phrases.

// expediate-api/src/categoriser/foods.map.ts
export const Grains = [
  "wheat",
  "barley",
  "rice",
  "pasta",
  "noodle",
  "toast",
  // ...
];

export const WholeAdjectives = [
  "whole",
  "wholemeal",
  "whole grain",
  "wild",
  "stone ground",
  // ...
];

export const RefinedAdjectives = [
  "refined",
  "white",
  "regular",
  "instant",
  // ...
];

Then category lists include dynamically generated combinations:

Category token expansion: generate all "adjective/grain" variants for whole-grains and refined-grains.

// expediate-api/src/categoriser/foods.map.ts
"whole-grains": [
  "oat",
  "quinoa",
  "millet",
  // ...
  ...Grains.map((grain) =>
    WholeAdjectives.map((adjective) => `${adjective} ${grain}`),
  ).flat(),
],

"refined-grains": [
  "all-purpose flour",
  ...Grains,
  ...Grains.map((grain) =>
    RefinedAdjectives.map((adjective) => `${adjective} ${grain}`),
  ).flat(),
],

The same approach has been used for other categories, such as fried foods and proteins.

Screenshot suggestion: show wholemeal fusilliwhole-grains and white ricerefined-grains.

Wholemeal fusilli and refined grains categorisation screenshot

Adjective-sensitive grain categorisation example.

Transforming the data consistently

To keep the scoring reliable, the transformer normalises input:

TransformerService.parse(): remove filler words, extract quantity/unit, then normalise the remaining food string.

// expediate-api/src/transformer/transformer.service.ts
const FillerWords = ["of", "a", "an", "the", ...];

export class TransformerService {
  parse = (log: string): TransformedLog => {
    let parts = this.stripFillerWords(log).trim().toLowerCase().split(" ");
    let index = 0;

    let { quantity, unit, unitType } = this.getQuantityAndUnit(parts[0], parts[1]);

    if (!quantity) {
      if (unitType == "subjective") index = 1;
      else index = 0;
      quantity = 1;
    } else {
      if (unitType === "subjective") index = 2;
      else index = 1;
    }

    let food: string = "";
    for (let j = index; j < parts.length; j++) {
      food += `${parts[j]} `;
    }

    return {
      quantity,
      unit,
      unitType,
      food: food
        .trim()
        .replace(/[^a-zA-Z0-9 ]+/g, " ")
        .replace(/\s+/g, " "),
    };
  };

  stripFillerWords = (log: string) =>
    log
      .split(" ")
      .filter((word) => !FillerWords.includes(word.toLowerCase()))
      .join(" ");
}

Screenshot suggestion: show raw log → parsed { food, quantity, unit, unitType }.

Raw log request and parsed response screenshot

Raw input log and parsed output example.

Scoring accurately

Scoring uses a category-specific matrix (CategoryScores) and accumulates score per item based on how many servings have already been recorded for that category.

Point matrix (CategoryScores): points by serving count per category.

// expediate-api/src/scorer/scores.ts
export const CategoryScores: Record<FoodCategory, number[]> = {
  fruit: [2, 2, 2, 1, 0, 0],
  vegetables: [2, 2, 2, 1, 0, 0],
  "whole-grains": [2, 2, 1, 0, 0, -1],
  refined-grains: [-1, -1, -2, -2, -2, -2],
  sweets: [-2, -2, -2, -2, -2, -2],
  "fatty-proteins": [-1, -1, -2, -2, -2, -2],
  unknown: [0],
};

Score accumulation (serving-by-serving): index into the matrix using a capped serving count.

// expediate-api/src/scorer/scorer.service.ts
for (let i = 0; i < servings; i++) {
  const count =
    categoryLogs[category].logs.reduce(
      (accumulator, { servings }) => (accumulator += servings),
      0,
    ) + i;

  score += scoreArray[Math.min(count, scoreArray.length - 1)];
}

Testing strategy

Unit tests cover the transformer (filler words, objective vs subjective units), categoriser (whole vs refined grains, plural/singular), and scorer (matrix + serving rounding).

Examples:

Transformer tests:

// expediate-api/src/transformer/transformer.service.spec.ts
it("should strip out filler words if provided", () => {
  const log = "1 portion of white bread";
  const { unit, food, quantity } = service.parse(log);
  expect(food).toBe("white bread");
  expect(unit).toBe("portion");
  expect(quantity).toBe(1);
});

it("should give me an objective type based on the unit provided", () => {
  const log = "100g oats";
  const { unit, unitType, quantity } = service.parse(log);
  expect(unit).toBe("g");
  expect(unitType).toBe("objective");
  expect(quantity).toBe(100);
});

Categoriser tests (plural/singular and grain adjectives):

// expediate-api/src/categoriser/categoriser.service.spec.ts
it("should categorise both plural and singular foods", () => {
  const categories = [
    service.categorise("berry"),
    service.categorise("berries"),
    service.categorise("noodle"),
    service.categorise("noodles"),
    service.categorise("potato"),
    service.categorise("potatoes"),
  ];

  categories.forEach((category) => {
    expect(category).not.toBe("unknown");
  });
});

End-to-end(-ish) scoring through LogService:

// expediate-api/src/log/log.service.spec.ts
it("should correctly score my breakfast", () => {
  const payload = {
    log: "oats, tahini, peanut butter, 1 banana, 1 portion milk, 15g honey",
  };

  const { totalScore, logs } = service.create(payload);
  expect(totalScore).toBe(7);
  expect(logs["whole-grains"].score).toBe(2);
  expect(logs["nuts-seeds"].score).toBe(4);
});

Screenshot suggestion: Jest output/coverage.

Jest test output screenshot

Jest test output and suite pass summary.

UI build

The web app is a thin Next.js on top of the API:

Expediate log input UI screenshot

UI: food log input screen.

Expediate DQS results UI screenshot

UI: DQS result with per-category breakdown.

Project management

I used Todoist to manage the iteration loop:

Todoist board showing MVP and iteration tasks

Todoist board from MVP to parsing and categorisation iterations.

Outcome

Future considerations