Nutrition Calculator Source Code

Browse the implementation files for Nutrition Calculator.
Nutrition.svelte svelte
<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}

Description

Browse the implementation files for Nutrition Calculator.

References

  1. Tool.io on GitHub
  2. Tool.io TypeScript tools library

Similar or alternative tools

Don't forget to set a bookmark for tool.io!
Privacy | Imprint | Cookies