Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/api/src/open-food-facts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { lookupOpenFoodFacts, parseOpenFoodFactsProduct } from "./open-food-facts.js";

test("parses per-100g values and converts salt/sodium to mg", () => {
const result = parseOpenFoodFactsProduct("7290000000001", {
product_name: " Cottage ",
quantity: "250 g",
nutriments: { "energy-kcal_100g": 98, proteins_100g: 11.5, carbohydrates_100g: 3.4, fat_100g: 4, salt_100g: 1.25 },
});
assert.deepEqual(result, {
barcode: "7290000000001",
name: "Cottage",
basis: "solid",
calories: 98,
protein: 11.5,
carbohydrates: 3.4,
fat: 4,
fiber: 0,
sugars: 0,
sodium: 500,
});
});

test("falls back to kJ and detects liquids", () => {
const result = parseOpenFoodFactsProduct("7290000000002", {
product_name_he: "חלב",
quantity: "1 l",
nutriments: { energy_100g: 251.2, sodium_100g: 0.05 },
});
assert.equal(result?.calories, 60.04);
assert.equal(result?.basis, "liquid");
assert.equal(result?.sodium, 50);
});

test("returns null without a name or calories", () => {
assert.equal(parseOpenFoodFactsProduct("1", { product_name: "", nutriments: { "energy-kcal_100g": 5 } }), null);
assert.equal(parseOpenFoodFactsProduct("1", { product_name: "X", nutriments: {} }), null);
assert.equal(parseOpenFoodFactsProduct("1", null), null);
});

test("lookup is best effort", async () => {
const ok = (async () => new Response(JSON.stringify({ status: 1, product: { product_name: "A", nutriments: { "energy-kcal_100g": 10 } } }))) as typeof fetch;
assert.equal((await lookupOpenFoodFacts("7290000000003", ok))?.name, "A");
const boom = (async () => { throw new Error("offline"); }) as typeof fetch;
assert.equal(await lookupOpenFoodFacts("7290000000003", boom), null);
assert.equal(await lookupOpenFoodFacts("abc", ok), null);
});
82 changes: 82 additions & 0 deletions apps/api/src/open-food-facts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/** Barcode lookup in Open Food Facts, used when a barcode is not in our own catalog. */

export type FoodSuggestion = {
barcode: string;
name: string;
basis: "solid" | "liquid";
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugars: number;
sodium: number;
};

const FIELDS = "product_name,product_name_he,product_name_en,quantity,nutriments";
const TIMEOUT_MS = 4000;

function round(value: number): number {
return Math.round(value * 100) / 100;
}

function amount(nutriments: Record<string, unknown>, key: string): number | null {
const value = nutriments[key];
const parsed = typeof value === "string" ? Number(value) : value;
return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}

/**
* Turns an Open Food Facts product into a suggestion for a new food (values per 100 g / 100 ml).
* Returns null when the product has no name or no calories: nothing useful to prefill.
* Sodium is stored in mg; Open Food Facts reports grams, or salt (sodium = salt / 2.5).
*/
export function parseOpenFoodFactsProduct(barcode: string, product: unknown): FoodSuggestion | null {
if (!product || typeof product !== "object") return null;
const p = product as Record<string, unknown>;
const nutriments = (p.nutriments && typeof p.nutriments === "object" ? p.nutriments : {}) as Record<string, unknown>;
const text = (key: string) => (typeof p[key] === "string" ? (p[key] as string).trim() : "");
const name = (text("product_name") || text("product_name_he") || text("product_name_en")).slice(0, 120);
let calories = amount(nutriments, "energy-kcal_100g");
if (calories == null) {
const kj = amount(nutriments, "energy_100g");
calories = kj == null ? null : kj / 4.184;
}
if (!name || calories == null || calories > 10000) return null;
const sodiumGrams = amount(nutriments, "sodium_100g") ?? (() => {
const salt = amount(nutriments, "salt_100g");
return salt == null ? null : salt / 2.5;
})();
const quantity = text("quantity").toLowerCase();
return {
barcode,
name,
basis: /\d\s*(ml|cl|l)\b|מ"ל|מל/.test(quantity) ? "liquid" : "solid",
calories: round(calories),
protein: round(amount(nutriments, "proteins_100g") ?? 0),
carbohydrates: round(amount(nutriments, "carbohydrates_100g") ?? 0),
fat: round(amount(nutriments, "fat_100g") ?? 0),
fiber: round(amount(nutriments, "fiber_100g") ?? 0),
sugars: round(amount(nutriments, "sugars_100g") ?? 0),
sodium: round((sodiumGrams ?? 0) * 1000),
};
}

