DEV Community

BeGoodTool.com
BeGoodTool.com

Posted on

I built a calorie lookup table and realized the search box was already a category filter

A while back I wanted a fast way to answer "how many calories are in this" while eating instead of after — no app to install, no account, just type the food and see a number. I figured I'd build a small searchable table over a list of common foods. There's no clever algorithm hiding in something like this. It's a filtered array rendered as rows. What actually took the time was the boring stuff: how do you store "1 bowl (200g)" as data, what happens when someone searches for a category instead of a food, and whether the number you show is even the number you meant to show.

The search box already does more than the category dropdown

The table has both a text search and a category dropdown (Staple Food, Fruit, Drinks, Vegetable, Seafood, Meat, Egg). I built them as two separate filters, applied one after the other:

let showTable = computed(() => {
  let searchResult = foodCalories.value.filter((item) => {
    return (
      item.groupName.toUpperCase().indexOf(keyword.value.toUpperCase()) > -1 ||
      item.groupName_en.toUpperCase().indexOf(keyword.value.toUpperCase()) > -1 ||
      item.name.toUpperCase().indexOf(keyword.value.toUpperCase()) > -1 ||
      item.name_en.toUpperCase().indexOf(keyword.value.toUpperCase()) > -1
    );
  });
  return searchResult.filter((item) => {
    if (showGroup.value == "All") return true;
    return item.groupName_en == showGroup.value;
  });
});
Enter fullscreen mode Exit fullscreen mode

The text filter checks four fields per item, not one: the Chinese name, the English name, the Chinese category, and the English category. I added the category fields mostly so a groupName_en typo somewhere wouldn't make an item unfindable. What I didn't plan for is that typing "fruit" into the search box now returns every fruit in the table, with zero interaction with the dropdown next to it — the dropdown and the search box quietly do the same job through two different UI elements. It's not a bug exactly, it's just a filter that's wider than the label on the box suggests. Nobody's complained, but if I rebuilt this I'd probably narrow the text search to just the name fields and let the dropdown own category filtering exclusively.

Calories are stored as a string, and that decision has a shelf life

Each row looks like this:

{
  name: "白米飯",
  name_en: "White rice",
  unit: "1碗",
  unit_en: "1 bowl",
  weight: "200g",
  calories: "225",
  groupName: "主食",
  groupName_en: "Staple Food",
},
Enter fullscreen mode Exit fullscreen mode

calories is a string, "225", not the number 225. That was fine as long as all I ever did with it was print it next to the name. The table has no sort feature today, and that's exactly why it's gotten away with this so far — "9" sorting after "80" because "9" > "8" lexicographically only matters the moment you try to sort. The day I add "sort by calories," every row with a triple-digit value will need parseInt first, or the ordering will look randomly wrong to anyone who doesn't know why. I left it as a string because the data came from a spreadsheet of copy-pasted nutrition labels, and normalizing every value to a real number felt like busywork for a feature that didn't exist yet. That's a fair trade until it isn't.

A <table> your browser never actually shows you

The template renders the food data twice. Once as a real HTML <table> with <thead>/<tbody>, and once as a grid of styled <div>s that's the thing you actually see and interact with:

.foodCalories__table {
  height: 0;
  overflow: hidden;
}
Enter fullscreen mode Exit fullscreen mode

The semantic table exists purely for crawlers and screen readers — search engines and assistive tech get proper table markup with real headers, while the visible UI is a <div> grid because that's what let me do per-cell click-to-copy, a font-size toggle, and a mobile layout that reflows without fighting <table> layout rules. Two representations of the same 300+ rows, kept in sync only because they're both generated from the same v-for over the same array. If I ever changed one loop without the other, they'd silently drift apart with no error to catch it.

Where the numbers don't quite line up

The honest gotchas, since none of this is as tidy as it looks:

  • Click-to-copy doesn't always match what's on screen. The visible unit column shows the Chinese unit for both tw and cn locales (locale == "tw" || locale == "cn" ? item.unit : item.unit_en), but the click-to-copy handler for that same cell only checks locale == "tw". A cn-locale user sees "1碗" on screen and copies "1 bowl" to their clipboard. Small, easy to miss, and exactly the kind of thing that survives for years in a component nobody re-reads line by line.
  • Calories are per labeled serving, not per 100g, and the serving unit and its gram weight are just two adjacent strings (unit, weight) glued together for display — "1碗(200g)" — with no actual unit conversion behind them. Comparing two foods with different serving sizes means doing the per-gram math yourself.
  • The category dropdown is a hand-written list, separate from the actual groupName_en values in the data file. If a future data update adds a new category, it's searchable by text immediately but invisible in the dropdown until someone remembers to add it there too.

None of that makes the table wrong, exactly — it makes it a spreadsheet with a search box, and it's worth knowing that's what you're looking at before you trust it for anything more precise than "roughly how much is this."

I cleaned this up into a small free tool if you want to poke at the real thing: Common Food Calorie Table. No sign-up, just search and filter over 300+ foods.


Available in other languages

Top comments (0)