<script lang="ts">
import { calculateDailyIntake } from '@tool.io/tools/nutrition/daily-intake';
import { untrack } from 'svelte';
import type {
NutritionFood,
NutritionPageData
} from './types';
interface Props {
data?: NutritionPageData;
preview?: boolean;
tool: TypeTool;
}
let {
data = undefined,
preview = false,
tool
}: Props = $props();
type Selection = {
food: NutritionFood;
grams: number;
id: string;
};
let selectionSequence = 0;
const starterMasses = [200, 40, 80];
let selections: Selection[] = $state(
untrack(() =>
(data?.foods ?? []).map((food, index) => ({
food,
grams: starterMasses[index] ?? 100,
id: `${food.id}-${++selectionSequence}`
}))
)
);
let profileId = $state(
untrack(
() =>
data?.profiles.find(
(profile) => profile.jurisdiction === 'US'
)?.id ??
data?.profiles[0]?.id ??
''
)
);
let query = $state('');
let searchError = $state('');
let searchResults: NutritionFood[] = $state([]);
let searching = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let selectedProfile = $derived(
data?.profiles.find((profile) => profile.id === profileId)
);
let calculation = $derived.by(() =>
calculateDailyIntake({
foods: selections.map((selection) => ({
grams: selection.grams,
id: selection.food.id,
name: selection.food.name,
nutrients: selection.food.nutrients,
selectionId: selection.id
})),
referenceProfileId: selectedProfile?.id,
referenceValues: selectedProfile?.values
})
);
const formatAmount = (amount: number, unit: string) =>
`${new Intl.NumberFormat('en-US', {
maximumFractionDigits:
unit === 'kcal' || amount >= 100
? 0
: amount >= 10
? 1
: 2
}).format(amount)} ${unit}`;
const search = async () => {
const requestedQuery = query.trim();
if (requestedQuery.length < 2) {
searchResults = [];
searchError = '';
return;
}
searching = true;
searchError = '';
try {
const response = await fetch(
`/api/nutrition/search?query=${encodeURIComponent(requestedQuery)}`
);
if (!response.ok)
throw new Error('Food search is unavailable.');
const payload = (await response.json()) as {
foods: NutritionFood[];
};
if (query.trim() === requestedQuery) {
searchResults = payload.foods;
}
} catch (error) {
searchResults = [];
searchError =
error instanceof Error
? error.message
: 'Food search is unavailable.';
} finally {
if (query.trim() === requestedQuery) searching = false;
}
};
const scheduleSearch = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => void search(), 180);
};
const addFood = (food: NutritionFood) => {
selections = [
...selections,
{
food,
grams: food.portions[0]?.grams ?? 100,
id: `${food.id}-${++selectionSequence}`
}
];
query = '';
searchResults = [];
};
const updateMass = (id: string, value: string) => {
const grams = Number(value);
selections = selections.map((selection) =>
selection.id === id ? { ...selection, grams } : selection
);
};
const removeFood = (id: string) => {
selections = selections.filter(
(selection) => selection.id !== id
);
};
const referenceWording = (comparison: string) => {
if (comparison === 'maximum') return 'upper reference';
if (comparison === 'minimum') return 'minimum';
if (comparison === 'energy-estimate')
return 'energy estimate';
return 'reference';
};
</script>
{#if preview}
<a
href="/nutrition"
class="flex h-full min-h-28 w-full flex-col justify-center rounded-2xl bg-white p-4 text-steel-900 dark:bg-steel-800 dark:text-white"
>
<strong class="text-lg">Nutrition calculator</strong>
<span class="mt-1 text-sm"
>Combine foods by grams and inspect nutrients</span
>
</a>
{:else}
<section class="w-full min-w-0 space-y-5">
<div
class="grid gap-4 rounded-3xl bg-steel-100 p-4 dark:bg-steel-900"
>
<label class="block">
<span class="mb-1 block text-sm font-semibold"
>Reference profile</span
>
<select
class="h-12 w-full rounded-2xl border border-steel-300 bg-white px-4 dark:border-steel-700 dark:bg-steel-800"
bind:value={profileId}
>
{#each data?.profiles ?? [] as profile}
<option value={profile.id}>{profile.label}</option>
{/each}
</select>
</label>
<div class="relative">
<label
for="nutrition-food-search"
class="mb-1 block text-sm font-semibold"
>
Add a food
</label>
<div class="flex gap-2">
<input
id="nutrition-food-search"
type="search"
class="h-12 min-w-0 flex-1 rounded-2xl border border-steel-300 bg-white px-4 dark:border-steel-700 dark:bg-steel-800"
placeholder="Try yogurt, Brie, bread, or banana"
bind:value={query}
oninput={scheduleSearch}
/>
<button
type="button"
class="h-12 rounded-2xl bg-steel-800 px-5 font-semibold text-white hover:bg-blue-600 dark:bg-steel-700"
onclick={() => void search()}>Search</button
>
</div>
{#if searching}
<p class="mt-2 text-sm text-steel-500">
Searching USDA foods…
</p>
{:else if searchError}
<p
class="mt-2 text-sm text-red-700 dark:text-red-300"
>
{searchError}
</p>
{:else if query.trim().length >= 2 && searchResults.length === 0}
<p class="mt-2 text-sm text-steel-500">
No matching food found.
</p>
{/if}
{#if searchResults.length > 0}
<ul
class="absolute z-10 mt-2 max-h-80 w-full overflow-y-auto rounded-2xl border border-steel-200 bg-white p-2 shadow-xl dark:border-steel-700 dark:bg-steel-800"
>
{#each searchResults as food}
<li>
<button
type="button"
class="w-full rounded-xl px-3 py-2 text-left hover:bg-steel-100 dark:hover:bg-steel-700"
onclick={() => addFood(food)}
>
<strong class="block">{food.name}</strong>
<span class="text-xs text-steel-500">
{food.dataType} · FDC {food.fdcId}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-2">
<h3 class="text-xl font-semibold">Selected foods</h3>
{#if selections.length === 0}
<p
class="rounded-2xl bg-white p-4 text-steel-500 dark:bg-steel-850"
>
Add at least one food to calculate nutrition.
</p>
{:else}
{#each selections as selection (selection.id)}
<div
class="grid items-center gap-3 rounded-2xl bg-white p-3 sm:grid-cols-[minmax(0,1fr)_8rem_2.5rem] dark:bg-steel-850"
>
<div class="min-w-0">
<strong class="block truncate"
>{selection.food.name}</strong
>
<span class="text-xs text-steel-500">
USDA FDC {selection.food.fdcId} · {selection
.food.nutrients.length} nutrients
</span>
</div>
<label class="flex items-center gap-2">
<span class="sr-only"
>Grams of {selection.food.name}</span
>
<input
type="number"
min="0.01"
step="any"
class="h-10 w-full min-w-0 rounded-xl border border-steel-300 bg-white px-3 dark:border-steel-700 dark:bg-steel-800"
value={selection.grams}
oninput={(event) =>
updateMass(
selection.id,
event.currentTarget.value
)}
/>
<span class="text-sm">g</span>
</label>
<button
type="button"
class="flex size-10 items-center justify-center rounded-full bg-steel-100 text-xl hover:bg-red-100 hover:text-red-700 dark:bg-steel-700 dark:hover:bg-red-950"
aria-label={`Remove ${selection.food.name}`}
onclick={() => removeFood(selection.id)}>×</button
>
</div>
{/each}
{/if}
</div>
{#if calculation.ok}
{@const energy = calculation.output.nutrients.find(
(item) => item.id === 'nutrient.energy'
)}
{@const protein = calculation.output.nutrients.find(
(item) => item.id === 'nutrient.protein'
)}
{@const carbohydrate = calculation.output.nutrients.find(
(item) => item.id === 'nutrient.carbohydrate'
)}
{@const fat = calculation.output.nutrients.find(
(item) => item.id === 'nutrient.total-fat'
)}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each [energy, protein, carbohydrate, fat].filter(Boolean) as nutrient}
<div
class="rounded-2xl bg-white p-3 dark:bg-steel-850"
>
<span class="block text-xs text-steel-500"
>{nutrient!.label}</span
>
<strong class="text-lg"
>{formatAmount(
nutrient!.total,
nutrient!.unit
)}</strong
>
</div>
{/each}
</div>
<div
class="overflow-x-auto rounded-2xl bg-white dark:bg-steel-850"
>
<table
class="w-full min-w-[680px] border-collapse text-sm"
>
<thead>
<tr
class="border-b border-steel-200 text-left dark:border-steel-700"
>
<th class="p-3">Nutrient</th>
<th class="p-3 text-right">Total</th>
<th class="p-3 text-right">Reference</th>
<th class="w-48 p-3">Comparison</th>
</tr>
</thead>
<tbody>
{#each calculation.output.nutrients as nutrient}
<tr
class="border-b border-steel-100 last:border-0 dark:border-steel-800"
>
<th class="p-3 text-left font-medium">
<details>
<summary class="cursor-pointer"
>{nutrient.label}</summary
>
<ul
class="mt-1 space-y-0.5 text-xs font-normal text-steel-500"
>
{#each nutrient.contributions as contribution}
<li>
{contribution.foodName}: {formatAmount(
contribution.amount,
nutrient.unit
)}
</li>
{/each}
</ul>
</details>
{#if !nutrient.complete}
<span
class="block text-xs font-normal text-amber-700 dark:text-amber-300"
>Incomplete source coverage</span
>
{/if}
</th>
<td class="p-3 text-right tabular-nums"
>{formatAmount(
nutrient.total,
nutrient.unit
)}</td
>
<td class="p-3 text-right tabular-nums">
{#if nutrient.reference}
{formatAmount(
nutrient.reference.amount,
nutrient.unit
)}
<span class="block text-xs text-steel-500"
>{referenceWording(
nutrient.reference.comparison
)}</span
>
{:else}—{/if}
</td>
<td class="p-3">
{#if nutrient.reference}
{#if nutrient.reference.comparison === 'maximum'}
<span
class={`block text-right tabular-nums ${nutrient.reference.percentage > 100 ? 'text-red-700 dark:text-red-300' : ''}`}
>
{nutrient.reference.percentage.toFixed(
0
)}% of upper reference
</span>
{:else}
<div
class="h-2 overflow-hidden rounded-full bg-steel-200 dark:bg-steel-700"
>
<div
class="h-full rounded-full bg-blue-500"
style={`width: ${Math.min(100, nutrient.reference.percentage)}%`}
></div>
</div>
<span
class="mt-1 block text-right text-xs tabular-nums"
>{nutrient.reference.percentage.toFixed(
0
)}%</span
>
{/if}
{:else}<span class="text-steel-400"
>No reference</span
>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if selections.length > 0}
<p
class="rounded-2xl bg-red-100 p-4 text-red-900 dark:bg-red-950 dark:text-red-100"
>
{calculation.error.issues[0]?.message ??
'The selected foods could not be calculated.'}
</p>
{/if}
<div
class="rounded-2xl bg-steel-100 p-4 text-sm text-steel-600 dark:bg-steel-900 dark:text-steel-300"
>
<p>
Food composition: USDA FoodData Central. Values are
calculated from the selected record's edible amount per
100 grams.
</p>
{#if selectedProfile}
{@const referenceSource = data?.sources.find(
(source) => source.id === selectedProfile.sourceId
)}
{#if referenceSource}
<p class="mt-2">
Reference profile:
<a
class="underline"
href={referenceSource.url}
target="_blank"
rel="noopener noreferrer"
>{referenceSource.publisher}</a
>
·
<a
class="underline"
href={referenceSource.licenseUrl}
target="_blank"
rel="noopener noreferrer"
>{referenceSource.license}</a
>
</p>
{/if}
{/if}
{#if data?.datasetVersion}
<p class="mt-2">
Normalized reference dataset: {data.datasetVersion}
</p>
{/if}
<p class="mt-2">
These population references describe habitual intake and
are not personalized medical advice.
</p>
</div>
</section>
{/if}
Browse the implementation files for Nutrition Calculator.