/** Best effort: any network or format problem means "not found", never an error for the user. */
export async function lookupOpenFoodFacts(
barcode: string,
fetchImpl: typeof fetch = fetch,
): Promise<FoodSuggestion | null> {
if (!/^\d{6,14}$/.test(barcode)) return null;
try {
const response = await fetchImpl(
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json?fields=${FIELDS}`,
{ headers: { "User-Agent": "DropIt/1.0" }, signal: AbortSignal.timeout(TIMEOUT_MS) },
);
if (!response.ok) return null;
const body = (await response.json()) as { status?: number; product?: unknown };
return body.status === 1 ? parseOpenFoodFactsProduct(barcode, body.product) : null;
} catch {
return null;
}
}
6 changes: 5 additions & 1 deletion apps/api/src/routes/foods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { currentUser, requireUser } from "../auth.js";
import { prisma } from "../db.js";
import { resolveBarcode } from "../domain.js";
import { asyncRoute, HttpError } from "../http.js";
import { lookupOpenFoodFacts } from "../open-food-facts.js";
import { CATALOG_PAGE_SIZE, catalogWhere, pickRecentFoods, pickSearchResults } from "../food-search.js";
import { num, presentFood } from "../present.js";
import { requirePermission } from "../rbac.js";
Expand Down Expand Up @@ -112,10 +113,13 @@ foodsRouter.get(
const published = rows.filter((food) => food.published && food.ownerId !== user.id);
const verified = published.find((food) => food.verifiedAt != null) ?? null;
const decision = resolveBarcode({ own, verified, published });
// Not in our catalog: offer Open Food Facts values to prefill a new food. Nothing is saved here.
const suggestion = decision.status === "none" ? await lookupOpenFoodFacts(barcode) : null;
res.json({
status: decision.status,
status: suggestion ? "external" : decision.status,
food: decision.food ? presentFood(decision.food, user.id) : null,
foods: decision.foods.map((food) => presentFood(food, user.id)),
suggestion,
});
}),
);
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/lib/copy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ class Copy {
String get catalogLoadMore => pick('Load more', 'טען עוד');
String get catalogPrivate => pick('Private', 'פרטי');
String get chooseFood => pick('Choose a published food', 'בחירת מזון שפורסם');
String get onlineValues => pick(
'Values from Open Food Facts (per 100 g / 100 ml) – please check them before saving.',
'הערכים מ-Open Food Facts (ל-100 גרם / 100 מ״ל) – נא לבדוק אותם לפני השמירה.',
);
String get noFood => pick(
'This barcode is not in the database yet.',
'הברקוד הזה עדיין לא במאגר.',
Expand Down
51 changes: 39 additions & 12 deletions apps/mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1366,11 +1366,18 @@ class _TodayScreenState extends State<TodayScreen> {
if (!mounted) return;
Map<String, dynamic>? food = (result['food'] as Map?)
?.cast<String, dynamic>();
if (result['status'] == 'none') {
if (result['status'] == 'none' || result['status'] == 'external') {
final suggestion = (result['suggestion'] as Map?)
?.cast<String, dynamic>();
food = await Navigator.push<Map<String, dynamic>>(
context,
MaterialPageRoute(
builder: (_) => FoodScreen(api: widget.api, t: t, barcode: barcode),
builder: (_) => FoodScreen(
api: widget.api,
t: t,
barcode: barcode,
suggestion: suggestion,
),
),
);
} else if (result['status'] == 'list') {
Expand Down Expand Up @@ -4034,10 +4041,14 @@ class FoodScreen extends StatefulWidget {
required this.t,
this.barcode,
this.initialName,
this.suggestion,
});
final Api api;
final Copy t;

/// Open Food Facts values for the barcode, to prefill (and flag for review).
final Map<String, dynamic>? suggestion;

/// Known when the food was scanned; otherwise the user types it.
final String? barcode;
final String? initialName;
Expand All @@ -4047,18 +4058,29 @@ class FoodScreen extends StatefulWidget {
}

class _FoodScreenState extends State<FoodScreen> {
late final name = TextEditingController(text: widget.initialName ?? '');
late final name = TextEditingController(
text: (widget.suggestion?['name'] as String?) ?? widget.initialName ?? '',
);
late final barcode = TextEditingController(text: widget.barcode ?? '');
String basis = 'solid';
final calories = TextEditingController();
final protein = TextEditingController();
final carbohydrates = TextEditingController();
final fat = TextEditingController();
final fiber = TextEditingController();
final sugars = TextEditingController();
final sodium = TextEditingController();
late String basis = widget.suggestion?['basis'] == 'liquid' ? 'liquid' : 'solid';
late final calories = _prefilled('calories');
late final protein = _prefilled('protein');
late final carbohydrates = _prefilled('carbohydrates');
late final fat = _prefilled('fat');
late final fiber = _prefilled('fiber');
late final sugars = _prefilled('sugars');
late final sodium = _prefilled('sodium');
String error = '';
final scanned = <TextEditingController>{};
late final scanned = <TextEditingController>{
if (widget.suggestion != null)
for (final c in [calories, protein, carbohydrates, fat, fiber, sugars, sodium])
if (c.text.isNotEmpty) c,
};

TextEditingController _prefilled(String key) {
final value = widget.suggestion?[key];
return TextEditingController(text: value is num ? _plainNumber(value) : '');
}
bool scannedFromServing = false;
String scannedText = '';
NutritionScan? lastScan;
Expand Down Expand Up @@ -4122,6 +4144,11 @@ class _FoodScreenState extends State<FoodScreen> {
label: Text(t.scanLabel),
),
),
if (widget.suggestion != null)
Text(
t.onlineValues,
style: TextStyle(color: context.apple.labelSecondary),
),
if (scannedText.isNotEmpty)
Align(
alignment: AlignmentDirectional.centerStart,
Expand Down
24 changes: 19 additions & 5 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type FastingState,
type FastNotification,
type Food,
type FoodSuggestion,
type ReminderOffset,
MEALS,
type MealCategory,
Expand Down Expand Up @@ -611,7 +612,7 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o
const [query, setQuery] = useState("");
const [results, setResults] = useState<Food[] | null>(null);
const [recent, setRecent] = useState<RecentFood[]>([]);
const [lookup, setLookup] = useState<{ status: string; food: Food | null; foods: Food[] } | null>(null);
const [lookup, setLookup] = useState<{ status: string; food: Food | null; foods: Food[]; suggestion?: FoodSuggestion | null } | null>(null);
const [picked, setPicked] = useState<Food | null>(null);
const [amount, setAmount] = useState("100");
const [error, setError] = useState("");
Expand Down Expand Up @@ -720,7 +721,7 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o
return;
}
try {
const result = await api<{ status: string; food: Food | null; foods: Food[] }>(`/api/foods/barcode/${encodeURIComponent(trimmedQuery)}`);
const result = await api<{ status: string; food: Food | null; foods: Food[]; suggestion?: FoodSuggestion | null }>(`/api/foods/barcode/${encodeURIComponent(trimmedQuery)}`);
setLookup(result);
if (result.food) pick(result.food);
} catch (err) {
Expand All @@ -746,6 +747,12 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o
}
}

function suggestionLink(meal: MealCategory, suggestion: FoodSuggestion) {
const search = new URLSearchParams({ meal, source: "online" });
for (const [key, value] of Object.entries(suggestion)) search.set(key, String(value));
return `/foods/new?${search}`;
}

function newFoodLink(meal: MealCategory) {
const search = new URLSearchParams({ meal });
if (barcodeQuery) search.set("barcode", trimmedQuery);
Expand Down Expand Up @@ -906,6 +913,12 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o
{lookup?.status === "none" && (
<p>{t.noFood} <Link to={newFoodLink(meal)}>{t.createFood}</Link></p>
)}
{lookup?.status === "external" && lookup.suggestion && (
<p>
{t.foundOnline}: <strong>{lookup.suggestion.name}</strong>{" "}
<Link to={suggestionLink(meal, lookup.suggestion)}>{t.reviewAndSave}</Link>
</p>
)}
{lookup?.status === "list" && (
<div className="food-list">
<h3 className="label">{t.chooseFood}</h3>
Expand Down Expand Up @@ -1870,21 +1883,22 @@ function FoodForm() {
return (
<Form className="card" onSubmit={save} key={food?.id ?? "new"}>
<h1>{t.createFood}</h1>
{!id && params.get("source") === "online" && <p className="muted">{t.onlineValues}</p>}
{!id && <label><span>{t.barcode}</span><input name="barcode" defaultValue={barcode} placeholder={t.optional} inputMode="numeric" /></label>}
<label><span>{t.name}</span><input name="name" defaultValue={food?.name ?? (id ? undefined : params.get("name") ?? undefined)} required /></label>
<MenuField
name="basis"
label={t.measuredAs}
defaultValue={food?.basis ?? "solid"}
defaultValue={food?.basis ?? (params.get("basis") === "liquid" ? "liquid" : "solid")}
options={[
{ value: "solid", label: t.solid },
{ value: "liquid", label: t.liquid },
]}
/>
<label><span>{t.calories}</span><input name="calories" defaultValue={food?.calories} placeholder="0" inputMode="decimal" pattern="\d+([.,]\d+)?" required /></label>
<label><span>{t.calories}</span><input name="calories" defaultValue={food?.calories ?? (id ? undefined : params.get("calories") ?? undefined)} placeholder="0" inputMode="decimal" pattern="\d+([.,]\d+)?" required /></label>
<div className="nutrient-grid">
{(["protein", "carbohydrates", "fat", "fiber", "sugars", "sodium"] as const).map((field) => (
<label key={field}><span>{t[field]}</span><input name={field} defaultValue={food?.[field]} placeholder={t.optional} inputMode="decimal" pattern="\d+([.,]\d+)?" /></label>
<label key={field}><span>{t[field]}</span><input name={field} defaultValue={food?.[field] ?? (id ? undefined : params.get(field) ?? undefined)} placeholder={t.optional} inputMode="decimal" pattern="\d+([.,]\d+)?" /></label>
))}
</div>
{error && <p className="error">{error}</p>}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ export type Nutrients = {
sodium: number;
};

export type FoodSuggestion = {
barcode: string;
name: string;
basis: "solid" | "liquid";
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugars: number;
sodium: number;
};

export type Food = Nutrients & {
id: string;
barcode: string | null;
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ const en = {
catalogFilter: "Verification",
personal: "Your food",
chooseFood: "Choose a published food",
foundOnline: "Found online",
reviewAndSave: "Review and save",
onlineValues: "Values from Open Food Facts (per 100 g / 100 ml). Please check them before saving.",
noFood: "This barcode is not in the database yet.",
emptyDay: "No foods logged today.",
emptyMeal: "Nothing logged yet.",
Expand Down Expand Up @@ -411,6 +414,9 @@ const he: typeof en = {
catalogFilter: "אימות",
personal: "המזון שלך",
chooseFood: "בחירת מזון שפורסם",
foundOnline: "נמצא ברשת",
reviewAndSave: "בדיקה ושמירה",
onlineValues: "הערכים מ-Open Food Facts (ל-100 גרם / 100 מ״ל). נא לבדוק אותם לפני השמירה.",
noFood: "הברקוד הזה עדיין לא במאגר.",
emptyDay: "עדיין לא נרשם מזון היום.",
emptyMeal: "עדיין לא נרשם.",
Expand Down
2 changes: 1 addition & 1 deletion docs/ideas.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Ideas for improving Drop It, to revisit. Roughly in priority order.
Real failures become parser tests (like the Pringles one) and a test set
builds up over time. Liquid / per-100 ml labels still fail.

2. **Auto-fill foods from a barcode database**
2. **Auto-fill foods from a barcode database** (done: API falls back to Open Food Facts, clients prefill the form)
Look the barcode up in Open Food Facts (good Israeli coverage) before asking
for a label photo. The label scanner becomes the fallback. Also seeds the
catalog.
Expand Down
Loading