initial commit

This commit is contained in:
2026-07-02 10:57:45 +02:00
parent 43ef81b8e8
commit fbf4026130
10 changed files with 996 additions and 52 deletions
+44 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { SafeAreaView, StyleSheet, Text, View } from 'react-native';
import { BackHandler, SafeAreaView, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import BottomTabBar from './src/components/layout/BottomTabBar';
@@ -7,6 +7,7 @@ import AlternativesScreen from './src/screens/AlternativesScreen';
import HomeScreen from './src/screens/HomeScreen';
import ProductDetailScreen from './src/screens/ProductDetailScreen';
import ProfileScreen from './src/screens/ProfileScreen';
import RatingCriteriaDetailScreen from './src/screens/RatingCriteriaDetailScreen';
import ScanScreen from './src/screens/ScanScreen';
import ShoppingListScreen from './src/screens/ShoppingListScreen';
import { lookupProductByBarcode } from './src/services/productApi';
@@ -89,6 +90,12 @@ export default function App() {
const routeProduct = route.product;
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', goBackFromRoute);
return () => subscription.remove();
}, [route]);
const tabScreen = useMemo(() => {
if (activeTab === 'scan') {
return (
@@ -160,6 +167,10 @@ export default function App() {
setRoute({ name: 'alternatives', product });
}
function showRatingCriteria(product, category) {
setRoute({ name: 'ratingCriteria', product, category });
}
function addProductToCache(product) {
setProductCache((current) => ({
...current,
@@ -322,6 +333,25 @@ export default function App() {
setRoute({ name: 'tabs' });
}
function goBackFromRoute() {
if (route.name === 'ratingCriteria' && routeProduct) {
setRoute({ name: 'productDetail', product: routeProduct });
return true;
}
if (route.name === 'alternatives' && routeProduct) {
setRoute({ name: 'productDetail', product: routeProduct });
return true;
}
if (route.name === 'productDetail' && routeProduct) {
setRoute({ name: 'tabs' });
return true;
}
return false;
}
function renderRoute() {
if (!isHydrated) {
return (
@@ -337,6 +367,7 @@ export default function App() {
onAddToList={addProductToShoppingList}
onBack={goBackToTabs}
onSaveManualPrice={saveManualPriceForProduct}
onShowRatingCriteria={showRatingCriteria}
onShowAlternatives={showAlternatives}
preferences={preferences}
product={routeProduct}
@@ -344,10 +375,21 @@ export default function App() {
);
}
if (route.name === 'ratingCriteria' && routeProduct) {
return (
<RatingCriteriaDetailScreen
category={route.category}
onBack={goBackFromRoute}
preferences={preferences}
product={routeProduct}
/>
);
}
if (route.name === 'alternatives' && routeProduct) {
return (
<AlternativesScreen
onBack={() => setRoute({ name: 'productDetail', product: routeProduct })}
onBack={goBackFromRoute}
onChoose={(product) => {
addProductToShoppingList(product);
setActiveTab('list');
+10 -2
View File
@@ -14,8 +14,10 @@
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen."
}
"NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen.",
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "net.fretux.shoppy"
},
"android": {
"permissions": [
@@ -23,6 +25,12 @@
],
"adaptiveIcon": {
"backgroundColor": "#f7f3ea"
},
"package": "net.fretux.shoppy"
},
"extra": {
"eas": {
"projectId": "a7a5424e-5049-4040-9c82-06cab416c228"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 20.4.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
+13 -4
View File
@@ -1,4 +1,4 @@
import { StyleSheet, Text, View } from 'react-native';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
import ProductScoreBadge from './ProductScoreBadge';
@@ -6,13 +6,16 @@ import ProductScoreBadge from './ProductScoreBadge';
const labels = {
allergies: 'Allergien',
budget: 'Budget',
ingredients: 'Zutaten',
nutrition: 'Ernährung',
sustainability: 'Nachhaltigkeit'
};
export default function RatingCategoryRow({ category, rating }) {
export default function RatingCategoryRow({ category, rating, onPress }) {
const Container = onPress ? Pressable : View;
return (
<View style={styles.row}>
<Container accessibilityRole={onPress ? 'button' : undefined} onPress={onPress} style={styles.row}>
<View style={styles.copy}>
<Text style={styles.title}>{labels[category] || category}</Text>
<Text style={styles.message}>{rating.message}</Text>
@@ -21,7 +24,8 @@ export default function RatingCategoryRow({ category, rating }) {
))}
</View>
<ProductScoreBadge status={rating.status} score={rating.score} />
</View>
{onPress ? <Text style={styles.chevron}>›</Text> : null}
</Container>
);
}
@@ -56,5 +60,10 @@ const styles = StyleSheet.create({
fontWeight: '700',
lineHeight: 19,
marginTop: 4
},
chevron: {
color: colors.muted,
fontSize: 24,
fontWeight: '700'
}
});
+7 -1
View File
@@ -19,6 +19,7 @@ export default function ProductDetailScreen({
onBack,
onAddToList,
onSaveManualPrice,
onShowRatingCriteria,
onShowAlternatives
}) {
const [savedMessage, setSavedMessage] = useState('');
@@ -98,7 +99,12 @@ export default function ProductDetailScreen({
<SectionTitle>Bewertungskriterien</SectionTitle>
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
<RatingCategoryRow category={category} key={category} rating={categoryRating} />
<RatingCategoryRow
category={category}
key={category}
onPress={() => onShowRatingCriteria(pricedProduct, category)}
rating={categoryRating}
/>
))}
<SectionTitle>Produktdaten</SectionTitle>
+3
View File
@@ -6,6 +6,7 @@ const INTERNAL_ALTERNATIVE_LIMIT = 8;
const improvementLabels = {
allergies: 'Besser passend bei Allergien',
ingredients: 'Bessere Zutaten',
budget: 'Günstiger fürs Budget',
nutrition: 'Bessere Ernährung',
sustainability: 'Nachhaltiger'
@@ -148,6 +149,8 @@ function knownDataScore(product) {
return [
[hasKnownAllergens(product), 3],
[completeness.allergens !== false, 2],
[completeness.ingredients !== false, 2],
[completeness.additives !== false, 1],
[Boolean(nutrition.nutriScore), 2],
[hasNutrientLevels(nutrition), 2],
[completeness.nutrition !== false, 1],
+55
View File
@@ -14,11 +14,15 @@ const productFields = [
'ingredients_text',
'ingredients_text_de',
'ingredients_text_en',
'ingredients_analysis_tags',
'ingredients_from_palm_oil_tags',
'additives_tags',
'allergens_tags',
'nutriments',
'nutrient_levels',
'nutriscore_grade',
'ecoscore_grade',
'ecoscore_data',
'labels_tags',
'origins',
'origins_tags',
@@ -133,12 +137,19 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) {
price: undefined,
currency: 'CHF',
ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en),
ingredientAnalysis: {
vegan: analysisStatus(product.ingredients_analysis_tags, 'vegan'),
vegetarian: analysisStatus(product.ingredients_analysis_tags, 'vegetarian'),
palmOil: palmOilStatus(product.ingredients_analysis_tags, product.ingredients_from_palm_oil_tags),
additives: normalizeAdditives(product.additives_tags || [])
},
allergens: normalizeAllergens(product.allergens_tags || []),
nutrition: {
caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']),
sugarPer100g: numberOrUndefined(nutriments.sugars_100g),
caffeineMgPer100g: normalizeCaffeineMg(nutriments.caffeine_100g),
fatPer100g: numberOrUndefined(nutriments.fat_100g),
saturatedFatPer100g: numberOrUndefined(nutriments['saturated-fat_100g']),
proteinPer100g: numberOrUndefined(nutriments.proteins_100g),
saltPer100g: numberOrUndefined(nutriments.salt_100g),
nutrientLevels: normalizeNutrientLevels(product.nutrient_levels),
@@ -146,12 +157,14 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) {
},
sustainability: {
ecoScore: normalizeGrade(product.ecoscore_grade),
carbonFootprintKgCo2ePerKg: numberOrUndefined(product.ecoscore_data?.agribalyse?.co2_total),
isBio: hasOrganicLabel(product.labels_tags || []),
originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags),
packaging: firstTextValue(product.packaging)
},
dataCompleteness: {
allergens: Array.isArray(product.allergens_tags),
additives: Array.isArray(product.additives_tags),
ecoScore: Boolean(normalizeGrade(product.ecoscore_grade)),
ingredients: Boolean(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en),
nutriScore: Boolean(normalizeGrade(product.nutriscore_grade)),
@@ -218,6 +231,48 @@ function parseIngredients(ingredientsText) {
.slice(0, 16);
}
function analysisStatus(analysisTags = [], diet) {
if (analysisTags.includes(`en:${diet}`)) {
return true;
}
if (analysisTags.includes(`en:non-${diet}`)) {
return false;
}
if (analysisTags.includes(`en:${diet}-status-unknown`)) {
return undefined;
}
return undefined;
}
function palmOilStatus(analysisTags = [], palmOilTags = []) {
if (analysisTags.includes('en:palm-oil') || palmOilTags.length > 0) {
return true;
}
if (analysisTags.includes('en:palm-oil-free')) {
return false;
}
if (analysisTags.includes('en:palm-oil-content-unknown')) {
return undefined;
}
return undefined;
}
function normalizeAdditives(additiveTags) {
return additiveTags
.map((tag) => {
const cleanedTag = cleanTag(tag);
const codeMatch = String(tag || '').match(/\be\d{3,4}[a-z]?\b/i);
return codeMatch && cleanedTag ? `${codeMatch[0].toUpperCase()} ${cleanedTag}` : cleanedTag;
})
.filter(Boolean);
}
function normalizeAllergens(allergenTags) {
const allergenMap = {
'en:peanuts': 'Erdnüsse',
+773 -41
View File
@@ -21,6 +21,84 @@ const ecoScoreValues = {
E: 15
};
const additiveRiskGroups = [
{
id: 'nitrite-nitrate-curing-salts',
label: 'Nitrit-/Nitratpökelsalze',
codes: [[249, 252]],
examples: 'Wurst, Schinken, Speck, Trockenfleisch, gepökeltes Fleisch',
reason: 'Können zur Bildung von Nitrosaminen beitragen; nitrit-/nitrathaltige Wurstwaren werden mit erhöhtem Darmkrebsrisiko in Verbindung gebracht. Die EU hat die Grenzwerte 2023 reduziert, um die Bildung krebserregender Nitrosamine zu senken.'
},
{
id: 'artificial-colors-child-attention',
label: 'Bestimmte künstliche Farbstoffe',
codes: [102, 104, 110, 122, 124, 129],
examples: 'Süssigkeiten, Softdrinks, Backwaren, Desserts',
reason: 'In der EU müssen Produkte mit diesen Farbstoffen den Hinweis tragen, dass sie Aktivität und Aufmerksamkeit von Kindern beeinträchtigen können. EFSA sah nur begrenzte, aber ernstzunehmende Hinweise auf kleine Effekte bei manchen Kindern.'
},
{
id: 'sulfites',
label: 'Sulfite',
codes: [[220, 228]],
examples: 'Wein, Trockenfrüchte, Kartoffelprodukte, Meerrettich, Fertigprodukte',
reason: 'Können bei empfindlichen Personen Asthma- oder Unverträglichkeitsreaktionen auslösen. EFSA zog 2022 die temporäre akzeptable Tagesdosis zurück und sah bei geschätzter Exposition Sicherheitsbedenken, weil die Datenlage unvollständig ist.'
},
{
id: 'phosphates',
label: 'Phosphate',
codes: [[338, 341], 343, [450, 452]],
examples: 'Schmelzkäse, Cola, Fleischwaren, Backwaren, Fertiggerichte',
reason: 'Phosphate sind nicht per se giftig, aber die Gesamtaufnahme kann zu hoch werden. EFSA schätzte 2019, dass die Aufnahme aus Lebensmitteln den sicheren Wert überschreiten kann, besonders bei Kindern und bei Nahrungsergänzungsmitteln.'
},
{
id: 'bha-bht',
label: 'BHA / BHT',
codes: [320, 321],
examples: 'Fetthaltige Snacks, Cerealien, Kaugummi, Backwaren, Fleisch- und Fertigprodukte',
reason: 'BHA ist wegen Tierstudien als möglicherweise krebserregend eingestuft worden; die FDA überprüft BHA 2026 erneut, obwohl es in den USA seit Jahrzehnten zugelassen ist. EFSA hält BHA/BHT innerhalb der ADI-Grenzen für verwendbar, daher ist das eher ein Vorsorge-Thema als ein akutes Giftproblem.'
},
{
id: 'benzoates',
label: 'Natriumbenzoat / Benzoate',
codes: [[210, 213]],
terms: ['benzoat', 'benzoate', 'benzoic acid', 'benzoesäure'],
examples: 'Softdrinks, Saucen, Konserven, saure Lebensmittel',
reason: 'Für sich genommen von EFSA nicht als krebserregend bewertet; kritisch sind Kombinationen mit bestimmten Farbstoffen bei Kindern und in Getränken mit Vitamin C, wo unter bestimmten Bedingungen Benzol entstehen kann. Die FDA fand in den meisten getesteten Getränken keine oder sehr niedrige Benzolwerte, aber der Mechanismus ist bekannt.'
},
{
id: 'aspartame',
label: 'Aspartam',
codes: [951],
terms: ['aspartam', 'aspartame'],
examples: 'Light-Getränke, zuckerfreie Kaugummis, Desserts, Protein- und Diätprodukte',
reason: 'IARC stufte Aspartam 2023 als möglicherweise krebserregend ein; JECFA bestätigte gleichzeitig die akzeptable Tagesdosis von 40 mg/kg Körpergewicht. Praktisch heisst das: nicht automatisch gefährlich, aber bei hohem Konsum ein Stoff, den viele vorsorglich reduzieren.'
},
{
id: 'carrageenan-emulsifiers',
label: 'Carrageen / ausgewählte Emulgatoren',
codes: [407, 433, 466],
terms: ['carrageen', 'carrageenan', 'polysorbat 80', 'polysorbate 80', 'carboxymethylcellulose', 'cmc'],
examples: 'Pflanzendrinks, Sahne-/Milchprodukte, Eiscreme, Saucen, Fertigprodukte',
reason: 'Die Evidenz ist weniger eindeutig als bei Nitriten: Tier-, Zell- und erste Humanstudien deuten auf mögliche Effekte auf Darmbarriere, Mikrobiom und Entzündung hin. EFSA hat Carrageen und CMC weiterhin bewertet, fordert aber teils zusätzliche Daten, vor allem für empfindliche Gruppen.'
},
{
id: 'titanium-dioxide',
label: 'Titandioxid / TiO2',
codes: [171],
terms: ['titandioxid', 'titanium dioxide', 'tio2', 'tio 2'],
examples: 'Früher: weisse Überzüge, Kaugummi, Süsswaren; ausserhalb EU/Schweiz auch Nahrungsergänzungsmittel und Süsswaren',
reason: 'In der Schweiz und EU seit 2022 als Lebensmittelzusatzstoff verboten, weil EFSA Genotoxizitätsbedenken nicht ausschliessen konnte. In den USA ist Titandioxid in Lebensmitteln bis 1 Prozent nach FDA-Regeln weiterhin erlaubt.'
},
{
id: 'red-3-erythrosine',
label: 'Red No. 3 / Erythrosin',
codes: [127],
terms: ['erythrosin', 'erythrosine', 'red no 3', 'red 3'],
examples: 'Vor allem US-Süsswaren, Dekore, einzelne Arznei- und Lebensmittelprodukte',
reason: 'Die FDA hat 2025 die Zulassung für Lebensmittel und oral eingenommene Arzneimittel widerrufen; Hersteller haben in den USA Übergangsfristen, Lebensmittel bis Januar 2027.'
}
];
function statusFromScore(score) {
if (score >= 78) return 'good';
if (score >= 48) return 'medium';
@@ -37,16 +115,17 @@ export function calculateProductRating(product, preferences) {
const categoryScores = {
allergies: rateAllergies(product, preferences),
budget: rateBudget(product, preferences),
ingredients: rateIngredients(product, preferences),
nutrition: rateNutrition(product, preferences),
sustainability: rateSustainability(product, preferences)
};
const weights = ratingWeights(preferences);
const overallScore = Math.round(
Object.entries(categoryScores).reduce((sum, [category, value]) => (
sum + value.score * weights[category]
), 0)
);
const hasBlockedAllergen = categoryScores.allergies.score === 0;
const criticalAdditiveCount = categoryScores.ingredients.criticalAdditiveCount || 0;
const overallScore = hasBlockedAllergen
? 0
: capOverallScoreForCriticalAdditives(weightedCategoryScore(categoryScores, weights), criticalAdditiveCount);
const overallStatus = statusFromScore(overallScore);
const reasons = Object.entries(categoryScores).map(([category, value]) => ({
@@ -67,12 +146,44 @@ export function calculateProductRating(product, preferences) {
function ratingWeights() {
return {
allergies: 0.1,
budget: 0.25,
nutrition: 0.4,
sustainability: 0.25
budget: 0.2,
ingredients: 0.15,
nutrition: 0.35,
sustainability: 0.2
};
}
function weightedCategoryScore(categoryScores, weights) {
const knownEntries = Object.entries(categoryScores).filter(([, value]) => value.hasKnownFacts !== false);
const totalWeight = knownEntries.reduce((sum, [category]) => sum + weights[category], 0);
if (totalWeight === 0) {
return 50;
}
return Math.round(
knownEntries.reduce((sum, [category, value]) => (
sum + value.score * weights[category]
), 0) / totalWeight
);
}
function capOverallScoreForCriticalAdditives(score, criticalAdditiveCount) {
if (criticalAdditiveCount >= 3) {
return Math.min(score, 35);
}
if (criticalAdditiveCount === 2) {
return Math.min(score, 42);
}
if (criticalAdditiveCount === 1) {
return Math.min(score, 47);
}
return score;
}
function rateAllergies(product, preferences) {
const avoidAllergens = preferences.avoidAllergens || [];
const matchingAllergens = (product.allergens || []).filter((allergen) =>
@@ -90,6 +201,82 @@ function rateAllergies(product, preferences) {
return withScore(completeness.allergens === false ? 70 : 100, 'Keine hinterlegten Allergene gefunden.', missingData);
}
function rateIngredients(product, preferences) {
const analysis = product.ingredientAnalysis || {};
const ingredients = ingredientTerms(product);
const additives = analysis.additives || [];
const harmfulAdditives = harmfulAdditiveMatches(additives, ingredients);
const completeness = product.dataCompleteness || {};
const hasIngredientText = ingredients.length > 0;
const palmOil = ingredientFlag(
analysis.palmOil,
ingredients.some((ingredient) => palmOilTerms().some((term) => ingredient.includes(term)))
);
const vegan = ingredientFlag(analysis.vegan, hasIngredientText ? !hasAnimalIngredient(ingredients, true) : undefined);
const vegetarian = ingredientFlag(analysis.vegetarian, hasIngredientText ? !hasAnimalIngredient(ingredients, false) : undefined);
const components = [
scoreComponent(palmOil === true ? 22 : palmOil === false ? 92 : null, 2),
scoreComponent(additiveScore(additives, completeness.additives, harmfulAdditives), harmfulAdditives.length > 0 ? 3 : 1)
];
const missingData = missingMessages([
[!hasIngredientText, 'Zutatenliste fehlt in Open Food Facts.'],
[palmOil === undefined, 'Palmöl-Analyse fehlt in Open Food Facts.'],
[preferences.preferredDiet === 'vegan' && vegan === undefined, 'Vegan-Analyse fehlt in Open Food Facts.'],
[preferences.preferredDiet === 'vegetarian' && vegetarian === undefined, 'Vegetarisch-Analyse fehlt in Open Food Facts.'],
[completeness.additives === false, 'Zusatzstoffangaben fehlen in Open Food Facts.']
]);
const scoreDetails = ingredientScoreDetails({
additives,
harmfulAdditives,
palmOil,
preferences,
vegan,
vegetarian
});
if (preferences.preferredDiet === 'vegan') {
components.push(scoreComponent(vegan === true ? 100 : vegan === false ? 0 : null, 3));
if (vegan === false) {
return withScore(cappedHarmfulAdditiveScore(weightedKnownScore(components, 50), harmfulAdditives), 'Nicht vegan laut Zutatenanalyse.', missingData, { hasKnownFacts: hasKnownComponents(components), criticalAdditiveCount: harmfulAdditives.length, details: scoreDetails });
}
}
if (preferences.preferredDiet === 'vegetarian') {
components.push(scoreComponent(vegetarian === true ? 100 : vegetarian === false ? 0 : null, 3));
if (vegetarian === false) {
return withScore(cappedHarmfulAdditiveScore(weightedKnownScore(components, 50), harmfulAdditives), 'Nicht vegetarisch laut Zutatenanalyse.', missingData, { hasKnownFacts: hasKnownComponents(components), criticalAdditiveCount: harmfulAdditives.length, details: scoreDetails });
}
}
const score = cappedHarmfulAdditiveScore(weightedKnownScore(components, 50), harmfulAdditives);
const hasKnownFacts = hasKnownComponents(components);
const ingredientOptions = { hasKnownFacts, criticalAdditiveCount: harmfulAdditives.length, details: scoreDetails };
if (palmOil === true) {
return withScore(score, 'Enthält Palmöl.', missingData, ingredientOptions);
}
if (harmfulAdditives.length > 0) {
return withScore(score, `Kritische Zusatzstoffe: ${formatAdditiveList(harmfulAdditives)}.`, missingData, ingredientOptions);
}
if (additives.length > 0) {
return withScore(score, `Enthält Zusatzstoffe: ${additives.slice(0, 3).join(', ')}.`, missingData, ingredientOptions);
}
if (preferences.preferredDiet === 'vegan' && vegan === true) {
return withScore(score, 'Vegan laut Zutatenanalyse.', missingData, ingredientOptions);
}
if (preferences.preferredDiet === 'vegetarian' && vegetarian === true) {
return withScore(score, 'Vegetarisch laut Zutatenanalyse.', missingData, ingredientOptions);
}
return withScore(score, 'Keine kritischen Zutatenhinweise gefunden.', missingData, ingredientOptions);
}
function rateNutrition(product, preferences) {
const nutrition = product.nutrition || {};
const nutrientLevels = nutrition.nutrientLevels || {};
@@ -104,74 +291,76 @@ function rateNutrition(product, preferences) {
const nutrientLevelStatus = statusFromNutrientLevels(highLevels, moderateLevels);
const nutrientLevelMessage = messageFromNutrientLevels(highLevels, moderateLevels);
const baseScore = nutritionScoreFromData(product, nutrition, nutrientLevels);
const details = nutritionScoreDetails(product, preferences, nutrition, nutrientLevels, baseScore);
const options = { details };
if (preferences.preferredDiet === 'lowSugar') {
const score = Math.min(baseScore, sugarScore(nutrition.sugarPer100g, nutrientLevels.sugars));
if (score < 48) {
return withScore(score, nutrientLevelMessage || 'Sehr hoher Zuckergehalt für dein Ziel.', missingData);
return withScore(score, nutrientLevelMessage || 'Sehr hoher Zuckergehalt für dein Ziel.', missingData, options);
}
if (score < 78) {
return withScore(score, nutrientLevelMessage || 'Zuckergehalt prüfen, liegt über deinem Ziel.', missingData);
return withScore(score, nutrientLevelMessage || 'Zuckergehalt prüfen, liegt über deinem Ziel.', missingData, options);
}
return withScore(score, nutrientLevelMessage || 'Zuckergehalt passt gut zu deinem Ziel.', missingData);
return withScore(score, nutrientLevelMessage || 'Zuckergehalt passt gut zu deinem Ziel.', missingData, options);
}
if (preferences.preferredDiet === 'highProtein') {
if (isBeverage(product)) {
return withScore(baseScore, nutrientLevelMessage || 'Getränk: Protein wird für diese Kategorie nicht abgewertet.', missingData);
return withScore(baseScore, nutrientLevelMessage || 'Getränk: Protein wird für diese Kategorie nicht abgewertet.', missingData, options);
}
const score = Math.min(baseScore, proteinScore(nutrition.proteinPer100g));
if (score < 78) {
return withScore(score, nutrientLevelMessage || 'Proteinanteil ist eher niedrig.', missingData);
return withScore(score, nutrientLevelMessage || 'Proteinanteil ist eher niedrig.', missingData, options);
}
return withScore(score, 'Passt gut zu deinem Protein-Ziel.', missingData);
}
if (preferences.preferredDiet === 'vegan' && (product.allergens || []).includes('Milch')) {
return withScore(Math.min(baseScore, 5), 'Enthält Milch und passt nicht zu vegan.', missingData);
return withScore(score, 'Passt gut zu deinem Protein-Ziel.', missingData, options);
}
if (nutrientLevelStatus !== 'good') {
return withScore(baseScore, nutrientLevelMessage, missingData);
return withScore(baseScore, nutrientLevelMessage, missingData, options);
}
if (['A', 'B'].includes(nutrition.nutriScore)) {
return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore} ist positiv.`, missingData);
return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore} ist positiv.`, missingData, options);
}
if (nutrition.nutriScore === 'C') {
return withScore(baseScore, 'Nutri-Score C: in Ordnung, aber nicht optimal.', missingData);
return withScore(baseScore, 'Nutri-Score C: in Ordnung, aber nicht optimal.', missingData, options);
}
return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.`, missingData);
return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.`, missingData, options);
}
function rateSustainability(product, preferences) {
const sustainability = product.sustainability || {};
const carbonFootprint = carbonFootprintValue(sustainability);
const missingData = missingMessages([
[!sustainability.ecoScore, 'Eco-Score fehlt in Open Food Facts.'],
[typeof carbonFootprint !== 'number', 'CO2-Fussabdruck fehlt in Open Food Facts.'],
[!sustainability.originCountry, 'Herkunftsland fehlt in Open Food Facts.'],
[!sustainability.packaging, 'Verpackungsangabe fehlt in Open Food Facts.']
]);
const score = sustainabilityScore(product, preferences);
const components = sustainabilityScoreComponents(product, preferences);
const score = weightedKnownScore(components, 50);
const options = { hasKnownFacts: hasKnownComponents(components) };
if (bioPreferenceApplies(product, preferences) && !sustainability.isBio) {
return withScore(score, 'Nicht Bio, obwohl Bio bevorzugt wird.', missingData);
return withScore(score, 'Nicht Bio, obwohl Bio bevorzugt wird.', missingData, options);
}
if (['A', 'B'].includes(sustainability.ecoScore)) {
return withScore(score, `Eco-Score ${sustainability.ecoScore} ist stark.`, missingData);
return withScore(score, `Eco-Score ${sustainability.ecoScore} ist stark.`, missingData, options);
}
if (sustainability.ecoScore === 'C') {
return withScore(score, 'Eco-Score C: akzeptabel, aber verbesserbar.', missingData);
return withScore(score, 'Eco-Score C: akzeptabel, aber verbesserbar.', missingData, options);
}
return withScore(score, `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.`, missingData);
return withScore(score, `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.`, missingData, options);
}
function rateBudget(product, preferences) {
@@ -218,6 +407,175 @@ function nutritionScoreFromData(product, nutrition, nutrientLevels) {
return clampScore(nutriScore - nutrientPenalty);
}
function nutritionScoreDetails(product, preferences, nutrition, nutrientLevels, baseScore) {
const details = [];
if (nutrition.nutriScore) {
const nutriScoreValue = nutriScoreValues[nutrition.nutriScore] ?? 50;
details.push({
type: nutriScoreValue >= 78 ? 'positive' : 'penalty',
title: `Nutri-Score ${nutrition.nutriScore}`,
body: nutriScoreValue >= 78
? 'Der Nutri-Score ist gut und erhöht die Ernährungswertung.'
: nutriScoreValue >= 48
? 'Der Nutri-Score ist mittelmässig und begrenzt die Ernährungswertung.'
: 'Der Nutri-Score ist schwach und reduziert die Ernährungswertung deutlich.',
meta: `Basiswert: ${nutriScoreValue} von 100`
});
}
nutritionNutrientDetails(nutrition, nutrientLevels).forEach((detail) => details.push(detail));
if (preferences.preferredDiet === 'lowSugar') {
details.push(sugarGoalDetail(nutrition.sugarPer100g, nutrientLevels.sugars));
}
if (preferences.preferredDiet === 'highProtein' && !isBeverage(product)) {
details.push(proteinGoalDetail(nutrition.proteinPer100g));
}
if (isWaterLike(product) && hasNoCriticalNutrients(nutrition, nutrientLevels)) {
details.push({
type: 'positive',
title: 'Wasserähnliches Produkt',
body: 'Wasserähnliche Produkte ohne kritische Nährwerte bekommen eine sehr gute Ernährungswertung.',
meta: 'Keine hohe Zucker-, Salz-, Fett- oder gesättigte-Fettsäuren-Markierung erkannt.'
});
} else if (isBeverage(product) && !nutrition.nutriScore && hasNoCriticalNutrients(nutrition, nutrientLevels)) {
details.push({
type: 'positive',
title: 'Getränk ohne kritische Nährwerte',
body: 'Bei Getränken ohne Nutri-Score wird nicht automatisch abgewertet, wenn keine kritischen Nährwerte vorliegen.',
meta: 'Ersatzwert: 86 von 100'
});
}
details.push({
type: baseScore >= 78 ? 'positive' : 'penalty',
title: 'Ernährungsbasis',
body: 'Die Basiswertung kombiniert Nutri-Score und Nährwert-Level. Hohe Level werden stärker abgezogen als mittlere Level.',
meta: `Aktueller Basiswert: ${Math.round(baseScore)} von 100`
});
return details;
}
function nutritionNutrientDetails(nutrition, nutrientLevels) {
const entries = [
{
key: 'sugars',
label: 'Zucker',
value: nutrition.sugarPer100g,
unit: 'g pro 100 g',
highText: 'Hoher Zuckerwert reduziert die Bewertung deutlich, besonders bei Low-Sugar-Ziel.',
moderateText: 'Mittlerer Zuckerwert reduziert die Bewertung leicht bis moderat.',
lowText: 'Niedriger Zuckerwert verbessert die Bewertung.'
},
{
key: 'fat',
label: 'Fett',
value: nutrition.fatPer100g,
unit: 'g pro 100 g',
highText: 'Hoher Fettwert reduziert die Bewertung.',
moderateText: 'Mittlerer Fettwert reduziert die Bewertung leicht.',
lowText: 'Niedriger Fettwert ist positiv.'
},
{
key: 'saturatedFat',
label: 'Gesättigte Fettsäuren',
value: nutrition.saturatedFatPer100g,
unit: 'g pro 100 g',
highText: 'Hohe gesättigte Fettsäuren reduzieren die Bewertung deutlich.',
moderateText: 'Mittlere gesättigte Fettsäuren reduzieren die Bewertung.',
lowText: 'Niedrige gesättigte Fettsäuren sind positiv.'
},
{
key: 'salt',
label: 'Salz',
value: nutrition.saltPer100g,
unit: 'g pro 100 g',
highText: 'Hoher Salzgehalt reduziert die Bewertung deutlich.',
moderateText: 'Mittlerer Salzgehalt reduziert die Bewertung.',
lowText: 'Niedriger Salzgehalt ist positiv.'
}
];
return entries
.filter((entry) => nutrientLevels[entry.key] || typeof entry.value === 'number')
.map((entry) => nutrientDetail(entry, nutrientLevels[entry.key]));
}
function nutrientDetail(entry, level) {
const type = level === 'high' || level === 'moderate' ? 'penalty' : 'positive';
const body = level === 'high'
? entry.highText
: level === 'moderate'
? entry.moderateText
: entry.lowText;
const metaParts = [];
if (level) {
metaParts.push(`Level: ${nutrientLevelLabel(level)}`);
}
if (typeof entry.value === 'number') {
metaParts.push(`Wert: ${formatNutritionValue(entry.value)} ${entry.unit}`);
}
return {
type,
title: entry.label,
body,
meta: metaParts.join(' · ')
};
}
function sugarGoalDetail(sugarPer100g, sugarLevel) {
const score = sugarScore(sugarPer100g, sugarLevel);
return {
type: score >= 78 ? 'positive' : 'penalty',
title: 'Low-Sugar-Ziel',
body: score >= 78
? 'Der Zuckerwert passt zu deinem Low-Sugar-Ziel.'
: score >= 48
? 'Der Zuckerwert liegt über dem idealen Bereich für dein Low-Sugar-Ziel.'
: 'Der Zuckerwert ist für dein Low-Sugar-Ziel klar zu hoch.',
meta: typeof sugarPer100g === 'number'
? `Zucker: ${formatNutritionValue(sugarPer100g)} g pro 100 g`
: 'Zuckerwert fehlt.'
};
}
function proteinGoalDetail(proteinPer100g) {
const score = proteinScore(proteinPer100g);
return {
type: score >= 78 ? 'positive' : 'penalty',
title: 'Protein-Ziel',
body: score >= 78
? 'Der Proteinanteil passt gut zu deinem High-Protein-Ziel.'
: 'Der Proteinanteil ist für dein High-Protein-Ziel eher niedrig.',
meta: typeof proteinPer100g === 'number'
? `Protein: ${formatNutritionValue(proteinPer100g)} g pro 100 g`
: 'Proteinwert fehlt.'
};
}
function nutrientLevelLabel(level) {
const labels = {
low: 'niedrig',
moderate: 'mittel',
high: 'hoch'
};
return labels[level] || level;
}
function formatNutritionValue(value) {
return Number.isInteger(value) ? String(value) : String(Math.round(value * 10) / 10);
}
function sugarScore(sugarPer100g, sugarLevel) {
if (sugarLevel === 'high') {
return 25;
@@ -248,23 +606,139 @@ function proteinScore(proteinPer100g) {
return interpolate(Math.max(proteinPer100g, 0), 0, 4, 20, 45);
}
function additiveScore(additives, additivesKnown, harmfulAdditives = []) {
if (!additivesKnown) {
return null;
}
if (harmfulAdditives.length >= 3) {
return 0;
}
if (harmfulAdditives.length === 2) {
return 8;
}
if (harmfulAdditives.length === 1) {
return 18;
}
if (additives.length === 0) {
return 95;
}
if (additives.length <= 2) {
return 72;
}
if (additives.length <= 5) {
return 45;
}
return 22;
}
function cappedHarmfulAdditiveScore(score, harmfulAdditives) {
if (harmfulAdditives.length >= 3) {
return Math.min(score, 8);
}
if (harmfulAdditives.length === 2) {
return Math.min(score, 14);
}
if (harmfulAdditives.length === 1) {
return Math.min(score, 24);
}
return score;
}
function ingredientScoreDetails({ additives, harmfulAdditives, palmOil, preferences, vegan, vegetarian }) {
const details = harmfulAdditives.map((additive) => ({
type: 'penalty',
title: `Harter Abzug: ${additive.label}`,
body: additive.reason,
meta: `Typische Produkte: ${additive.examples}`
}));
if (harmfulAdditives.length > 0) {
details.push({
type: 'penalty',
title: 'Deckel für Gesamtbewertung',
body: 'Kritische Zusatzstoffe begrenzen nicht nur die Zutatenwertung, sondern auch den Gesamtscore, damit gute Werte in anderen Kategorien diese Stoffe nicht überdecken.',
meta: harmfulAdditives.length >= 3 ? 'Maximaler Gesamtscore: 35' : harmfulAdditives.length === 2 ? 'Maximaler Gesamtscore: 42' : 'Maximaler Gesamtscore: 47'
});
}
if (palmOil === true) {
details.push({
type: 'penalty',
title: 'Palmöl erkannt',
body: 'Palmöl belastet die Zutatenwertung, weil es häufig mit Entwaldung, Biodiversitätsverlust und problematischen Lieferketten verbunden ist.',
meta: 'Zutatenwertung wird deutlich reduziert.'
});
} else if (palmOil === false) {
details.push({
type: 'positive',
title: 'Kein Palmöl erkannt',
body: 'Die Zutatenanalyse oder Zutatenliste enthält keinen Hinweis auf Palmöl.',
meta: 'Erhöht die Zutatenwertung.'
});
}
if (additives.length > 0 && harmfulAdditives.length === 0) {
details.push({
type: 'penalty',
title: 'Zusatzstoffe vorhanden',
body: 'Zusatzstoffe ohne kritische Markierung werden nach Anzahl moderat abgewertet.',
meta: `${additives.length} Zusatzstoff${additives.length === 1 ? '' : 'e'} erkannt.`
});
}
if (preferences.preferredDiet === 'vegan') {
details.push({
type: vegan === true ? 'positive' : 'penalty',
title: vegan === true ? 'Vegan kompatibel' : 'Nicht vegan oder unklar',
body: vegan === true
? 'Die Zutatenanalyse passt zum veganen Ziel.'
: 'Tierische Bestandteile oder eine fehlende Vegan-Bestätigung belasten die Zutatenwertung für dein Profil.',
meta: 'Relevant wegen deinem Profilziel: Vegan.'
});
}
if (preferences.preferredDiet === 'vegetarian') {
details.push({
type: vegetarian === true ? 'positive' : 'penalty',
title: vegetarian === true ? 'Vegetarisch kompatibel' : 'Nicht vegetarisch oder unklar',
body: vegetarian === true
? 'Die Zutatenanalyse passt zum vegetarischen Ziel.'
: 'Fleisch-/Fischbestandteile oder eine fehlende Bestätigung belasten die Zutatenwertung für dein Profil.',
meta: 'Relevant wegen deinem Profilziel: Vegetarisch.'
});
}
return details;
}
function sustainabilityScore(product, preferences) {
return weightedKnownScore(sustainabilityScoreComponents(product, preferences), 50);
}
function sustainabilityScoreComponents(product, preferences) {
const sustainability = product.sustainability || {};
let score = ecoScoreValues[sustainability.ecoScore] ?? 50;
const components = [
scoreComponent(ecoScoreValues[sustainability.ecoScore], 3),
scoreComponent(carbonFootprintScore(carbonFootprintValue(sustainability)), 2),
scoreComponent(originScore(sustainability.originCountry), 1),
scoreComponent(packagingScore(sustainability.packaging), 1)
];
if (bioPreferenceApplies(product, preferences) && !sustainability.isBio) {
score -= 18;
if (bioPreferenceApplies(product, preferences)) {
components.push(scoreComponent(sustainability.isBio ? 92 : 32, 1));
}
if (!sustainability.originCountry) {
score -= 6;
}
if (!sustainability.packaging) {
score -= 6;
}
return clampScore(score);
return components;
}
function budgetScore(share) {
@@ -328,13 +802,14 @@ function budgetPeriodLabel(period) {
return labels[period] || labels.week;
}
function withScore(score, message, missingData = []) {
function withScore(score, message, missingData = [], options = {}) {
const roundedScore = Math.round(clampScore(score));
return {
status: statusFromScore(roundedScore),
score: roundedScore,
message,
missingData
missingData,
...options
};
}
@@ -355,6 +830,263 @@ function bioPreferenceApplies(product, preferences) {
return preferences.preferBio && !isBeverage(product);
}
function scoreComponent(score, weight) {
if (typeof score !== 'number') {
return null;
}
return { score, weight };
}
function weightedKnownScore(components, fallbackScore) {
const knownComponents = components.filter(Boolean);
const totalWeight = knownComponents.reduce((sum, component) => sum + component.weight, 0);
if (totalWeight === 0) {
return fallbackScore;
}
const weightedScore = knownComponents.reduce(
(sum, component) => sum + component.score * component.weight,
0
);
return clampScore(weightedScore / totalWeight);
}
function hasKnownComponents(components) {
return components.some(Boolean);
}
function carbonFootprintValue(sustainability) {
if (typeof sustainability.carbonFootprintKgCo2ePerKg === 'number') {
return sustainability.carbonFootprintKgCo2ePerKg;
}
if (typeof sustainability.carbonFootprintKgCo2ePer100g === 'number') {
return sustainability.carbonFootprintKgCo2ePer100g * 10;
}
if (typeof sustainability.carbonFootprintGCo2ePerKg === 'number') {
return sustainability.carbonFootprintGCo2ePerKg / 1000;
}
if (typeof sustainability.carbonFootprintGCo2ePer100g === 'number') {
return sustainability.carbonFootprintGCo2ePer100g / 100;
}
if (typeof sustainability.carbonFootprint === 'number') {
return sustainability.carbonFootprint;
}
return undefined;
}
function carbonFootprintScore(carbonFootprint) {
if (typeof carbonFootprint !== 'number') {
return null;
}
if (carbonFootprint <= 0.5) return 100;
if (carbonFootprint <= 1.5) return interpolate(carbonFootprint, 0.5, 1.5, 100, 78);
if (carbonFootprint <= 4) return interpolate(carbonFootprint, 1.5, 4, 78, 35);
return clampScore(interpolate(Math.min(carbonFootprint, 10), 4, 10, 35, 5));
}
function originScore(originCountry) {
const origin = normalizeTerm(originCountry);
if (!origin) {
return null;
}
if (['schweiz', 'switzerland', 'suisse', 'svizzera'].some((term) => origin.includes(term))) {
return 92;
}
if (['deutschland', 'germany', 'france', 'frankreich', 'italy', 'italien', 'austria', 'österreich'].some((term) => origin.includes(term))) {
return 78;
}
if (['european union', 'eu', 'europa', 'europe'].some((term) => origin.includes(term))) {
return 68;
}
return 35;
}
function packagingScore(packaging) {
const value = normalizeTerm(packaging);
if (!value) {
return null;
}
if (['recycling', 'recycled', 'recyclable', 'mehrweg', 'returnable', 'pfand', 'glass', 'glas', 'paper', 'papier', 'cardboard', 'karton'].some((term) => value.includes(term))) {
return 88;
}
if (['pet', 'tetra pak', 'carton', 'becher'].some((term) => value.includes(term))) {
return 58;
}
if (['plastic', 'plastik', 'kunststoff', 'aluminium', 'aluminum', 'alu', 'dose', 'can'].some((term) => value.includes(term))) {
return 25;
}
return 55;
}
function harmfulAdditiveMatches(additives, ingredients) {
const additiveTexts = [
...additives,
...ingredients
];
const matches = additiveTexts.flatMap((text) => harmfulAdditivesInText(text));
const uniqueByGroup = new Map(matches.map((match) => [match.id, match]));
return Array.from(uniqueByGroup.values());
}
function harmfulAdditivesInText(text) {
const normalizedText = normalizeTerm(text);
const matches = [];
const codeMatches = normalizedText.matchAll(/\be\s*[- ]?(\d{3,4})([a-z]*)\b/g);
for (const match of codeMatches) {
const code = Number(match[1]);
const additiveCode = `E${match[1]}${match[2] || ''}`.toUpperCase();
const riskGroup = additiveRiskGroupForCode(code);
if (riskGroup) {
matches.push(additiveRiskMatch(riskGroup, additiveCode));
}
}
additiveRiskGroups.forEach((riskGroup) => {
const hasMatchingTerm = (riskGroup.terms || []).some((term) => normalizedText.includes(term));
if (hasMatchingTerm) {
matches.push(additiveRiskMatch(riskGroup, additiveCodeLabel(riskGroup)));
}
});
return matches;
}
function additiveRiskGroupForCode(code) {
return additiveRiskGroups.find((riskGroup) =>
riskGroup.codes.some((entry) => (
Array.isArray(entry)
? code >= entry[0] && code <= entry[1]
: code === entry
))
);
}
function additiveRiskMatch(riskGroup, matchedCode) {
return {
id: riskGroup.id,
label: `${matchedCode} ${riskGroup.label}`,
matchedCode,
group: riskGroup.label,
examples: riskGroup.examples,
reason: riskGroup.reason
};
}
function additiveCodeLabel(riskGroup) {
return riskGroup.codes
.map((entry) => (Array.isArray(entry) ? `E${entry[0]}-E${entry[1]}` : `E${entry}`))
.join(', ');
}
function formatAdditiveList(additives) {
const visibleAdditives = additives
.map((additive) => additive.label)
.slice(0, 5)
.join(', ');
const remainingCount = additives.length - 5;
return remainingCount > 0
? `${visibleAdditives} und ${remainingCount} weitere`
: visibleAdditives;
}
function ingredientFlag(tagValue, fallbackValue) {
if (typeof tagValue === 'boolean') {
return tagValue;
}
if (typeof fallbackValue === 'boolean') {
return fallbackValue;
}
return undefined;
}
function ingredientTerms(product) {
return (product.ingredients || [])
.map(normalizeTerm)
.filter(Boolean);
}
function palmOilTerms() {
return [
'palm oil',
'palmöl',
'palmol',
'palmfett',
'palmkern',
'palm kernel',
'huile de palme',
'olio di palma'
];
}
function hasAnimalIngredient(ingredients, includeDairyAndEggs) {
const alwaysAnimalTerms = [
'beef',
'pork',
'chicken',
'fish',
'meat',
'gelatine',
'gelatin',
'schwein',
'rind',
'huhn',
'fisch',
'fleisch',
'gelatine'
];
const veganOnlyTerms = [
'milk',
'milch',
'butter',
'cream',
'sahne',
'cheese',
'käse',
'kase',
'egg',
'eggs',
'ei',
'eier',
'honey',
'honig',
'lactose',
'laktose',
'whey',
'molke'
];
const blockedTerms = includeDairyAndEggs
? [...alwaysAnimalTerms, ...veganOnlyTerms]
: alwaysAnimalTerms;
return ingredients.some((ingredient) =>
blockedTerms.some((term) => ingredient.includes(term))
);
}
function isWaterLike(product) {
const terms = productTerms(product);
return terms.some((term) =>
@@ -0,0 +1,68 @@
const dailyLimitRules = [
{
key: 'caffeine',
label: 'Koffein',
limit: 200,
unit: 'mg',
nutritionKey: 'caffeineMgPer100g',
severity: 'critical',
message: 'Mehr Koffein als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
},
{
key: 'sugar',
label: 'Zucker',
limit: 50,
unit: 'g',
nutritionKey: 'sugarPer100g',
severity: 'warning',
message: 'Mehr Zucker als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
},
{
key: 'salt',
label: 'Salz',
limit: 5,
unit: 'g',
nutritionKey: 'saltPer100g',
severity: 'warning',
message: 'Mehr Salz als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
}
];
export function calculateShoppingListWarnings(shoppingList) {
return dailyLimitRules
.map((rule) => {
const total = totalNutrientAmount(shoppingList.items || [], rule);
return {
...rule,
total,
percentage: Math.round((total / rule.limit) * 100)
};
})
.filter((warning) => warning.total > warning.limit);
}
function totalNutrientAmount(items, rule) {
return items.reduce((sum, item) => {
const per100g = item.nutrition?.[rule.nutritionKey];
const quantityGrams = itemQuantityGrams(item);
if (typeof per100g !== 'number' || typeof quantityGrams !== 'number') {
return sum;
}
return sum + (per100g * quantityGrams * item.quantity) / 100;
}, 0);
}
function itemQuantityGrams(item) {
if (typeof item.packageQuantityGrams === 'number' && item.packageQuantityGrams > 0) {
return item.packageQuantityGrams;
}
if (typeof item.servingQuantityGrams === 'number' && item.servingQuantityGrams > 0) {
return item.servingQuantityGrams;
}
return 100;
}