diff --git a/App.js b/App.js index b5221fa..1dcac06 100644 --- a/App.js +++ b/App.js @@ -29,6 +29,7 @@ export default function App() { const [preferences, setPreferences] = useState(defaultPreferences); const [shoppingList, setShoppingList] = useState(initialShoppingList); const [productCache, setProductCache] = useState({}); + const [manualPrices, setManualPrices] = useState({}); const [recentProducts, setRecentProducts] = useState([]); const [isHydrated, setIsHydrated] = useState(false); @@ -40,11 +41,13 @@ export default function App() { storedPreferences, storedShoppingList, storedProductCache, + storedManualPrices, storedRecentProducts ] = await Promise.all([ storageService.loadPreferences(defaultPreferences), storageService.loadShoppingList(initialShoppingList), storageService.loadProductCache(), + storageService.loadManualPrices(), storageService.loadRecentProducts() ]); @@ -52,6 +55,7 @@ export default function App() { setPreferences(storedPreferences); setShoppingList(storedShoppingList); setProductCache(storedProductCache); + setManualPrices(storedManualPrices); setRecentProducts(storedRecentProducts); setIsHydrated(true); } @@ -75,6 +79,10 @@ export default function App() { if (isHydrated) storageService.saveProductCache(productCache); }, [isHydrated, productCache]); + useEffect(() => { + if (isHydrated) storageService.saveManualPrices(manualPrices); + }, [isHydrated, manualPrices]); + useEffect(() => { if (isHydrated) storageService.saveRecentProducts(recentProducts); }, [isHydrated, recentProducts]); @@ -100,6 +108,8 @@ export default function App() { onDeleteItem={deleteListItem} onStartScan={() => setActiveTab('scan')} onToggleItem={toggleListItem} + onUpdateItemPrice={updateListItemPrice} + preferences={preferences} shoppingList={shoppingList} /> ); @@ -122,7 +132,7 @@ export default function App() { recentProducts={recentProducts} /> ); - }, [activeTab, preferences, productCache, recentProducts, shoppingList]); + }, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]); async function scanProductByBarcode(barcode) { const result = await lookupProductByBarcode( @@ -132,18 +142,18 @@ export default function App() { ); if (result.status === 'found') { - openProduct(result.product); - addProductToCache(result.product); - addRecentProduct(result.product); + const pricedProduct = applyManualPrice(result.product); + openProduct(pricedProduct); } return result; } function openProduct(product) { - addProductToCache(product); - addRecentProduct(product); - setRoute({ name: 'productDetail', product }); + const pricedProduct = applyManualPrice(product); + addProductToCache(pricedProduct); + addRecentProduct(pricedProduct); + setRoute({ name: 'productDetail', product: pricedProduct }); } function showAlternatives(product) { @@ -160,17 +170,73 @@ export default function App() { function addRecentProduct(product) { setRecentProducts((current) => { const withoutDuplicate = current.filter((item) => item.id !== product.id); - return [product, ...withoutDuplicate].slice(0, 5); + const previous = current.find((item) => item.id === product.id); + const scanCount = (previous?.scanCount || 0) + 1; + return [{ ...product, scanCount, lastScannedAt: new Date().toISOString() }, ...withoutDuplicate].slice(0, 8); }); } + function applyManualPrice(product) { + const manualPrice = manualPrices[priceKey(product)]; + + if (typeof manualPrice !== 'number') { + return product; + } + + return { + ...product, + currency: product.currency || preferences.currency, + price: manualPrice + }; + } + + function saveManualPriceForProduct(product, price) { + const key = priceKey(product); + + setManualPrices((current) => ({ + ...current, + [key]: price + })); + setProductCache((current) => { + const cachedProduct = current[product.barcode]; + + if (!cachedProduct) { + return current; + } + + return { + ...current, + [product.barcode]: { + ...cachedProduct, + currency: product.currency || preferences.currency, + price, + updatedAt: new Date().toISOString() + } + }; + }); + setRecentProducts((current) => + current.map((item) => + item.id === product.id + ? { ...item, currency: product.currency || preferences.currency, price } + : item + ) + ); + } + function addProductToShoppingList(product) { setShoppingList((current) => { const existingItem = current.items.find((item) => item.productId === product.id); const items = existingItem ? current.items.map((item) => item.productId === product.id - ? { ...item, quantity: item.quantity + 1 } + ? { + ...item, + quantity: item.quantity + 1, + packageQuantityGrams: item.packageQuantityGrams ?? product.packageQuantityGrams, + packageQuantityLabel: item.packageQuantityLabel ?? product.packageQuantityLabel, + servingQuantityGrams: item.servingQuantityGrams ?? product.servingQuantityGrams, + nutrition: item.nutrition ?? product.nutrition + } : item ) : [ @@ -181,6 +247,12 @@ export default function App() { productName: product.name, quantity: 1, estimatedPrice: product.price, + currency: product.currency || current.currency, + barcode: product.barcode, + packageQuantityGrams: product.packageQuantityGrams, + packageQuantityLabel: product.packageQuantityLabel, + servingQuantityGrams: product.servingQuantityGrams, + nutrition: product.nutrition, checked: false, addedAt: new Date().toISOString() } @@ -195,6 +267,30 @@ export default function App() { }); } + function updateListItemPrice(itemId, price) { + const itemToUpdate = shoppingList.items.find((item) => item.id === itemId); + + if (itemToUpdate) { + saveManualPriceForProduct({ + id: itemToUpdate.productId, + barcode: itemToUpdate.barcode, + currency: itemToUpdate.currency || shoppingList.currency + }, price); + } + + setShoppingList((current) => { + const items = current.items.map((item) => + item.id === itemId ? { ...item, estimatedPrice: price } : item + ); + + return { + ...current, + estimatedTotal: calculateEstimatedTotal(items), + items + }; + }); + } + function toggleListItem(itemId) { setShoppingList((current) => ({ ...current, @@ -240,6 +336,7 @@ export default function App() { { + setPriceText(priceToInputValue(item.estimatedPrice)); + }, [item.estimatedPrice]); + + function savePrice() { + const parsedPrice = parsePrice(priceText); + + if (parsedPrice === undefined) { + setPriceError('Preis prüfen'); + return; + } + + setPriceError(''); + onUpdatePrice(item.id, parsedPrice); + } + return ( @@ -11,7 +31,25 @@ export default function ShoppingListItem({ item, onToggle, onDelete }) { {item.productName} - Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')} + Menge {item.quantity} · {formatPrice(item.estimatedPrice, item.currency || 'CHF')} + + {item.currency || 'CHF'} + { + setPriceText(value); + setPriceError(''); + }} + onSubmitEditing={savePrice} + placeholder="0.00" + placeholderTextColor={colors.muted} + style={styles.priceInput} + value={priceText} + /> + + {priceError ? {priceError} : null} Löschen @@ -20,6 +58,21 @@ export default function ShoppingListItem({ item, onToggle, onDelete }) { ); } +function priceToInputValue(price) { + return typeof price === 'number' ? price.toFixed(2) : ''; +} + +function parsePrice(value) { + const normalizedValue = value.trim().replace(',', '.'); + const parsedValue = Number(normalizedValue); + + if (!normalizedValue || !Number.isFinite(parsedValue) || parsedValue < 0) { + return undefined; + } + + return Math.round(parsedValue * 100) / 100; +} + const styles = StyleSheet.create({ row: { alignItems: 'center', @@ -63,6 +116,33 @@ const styles = StyleSheet.create({ fontSize: 13, marginTop: 3 }, + priceRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + marginTop: 10 + }, + currency: { + color: colors.muted, + fontSize: 13, + fontWeight: '800' + }, + priceInput: { + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + color: colors.text, + fontSize: 14, + minWidth: 92, + paddingHorizontal: 10, + paddingVertical: 7 + }, + errorText: { + color: colors.danger, + fontSize: 12, + fontWeight: '800', + marginTop: 4 + }, deleteButton: { paddingHorizontal: 8, paddingVertical: 6 diff --git a/src/components/product/ProductCard.js b/src/components/product/ProductCard.js index b76db16..f6d8bd1 100644 --- a/src/components/product/ProductCard.js +++ b/src/components/product/ProductCard.js @@ -9,7 +9,7 @@ export default function ProductCard({ product, rating, onPress }) { return ( - + {product.name} @@ -18,6 +18,7 @@ export default function ProductCard({ product, rating, onPress }) { {product.category || 'Produkt'} · Barcode {product.barcode} + {sourceLabel(product.dataSource)} {rating ? : null} @@ -25,6 +26,16 @@ export default function ProductCard({ product, rating, onPress }) { ); } +function sourceLabel(source) { + const labels = { + cache: 'Lokaler Cache', + local: 'Lokale Beispieldaten', + 'open-food-facts': 'Open Food Facts' + }; + + return labels[source] || 'Quelle unbekannt'; +} + const styles = StyleSheet.create({ card: { backgroundColor: colors.surface, @@ -57,5 +68,16 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 18, marginTop: 8 + }, + source: { + alignSelf: 'flex-start', + backgroundColor: '#eef3ef', + borderRadius: 8, + color: colors.primaryDark, + fontSize: 12, + fontWeight: '800', + marginTop: 8, + paddingHorizontal: 8, + paddingVertical: 4 } }); diff --git a/src/components/product/ProductImage.js b/src/components/product/ProductImage.js index 58fc5bf..4b4a477 100644 --- a/src/components/product/ProductImage.js +++ b/src/components/product/ProductImage.js @@ -2,13 +2,15 @@ import { Image, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../utils/constants'; -export default function ProductImage({ imageUrl, size = 'card' }) { +export default function ProductImage({ imageUrl, productName, size = 'card' }) { const style = size === 'large' ? styles.large : styles.card; if (!imageUrl) { return ( - Kein Bild + + {initialsFromName(productName)} + ); } @@ -23,6 +25,15 @@ export default function ProductImage({ imageUrl, size = 'card' }) { ); } +function initialsFromName(name) { + const words = String(name || 'Produkt') + .split(/\s+/) + .map((word) => word.trim()) + .filter(Boolean); + + return words.slice(0, 2).map((word) => word[0].toUpperCase()).join(''); +} + const styles = StyleSheet.create({ image: { backgroundColor: colors.surface, @@ -48,9 +59,12 @@ const styles = StyleSheet.create({ justifyContent: 'center' }, placeholderText: { - color: colors.muted, - fontSize: 12, + color: colors.primaryDark, + fontSize: 22, fontWeight: '800', textAlign: 'center' + }, + largePlaceholderText: { + fontSize: 54 } }); diff --git a/src/components/product/RatingCategoryRow.js b/src/components/product/RatingCategoryRow.js index 52a3f14..1ed40ad 100644 --- a/src/components/product/RatingCategoryRow.js +++ b/src/components/product/RatingCategoryRow.js @@ -16,8 +16,11 @@ export default function RatingCategoryRow({ category, rating }) { {labels[category] || category} {rating.message} + {(rating.missingData || []).map((message) => ( + {message} + ))} - + ); } @@ -46,5 +49,12 @@ const styles = StyleSheet.create({ color: colors.muted, fontSize: 14, lineHeight: 20 + }, + missingData: { + color: colors.warning, + fontSize: 13, + fontWeight: '700', + lineHeight: 19, + marginTop: 4 } }); diff --git a/src/components/scan/BarcodeScannerView.js b/src/components/scan/BarcodeScannerView.js index 85d7cb3..d9296a7 100644 --- a/src/components/scan/BarcodeScannerView.js +++ b/src/components/scan/BarcodeScannerView.js @@ -3,7 +3,7 @@ import { StyleSheet, Text, View } from 'react-native'; import { colors } from '../../utils/constants'; -export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) { +export default function BarcodeScannerView({ isLoadingProduct, isScanning, onBarcodeScanned }) { return ( - Barcode in den Rahmen halten. Bei Problemen manuell suchen. + {isLoadingProduct ? 'Produkt wird geladen...' : 'Barcode in den Rahmen halten. Bei Problemen manuell suchen.'} diff --git a/src/data/mockProducts.js b/src/data/mockProducts.js deleted file mode 100644 index 17d1551..0000000 --- a/src/data/mockProducts.js +++ /dev/null @@ -1,110 +0,0 @@ -export const mockProducts = [ - { - id: 'p-001', - barcode: '7610200336707', - name: 'Bio Haferdrink', - brand: 'Shoppy Natur', - category: 'Getränke', - price: 2.8, - currency: 'CHF', - ingredients: ['Hafer', 'Wasser', 'Sonnenblumenöl', 'Meersalz'], - allergens: ['Hafer'], - nutrition: { - caloriesPer100g: 46, - sugarPer100g: 3.2, - fatPer100g: 1.5, - proteinPer100g: 1.1, - saltPer100g: 0.1, - nutriScore: 'B' - }, - sustainability: { - ecoScore: 'A', - isBio: true, - originCountry: 'Schweiz', - packaging: 'Tetra Pak' - }, - createdAt: '2026-07-01T08:00:00.000Z', - updatedAt: '2026-07-01T08:00:00.000Z' - }, - { - id: 'p-002', - barcode: '7612345001112', - name: 'Erdnuss Schoko-Riegel', - brand: 'SnackFit', - category: 'Snacks', - price: 1.9, - currency: 'CHF', - ingredients: ['Erdnüsse', 'Zucker', 'Kakao', 'Milchpulver', 'Palmöl'], - allergens: ['Erdnüsse', 'Milch'], - nutrition: { - caloriesPer100g: 520, - sugarPer100g: 36, - fatPer100g: 31, - proteinPer100g: 14, - saltPer100g: 0.4, - nutriScore: 'D' - }, - sustainability: { - ecoScore: 'D', - isBio: false, - originCountry: 'Deutschland', - packaging: 'Kunststoff' - }, - createdAt: '2026-07-01T08:00:00.000Z', - updatedAt: '2026-07-01T08:00:00.000Z' - }, - { - id: 'p-003', - barcode: '7612345002225', - name: 'Protein Joghurt Beeren', - brand: 'Alpina', - category: 'Milchprodukte', - price: 2.4, - currency: 'CHF', - ingredients: ['Joghurt', 'Beeren', 'Milchprotein', 'Süßungsmittel'], - allergens: ['Milch'], - nutrition: { - caloriesPer100g: 82, - sugarPer100g: 5.1, - fatPer100g: 0.5, - proteinPer100g: 10.5, - saltPer100g: 0.12, - nutriScore: 'A' - }, - sustainability: { - ecoScore: 'B', - isBio: false, - originCountry: 'Schweiz', - packaging: 'Becher' - }, - createdAt: '2026-07-01T08:00:00.000Z', - updatedAt: '2026-07-01T08:00:00.000Z' - }, - { - id: 'p-004', - barcode: '7612345003338', - name: 'Vegane Linsen-Bowl', - brand: 'GreenMeal', - category: 'Fertiggericht', - price: 6.5, - currency: 'CHF', - ingredients: ['Linsen', 'Reis', 'Karotten', 'Spinat', 'Rapsöl'], - allergens: [], - nutrition: { - caloriesPer100g: 148, - sugarPer100g: 2.4, - fatPer100g: 4.2, - proteinPer100g: 7.8, - saltPer100g: 0.7, - nutriScore: 'A' - }, - sustainability: { - ecoScore: 'A', - isBio: true, - originCountry: 'Schweiz', - packaging: 'Recycling-Schale' - }, - createdAt: '2026-07-01T08:00:00.000Z', - updatedAt: '2026-07-01T08:00:00.000Z' - } -]; diff --git a/src/screens/AlternativesScreen.js b/src/screens/AlternativesScreen.js index a953d94..8c47632 100644 --- a/src/screens/AlternativesScreen.js +++ b/src/screens/AlternativesScreen.js @@ -7,6 +7,8 @@ import AlternativeProductCard from '../components/product/AlternativeProductCard import { findAlternatives } from '../services/alternativesService'; import { colors } from '../utils/constants'; +const DISPLAY_ALTERNATIVE_LIMIT = 5; + export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) { const [alternatives, setAlternatives] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -41,11 +43,11 @@ export default function AlternativesScreen({ product, preferences, productCache, {!isLoading && alternatives.length === 0 ? ( Keine Alternative gefunden - Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag. + Für dieses Produkt wurde in ähnlichen Kategorien noch keine klar bessere Option gefunden. ) : null} - {alternatives.map((item) => ( + {alternatives.slice(0, DISPLAY_ALTERNATIVE_LIMIT).map((item) => ( (product.scanCount || 0) > 1) + .sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)) + .slice(0, 3); + return ( + {frequentProducts.length > 0 ? ( + <> + Häufig gekauft + {frequentProducts.map((product) => ( + + onOpenProduct(product)} + /> + {product.scanCount} Scans + + ))} + + ) : null} + Letzte Scans {recentProducts.length === 0 ? ( @@ -71,6 +92,16 @@ const styles = StyleSheet.create({ fontSize: 16, lineHeight: 23 }, + frequentWrap: { + gap: 6 + }, + scanCount: { + alignSelf: 'flex-start', + color: colors.primary, + fontSize: 12, + fontWeight: '800', + marginLeft: 12 + }, emptyState: { backgroundColor: colors.surface, borderColor: colors.border, diff --git a/src/screens/ProductDetailScreen.js b/src/screens/ProductDetailScreen.js index 62bff3b..137e78d 100644 --- a/src/screens/ProductDetailScreen.js +++ b/src/screens/ProductDetailScreen.js @@ -13,7 +13,14 @@ import { calculateProductRating } from '../services/ratingService'; import { colors } from '../utils/constants'; import { formatPrice } from '../utils/formatPrice'; -export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) { +export default function ProductDetailScreen({ + product, + preferences, + onBack, + onAddToList, + onSaveManualPrice, + onShowAlternatives +}) { const [savedMessage, setSavedMessage] = useState(''); const [manualPrice, setManualPrice] = useState(''); const [priceError, setPriceError] = useState(''); @@ -34,6 +41,10 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd return; } + if (typeof pricedProduct.price === 'number') { + onSaveManualPrice(pricedProduct, pricedProduct.price); + } + onAddToList(pricedProduct); setPriceError(''); setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.'); @@ -44,7 +55,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd - + {product.brand || 'Ohne Marke'} @@ -52,6 +63,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd {product.category || 'Produkt'} · {formatPrice(pricedProduct.price, currency)} + {sourceLabel(product.dataSource)} @@ -116,6 +128,16 @@ function parseManualPrice(value) { return Math.round(parsedValue * 100) / 100; } +function sourceLabel(source) { + const labels = { + cache: 'Quelle: lokaler Cache', + local: 'Quelle: lokale Beispieldaten', + 'open-food-facts': 'Quelle: Open Food Facts' + }; + + return labels[source] || 'Quelle: unbekannt'; +} + const styles = StyleSheet.create({ productPanel: { backgroundColor: colors.surface, @@ -151,6 +173,17 @@ const styles = StyleSheet.create({ fontSize: 15, marginTop: 6 }, + source: { + alignSelf: 'flex-start', + backgroundColor: '#eef3ef', + borderRadius: 8, + color: colors.primaryDark, + fontSize: 12, + fontWeight: '800', + marginTop: 10, + paddingHorizontal: 8, + paddingVertical: 4 + }, summary: { color: colors.text, fontSize: 16, diff --git a/src/screens/ScanScreen.js b/src/screens/ScanScreen.js index 125bbcc..e1cf6ce 100644 --- a/src/screens/ScanScreen.js +++ b/src/screens/ScanScreen.js @@ -18,6 +18,7 @@ import { colors } from '../utils/constants'; export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) { const [permission, requestPermission] = useCameraPermissions(); const [isScanning, setIsScanning] = useState(true); + const [isLoadingProduct, setIsLoadingProduct] = useState(false); const [scanError, setScanError] = useState(''); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [manualSearchQuery, setManualSearchQuery] = useState(''); @@ -28,7 +29,10 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o if (!isScanning) return; setIsScanning(false); - const result = await onScanBarcode(event.data); + setIsLoadingProduct(true); + const result = await onScanBarcode(event.data).finally(() => { + setIsLoadingProduct(false); + }); if (result?.status !== 'found') { setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.'); @@ -70,7 +74,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o {hasCameraAccess ? ( <> - + setIsScanning((current) => !current)} @@ -82,6 +86,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen. + {isLoadingProduct ? Produkt wird geladen... : null} handleBarcodeScanned({ data: '3017620422003' })} /> handleBarcodeScanned({ data: '737628064502' })} /> diff --git a/src/screens/ShoppingListScreen.js b/src/screens/ShoppingListScreen.js index bb34f96..2d32028 100644 --- a/src/screens/ShoppingListScreen.js +++ b/src/screens/ShoppingListScreen.js @@ -4,11 +4,24 @@ import SecondaryButton from '../components/SecondaryButton'; import AppHeader from '../components/layout/AppHeader'; import ScreenContainer from '../components/layout/ScreenContainer'; import ShoppingListItem from '../components/list/ShoppingListItem'; -import { colors } from '../utils/constants'; +import { calculateShoppingListWarnings } from '../services/shoppingListWarningsService'; +import { budgetPeriodLabels, colors } from '../utils/constants'; import { formatPrice } from '../utils/formatPrice'; -export default function ShoppingListScreen({ shoppingList, onToggleItem, onDeleteItem, onStartScan, onCompleteList }) { +export default function ShoppingListScreen({ + preferences, + shoppingList, + onToggleItem, + onDeleteItem, + onUpdateItemPrice, + onStartScan, + onCompleteList +}) { const openItems = shoppingList.items.filter((item) => !item.checked).length; + const budget = preferences.maxBudgetPerShoppingTrip; + const budgetShare = typeof budget === 'number' && budget > 0 ? shoppingList.estimatedTotal / budget : 0; + const budgetStatus = budgetShare <= 0.75 ? 'good' : budgetShare <= 1 ? 'medium' : 'critical'; + const dailyWarnings = calculateShoppingListWarnings(shoppingList); return ( @@ -22,8 +35,47 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet Produkte abhaken, löschen oder nach dem Einkauf abschließen. + + + Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'} + + {formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} von {formatPrice(budget, shoppingList.currency)} + + + + + + + {dailyWarnings.length > 0 ? ( + + {dailyWarnings.map((warning) => ( + + + {warning.label}: {formatAmount(warning.total, warning.unit)} von {formatAmount(warning.limit, warning.unit)} + + {warning.message} + + ))} + + ) : null} + {shoppingList.items.length === 0 ? ( Die Liste ist leer @@ -37,6 +89,7 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet key={item.id} onDelete={() => onDeleteItem(item.id)} onToggle={() => onToggleItem(item.id)} + onUpdatePrice={onUpdateItemPrice} /> )) )} @@ -48,6 +101,14 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet ); } +function formatAmount(value, unit) { + const roundedValue = unit === 'mg' + ? Math.round(value) + : Math.round(value * 10) / 10; + + return `${roundedValue} ${unit}`; +} + const styles = StyleSheet.create({ summary: { backgroundColor: colors.surface, @@ -67,6 +128,77 @@ const styles = StyleSheet.create({ lineHeight: 20, marginTop: 4 }, + budgetBox: { + marginTop: 14 + }, + budgetHeader: { + alignItems: 'flex-start', + gap: 4, + justifyContent: 'space-between' + }, + budgetLabel: { + color: colors.muted, + fontSize: 13, + fontWeight: '800' + }, + budgetValue: { + fontSize: 15, + fontWeight: '800' + }, + progressTrack: { + backgroundColor: '#edf1ee', + borderRadius: 999, + height: 10, + marginTop: 10, + overflow: 'hidden' + }, + progressFill: { + borderRadius: 999, + height: '100%' + }, + good: { + color: colors.success + }, + medium: { + color: colors.warning + }, + critical: { + color: colors.danger + }, + goodFill: { + backgroundColor: colors.success + }, + mediumFill: { + backgroundColor: colors.warning + }, + criticalFill: { + backgroundColor: colors.danger + }, + warningList: { + gap: 10 + }, + warningBox: { + backgroundColor: colors.surface, + borderRadius: 8, + borderWidth: 1, + padding: 14 + }, + mediumWarningBox: { + borderColor: colors.warning + }, + criticalWarningBox: { + borderColor: colors.danger + }, + warningTitle: { + fontSize: 15, + fontWeight: '800' + }, + warningCopy: { + color: colors.text, + fontSize: 14, + lineHeight: 20, + marginTop: 4 + }, empty: { backgroundColor: colors.surface, borderColor: colors.border, diff --git a/src/services/alternativesService.js b/src/services/alternativesService.js index 86fb61b..16cf4cf 100644 --- a/src/services/alternativesService.js +++ b/src/services/alternativesService.js @@ -1,33 +1,234 @@ -import { mockProducts } from '../data/mockProducts'; +import { searchProducts } from './productApi'; import { calculateProductRating } from './ratingService'; -export async function findAlternatives(product, preferences, productCache = {}) { - const candidates = [...mockProducts, ...Object.values(productCache)] - .filter((candidate) => candidate.id !== product.id) - .filter((candidate) => candidate.category === product.category || candidate.price <= (product.price || 999)); +const MIN_SCORE_IMPROVEMENT = 5; +const INTERNAL_ALTERNATIVE_LIMIT = 8; - return Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values()) +const improvementLabels = { + allergies: 'Besser passend bei Allergien', + budget: 'Günstiger fürs Budget', + nutrition: 'Bessere Ernährung', + sustainability: 'Nachhaltiger' +}; + +const categoryGroups = [ + { + label: 'Getränke', + terms: ['beverage', 'beverages', 'drink', 'drinks', 'getränk', 'getränke', 'soda', 'cola', 'juice', 'water', 'waters', 'non alcoholic', 'sodas'] + }, + { + label: 'Snacks', + terms: ['snack', 'snacks', 'bar', 'riegel', 'chocolate', 'chips', 'crisps'] + }, + { + label: 'Milchprodukte', + terms: ['dairy', 'milk', 'yogurt', 'joghurt', 'milch', 'milchprodukte', 'cheese'] + }, + { + label: 'Fertiggerichte', + terms: ['meal', 'meals', 'ready meal', 'bowl', 'fertiggericht', 'prepared'] + } +]; + +export async function findAlternatives(product, preferences, productCache = {}) { + const productRating = calculateProductRating(product, preferences); + const categoryTerms = categoryTermsForProduct(product); + const remoteCandidates = await loadRemoteCandidates(categoryTerms, productCache, preferences.offlineModeEnabled); + const candidates = uniqueProducts([ + ...remoteCandidates, + ...Object.values(productCache) + ]) + .filter((candidate) => candidate.id !== product.id && candidate.barcode !== product.barcode) + .filter((candidate) => isSimilarCategory(product, candidate, categoryTerms)) + .filter((candidate) => !hasAvoidedAllergen(candidate, preferences)); + + return candidates .map((candidate) => ({ product: candidate, rating: calculateProductRating(candidate, preferences), - reason: buildReason(candidate, preferences) + dataQualityScore: knownDataScore(candidate) })) - .sort((left, right) => right.rating.overallScore - left.rating.overallScore) - .slice(0, 3); + .filter((item) => item.rating.overallScore >= productRating.overallScore + MIN_SCORE_IMPROVEMENT) + .map((item) => ({ + ...item, + reason: buildReason(item.rating, productRating, categoryTerms) + })) + .sort((left, right) => { + if (right.rating.overallScore !== left.rating.overallScore) { + return right.rating.overallScore - left.rating.overallScore; + } + + const priceDiff = priceForSort(left.product) - priceForSort(right.product); + if (priceDiff !== 0) { + return priceDiff; + } + + return right.dataQualityScore - left.dataQualityScore; + }) + .filter(uniqueByProductName) + .slice(0, INTERNAL_ALTERNATIVE_LIMIT); } -function buildReason(product, preferences) { - if (preferences.preferBio && product.sustainability?.isBio) { - return 'Bio-Alternative mit besserem Nachhaltigkeitsprofil.'; - } +async function loadRemoteCandidates(categoryTerms, productCache, offlineModeEnabled) { + const queries = categoryTerms.slice(0, 3); + const results = await Promise.all( + queries.map((query) => searchProducts(query, productCache, offlineModeEnabled).catch(() => [])) + ); - if (preferences.preferredDiet === 'lowSugar' && (product.nutrition?.sugarPer100g || 0) <= 6) { - return 'Weniger Zucker und dadurch besser passend zu deinem Ziel.'; - } - - if ((product.allergens || []).length === 0) { - return 'Keine bekannten Allergene im Beispieldatensatz.'; - } - - return 'Bessere Gesamtbewertung nach deinen Präferenzen.'; + return results.flat(); +} + +function categoryTermsForProduct(product) { + const rawTerms = [ + product.category, + ...(product.categoryTags || []) + ] + .map(normalizeTerm) + .filter(Boolean); + const matchingGroup = categoryGroups.find((group) => + rawTerms.some((term) => group.terms.some((groupTerm) => term.includes(groupTerm))) + ); + + return uniqueStrings([ + ...(matchingGroup?.terms || []), + ...rawTerms + ]); +} + +function isSimilarCategory(sourceProduct, candidate, categoryTerms) { + const sourceTerms = productTerms(sourceProduct); + const candidateTerms = productTerms(candidate); + + if (candidateTerms.some((term) => sourceTerms.includes(term))) { + return true; + } + + return candidateTerms.some((term) => + categoryTerms.some((categoryTerm) => term.includes(categoryTerm) || categoryTerm.includes(term)) + ); +} + +function productTerms(product) { + return [ + product.category, + ...(product.categoryTags || []) + ] + .map(normalizeTerm) + .filter(Boolean); +} + +function buildReason(rating, sourceRating, categoryTerms) { + const strongestImprovement = strongestCategoryImprovement(rating, sourceRating); + const scoreText = `besserer Bewertung (${rating.overallScore} statt ${sourceRating.overallScore})`; + const categoryLabel = categoryLabelForTerms(categoryTerms); + const improvementText = strongestImprovement + ? improvementLabels[strongestImprovement.category] || 'Bessere Bewertung' + : 'Bessere Bewertung'; + + if (categoryLabel) { + return `${improvementText}: ähnliches Produkt aus ${categoryLabel} mit ${scoreText}.`; + } + + return `${improvementText}: ähnliches Produkt mit ${scoreText}.`; +} + +function categoryLabelForTerms(terms) { + const matchingGroup = categoryGroups.find((group) => + terms.some((term) => group.terms.some((groupTerm) => term.includes(groupTerm))) + ); + + return matchingGroup?.label; +} + +function knownDataScore(product) { + const nutrition = product.nutrition || {}; + const sustainability = product.sustainability || {}; + const completeness = product.dataCompleteness || {}; + + return [ + [hasKnownAllergens(product), 3], + [completeness.allergens !== false, 2], + [Boolean(nutrition.nutriScore), 2], + [hasNutrientLevels(nutrition), 2], + [completeness.nutrition !== false, 1], + [Boolean(sustainability.ecoScore), 2], + [completeness.ecoScore !== false, 1], + [Boolean(sustainability.originCountry), 1], + [Boolean(sustainability.packaging), 1], + [typeof product.price === 'number', 1], + [Boolean(product.imageUrl), 1] + ].reduce((score, [isKnown, weight]) => score + (isKnown ? weight : 0), 0); +} + +function hasAvoidedAllergen(product, preferences) { + const avoidAllergens = (preferences.avoidAllergens || []).map(normalizeAllergen); + if (avoidAllergens.length === 0) { + return false; + } + + const productAllergens = (product.allergens || []).map(normalizeAllergen); + return productAllergens.some((allergen) => avoidAllergens.includes(allergen)); +} + +function strongestCategoryImprovement(rating, sourceRating) { + return Object.keys(rating.categoryScores || {}) + .map((category) => ({ + category, + improvement: categoryScore(rating, category) - categoryScore(sourceRating, category) + })) + .filter((item) => item.improvement > 0) + .sort((left, right) => right.improvement - left.improvement)[0]; +} + +function categoryScore(rating, category) { + return rating.categoryScores?.[category]?.score || 0; +} + +function priceForSort(product) { + return typeof product.price === 'number' ? product.price : Infinity; +} + +function hasKnownAllergens(product) { + return Array.isArray(product.allergens) && product.dataCompleteness?.allergens !== false; +} + +function hasNutrientLevels(nutrition) { + const nutrientLevels = nutrition.nutrientLevels || {}; + return Object.values(nutrientLevels).some(Boolean); +} + +function uniqueProducts(products) { + return Array.from(new Map(products.map((product) => [product.barcode || product.id, product])).values()); +} + +function uniqueByProductName(item, index, items) { + const normalizedName = normalizeProductName(item.product.name); + if (!normalizedName) { + return true; + } + + return items.findIndex((candidate) => normalizeProductName(candidate.product.name) === normalizedName) === index; +} + +function uniqueStrings(values) { + return Array.from(new Set(values)); +} + +function normalizeProductName(value) { + return normalizeTerm(value) + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeTerm(value) { + return String(value || '') + .toLowerCase() + .replace(/^[a-z]{2}:/, '') + .replace(/-/g, ' ') + .trim(); +} + +function normalizeAllergen(value) { + return normalizeTerm(value); } diff --git a/src/services/productApi.js b/src/services/productApi.js index 8775544..0398ad8 100644 --- a/src/services/productApi.js +++ b/src/services/productApi.js @@ -1,5 +1,3 @@ -import { mockProducts } from '../data/mockProducts'; - const openFoodFactsBaseUrl = 'https://world.openfoodfacts.org'; const productFields = [ 'code', @@ -10,11 +8,15 @@ const productFields = [ 'categories', 'categories_tags', 'image_url', + 'product_quantity', + 'quantity', + 'serving_quantity', 'ingredients_text', 'ingredients_text_de', 'ingredients_text_en', 'allergens_tags', 'nutriments', + 'nutrient_levels', 'nutriscore_grade', 'ecoscore_grade', 'labels_tags', @@ -29,7 +31,7 @@ export async function lookupProductByBarcode(barcode, productCache = {}, offline const cachedProduct = productCache[normalizedBarcode] || productCache[barcode]; if (cachedProduct) { - return { status: 'found', product: cachedProduct, source: 'cache' }; + return { status: 'found', product: { ...cachedProduct, dataSource: 'cache' }, source: 'cache' }; } if (offlineModeEnabled) { @@ -81,7 +83,7 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable } if (offlineModeEnabled) { - return filterProducts(cachedProducts, normalizedQuery); + return filterProducts(markProductsSource(cachedProducts, 'cache'), normalizedQuery); } try { @@ -90,18 +92,24 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable ); if (!response.ok) { - return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); + return filterProducts([ + ...markProductsSource(cachedProducts, 'cache') + ], normalizedQuery); } const payload = await response.json(); const apiProducts = (payload.products || []) .filter((product) => product.code) .map((product) => mapOpenFoodFactsProduct(product, product.code)); - const products = [...apiProducts, ...cachedProducts, ...mockProducts]; - return uniqueProducts(filterProducts(products, normalizedQuery)); + return uniqueProducts(filterProducts([ + ...apiProducts, + ...markProductsSource(cachedProducts, 'cache') + ], normalizedQuery)); } catch (error) { - return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); + return filterProducts([ + ...markProductsSource(cachedProducts, 'cache') + ], normalizedQuery); } } @@ -113,10 +121,15 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) { return { id: `off-${barcode}`, barcode, + dataSource: 'open-food-facts', name: product.product_name_de || product.product_name || product.product_name_en || `Produkt ${barcode}`, brand: firstTextValue(product.brands), category: firstCategory(product.categories, product.categories_tags), + categoryTags: normalizeTags(product.categories_tags || []), imageUrl: product.image_url, + packageQuantityGrams: numberOrUndefined(product.product_quantity), + packageQuantityLabel: firstTextValue(product.quantity), + servingQuantityGrams: numberOrUndefined(product.serving_quantity), price: undefined, currency: 'CHF', ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en), @@ -124,9 +137,11 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) { nutrition: { caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']), sugarPer100g: numberOrUndefined(nutriments.sugars_100g), + caffeineMgPer100g: normalizeCaffeineMg(nutriments.caffeine_100g), fatPer100g: numberOrUndefined(nutriments.fat_100g), proteinPer100g: numberOrUndefined(nutriments.proteins_100g), saltPer100g: numberOrUndefined(nutriments.salt_100g), + nutrientLevels: normalizeNutrientLevels(product.nutrient_levels), nutriScore: normalizeGrade(product.nutriscore_grade) }, sustainability: { @@ -135,11 +150,25 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) { originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags), packaging: firstTextValue(product.packaging) }, + dataCompleteness: { + allergens: Array.isArray(product.allergens_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)), + nutrition: Boolean(product.nutriments) + }, createdAt: now, updatedAt: now }; } +function markProductsSource(products, source) { + return products.map((product) => ({ + ...product, + dataSource: product.dataSource || source + })); +} + function normalizeBarcode(barcode) { return String(barcode || '').replace(/[^\d]/g, ''); } @@ -214,11 +243,31 @@ function normalizeAllergens(allergenTags) { .filter(Boolean); } +function normalizeTags(tags) { + return tags + .map((tag) => String(tag || '').trim().toLowerCase()) + .filter(Boolean); +} + function normalizeGrade(value) { const grade = String(value || '').trim().toUpperCase(); return ['A', 'B', 'C', 'D', 'E'].includes(grade) ? grade : undefined; } +function normalizeNutrientLevels(levels = {}) { + return { + fat: normalizeNutrientLevel(levels.fat), + saturatedFat: normalizeNutrientLevel(levels['saturated-fat']), + sugars: normalizeNutrientLevel(levels.sugars), + salt: normalizeNutrientLevel(levels.salt) + }; +} + +function normalizeNutrientLevel(value) { + const level = String(value || '').trim().toLowerCase(); + return ['low', 'moderate', 'high'].includes(level) ? level : undefined; +} + function hasOrganicLabel(labelTags) { return labelTags.some((tag) => { const normalizedTag = String(tag).toLowerCase(); @@ -231,6 +280,15 @@ function numberOrUndefined(value) { return Number.isFinite(numberValue) ? numberValue : undefined; } +function normalizeCaffeineMg(value) { + const numberValue = numberOrUndefined(value); + if (numberValue === undefined) { + return undefined; + } + + return numberValue <= 2 ? Math.round(numberValue * 1000) : numberValue; +} + function cleanTag(tag) { if (!tag) return undefined; const withoutLanguage = String(tag).replace(/^[a-z]{2}:/, ''); diff --git a/src/services/ratingService.js b/src/services/ratingService.js index 399b373..18117f9 100644 --- a/src/services/ratingService.js +++ b/src/services/ratingService.js @@ -1,14 +1,25 @@ -const scoreMap = { - good: 25, - medium: 14, - critical: 0 +const nutrientLevelLabels = { + fat: 'Fett', + saturatedFat: 'gesättigte Fettsäuren', + sugars: 'Zucker', + salt: 'Salz' }; -function priorityWeight(priority) { - if (priority === 'high') return 1.2; - if (priority === 'low') return 0.8; - return 1; -} +const nutriScoreValues = { + A: 100, + B: 86, + C: 65, + D: 35, + E: 15 +}; + +const ecoScoreValues = { + A: 100, + B: 86, + C: 65, + D: 35, + E: 15 +}; function statusFromScore(score) { if (score >= 78) return 'good'; @@ -23,40 +34,19 @@ function severityFromStatus(status) { } export function calculateProductRating(product, preferences) { - const avoidAllergens = preferences.avoidAllergens || []; - const matchingAllergens = (product.allergens || []).filter((allergen) => - avoidAllergens.some((blockedAllergen) => blockedAllergen.toLowerCase() === allergen.toLowerCase()) - ); - - const allergies = matchingAllergens.length > 0 - ? { - status: 'critical', - message: `Enthält: ${matchingAllergens.join(', ')}.` - } - : { - status: 'good', - message: 'Keine hinterlegten Allergene gefunden.' - }; - - const nutrition = rateNutrition(product, preferences); - const sustainability = rateSustainability(product, preferences); - const budget = rateBudget(product, preferences); - const categoryScores = { - allergies, - budget, - nutrition, - sustainability + allergies: rateAllergies(product, preferences), + budget: rateBudget(product, preferences), + nutrition: rateNutrition(product, preferences), + sustainability: rateSustainability(product, preferences) }; - const weightedScores = [ - scoreMap[allergies.status] * 1.35, - scoreMap[budget.status] * 1, - scoreMap[nutrition.status] * priorityWeight(preferences.nutritionPriority), - scoreMap[sustainability.status] * priorityWeight(preferences.sustainabilityPriority) - ]; - const maxScore = 25 * (1.35 + 1 + priorityWeight(preferences.nutritionPriority) + priorityWeight(preferences.sustainabilityPriority)); - const overallScore = Math.round((weightedScores.reduce((sum, value) => sum + value, 0) / maxScore) * 100); + const weights = ratingWeights(preferences); + const overallScore = Math.round( + Object.entries(categoryScores).reduce((sum, [category, value]) => ( + sum + value.score * weights[category] + ), 0) + ); const overallStatus = statusFromScore(overallScore); const reasons = Object.entries(categoryScores).map(([category, value]) => ({ @@ -74,77 +64,257 @@ export function calculateProductRating(product, preferences) { }; } +function ratingWeights() { + return { + allergies: 0.1, + budget: 0.25, + nutrition: 0.4, + sustainability: 0.25 + }; +} + +function rateAllergies(product, preferences) { + const avoidAllergens = preferences.avoidAllergens || []; + const matchingAllergens = (product.allergens || []).filter((allergen) => + avoidAllergens.some((blockedAllergen) => blockedAllergen.toLowerCase() === allergen.toLowerCase()) + ); + const completeness = product.dataCompleteness || {}; + const missingData = missingMessages([ + [completeness.allergens === false, 'Allergenangaben fehlen in Open Food Facts.'] + ]); + + if (matchingAllergens.length > 0) { + return withScore(0, `Enthält: ${matchingAllergens.join(', ')}.`, missingData); + } + + return withScore(completeness.allergens === false ? 70 : 100, 'Keine hinterlegten Allergene gefunden.', missingData); +} + function rateNutrition(product, preferences) { const nutrition = product.nutrition || {}; + const nutrientLevels = nutrition.nutrientLevels || {}; + const highLevels = nutrientLevelEntries(nutrientLevels, 'high'); + const moderateLevels = nutrientLevelEntries(nutrientLevels, 'moderate'); + const missingData = missingMessages([ + [!nutrition.nutriScore, 'Nutri-Score fehlt in Open Food Facts.'], + [Object.keys(nutrientLevels).length === 0 || !Object.values(nutrientLevels).some(Boolean), 'Nährwert-Level fehlen in Open Food Facts.'], + [preferences.preferredDiet === 'lowSugar' && typeof nutrition.sugarPer100g !== 'number', 'Zuckerwert pro 100 g fehlt.'], + [preferences.preferredDiet === 'highProtein' && !isBeverage(product) && typeof nutrition.proteinPer100g !== 'number', 'Proteinwert pro 100 g fehlt.'] + ]); + const nutrientLevelStatus = statusFromNutrientLevels(highLevels, moderateLevels); + const nutrientLevelMessage = messageFromNutrientLevels(highLevels, moderateLevels); + const baseScore = nutritionScoreFromData(product, nutrition, nutrientLevels); if (preferences.preferredDiet === 'lowSugar') { - if ((nutrition.sugarPer100g || 0) > 22) { - return { status: 'critical', message: 'Sehr hoher Zuckergehalt für dein Ziel.' }; + 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); } - if ((nutrition.sugarPer100g || 0) > 8) { - return { status: 'medium', message: 'Zuckergehalt prüfen, liegt über deinem Ziel.' }; + if (score < 78) { + return withScore(score, nutrientLevelMessage || 'Zuckergehalt prüfen, liegt über deinem Ziel.', missingData); } + + return withScore(score, nutrientLevelMessage || 'Zuckergehalt passt gut zu deinem Ziel.', missingData); } if (preferences.preferredDiet === 'highProtein') { - if ((nutrition.proteinPer100g || 0) >= 10) { - return { status: 'good', message: 'Passt gut zu deinem Protein-Ziel.' }; + if (isBeverage(product)) { + return withScore(baseScore, nutrientLevelMessage || 'Getränk: Protein wird für diese Kategorie nicht abgewertet.', missingData); } - return { status: 'medium', message: 'Proteinanteil ist eher niedrig.' }; + const score = Math.min(baseScore, proteinScore(nutrition.proteinPer100g)); + if (score < 78) { + return withScore(score, nutrientLevelMessage || 'Proteinanteil ist eher niedrig.', missingData); + } + + return withScore(score, 'Passt gut zu deinem Protein-Ziel.', missingData); } if (preferences.preferredDiet === 'vegan' && (product.allergens || []).includes('Milch')) { - return { status: 'critical', message: 'Enthält Milch und passt nicht zu vegan.' }; + return withScore(Math.min(baseScore, 5), 'Enthält Milch und passt nicht zu vegan.', missingData); + } + + if (nutrientLevelStatus !== 'good') { + return withScore(baseScore, nutrientLevelMessage, missingData); } if (['A', 'B'].includes(nutrition.nutriScore)) { - return { status: 'good', message: `Nutri-Score ${nutrition.nutriScore} ist positiv.` }; + return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore} ist positiv.`, missingData); } if (nutrition.nutriScore === 'C') { - return { status: 'medium', message: 'Nutri-Score C: in Ordnung, aber nicht optimal.' }; + return withScore(baseScore, 'Nutri-Score C: in Ordnung, aber nicht optimal.', missingData); } - return { status: 'critical', message: `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.` }; + return withScore(baseScore, `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.`, missingData); } function rateSustainability(product, preferences) { const sustainability = product.sustainability || {}; + const missingData = missingMessages([ + [!sustainability.ecoScore, 'Eco-Score 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); - if (preferences.preferBio && !sustainability.isBio) { - return { status: 'medium', message: 'Nicht Bio, obwohl Bio bevorzugt wird.' }; + if (bioPreferenceApplies(product, preferences) && !sustainability.isBio) { + return withScore(score, 'Nicht Bio, obwohl Bio bevorzugt wird.', missingData); } if (['A', 'B'].includes(sustainability.ecoScore)) { - return { status: 'good', message: `Eco-Score ${sustainability.ecoScore} ist stark.` }; + return withScore(score, `Eco-Score ${sustainability.ecoScore} ist stark.`, missingData); } if (sustainability.ecoScore === 'C') { - return { status: 'medium', message: 'Eco-Score C: akzeptabel, aber verbesserbar.' }; + return withScore(score, 'Eco-Score C: akzeptabel, aber verbesserbar.', missingData); } - return { status: 'critical', message: `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.` }; + return withScore(score, `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.`, missingData); } function rateBudget(product, preferences) { + const missingData = missingMessages([ + [typeof product.price !== 'number', 'Produktpreis fehlt.'], + [typeof preferences.maxBudgetPerShoppingTrip !== 'number', 'Budgetlimit fehlt im Profil.'] + ]); + if (typeof product.price !== 'number' || typeof preferences.maxBudgetPerShoppingTrip !== 'number') { - return { status: 'medium', message: 'Preis oder Budget fehlt für eine genaue Prüfung.' }; + return withScore(55, 'Preis oder Budget fehlt für eine genaue Prüfung.', missingData); } const share = product.price / preferences.maxBudgetPerShoppingTrip; const periodLabel = budgetPeriodLabel(preferences.budgetPeriod); + const score = budgetScore(share); if (share <= 0.08) { - return { status: 'good', message: `Preis passt gut zu deinem Budget pro ${periodLabel}.` }; + return withScore(score, `Preis passt gut zu deinem Budget pro ${periodLabel}.`, missingData); } if (share <= 0.18) { - return { status: 'medium', message: `Preis ist für dein Budget pro ${periodLabel} spürbar, aber noch im Rahmen.` }; + return withScore(score, `Preis ist für dein Budget pro ${periodLabel} spürbar, aber noch im Rahmen.`, missingData); } - return { status: 'critical', message: `Preis belastet dein Budget pro ${periodLabel} stark.` }; + return withScore(score, `Preis belastet dein Budget pro ${periodLabel} stark.`, missingData); +} + +function nutritionScoreFromData(product, nutrition, nutrientLevels) { + if (isWaterLike(product) && hasNoCriticalNutrients(nutrition, nutrientLevels)) { + return 98; + } + + if (isBeverage(product) && !nutrition.nutriScore && hasNoCriticalNutrients(nutrition, nutrientLevels)) { + return 86; + } + + const nutriScore = nutriScoreValues[nutrition.nutriScore] ?? 50; + const nutrientPenalty = Object.values(nutrientLevels || {}).reduce((penalty, level) => { + if (level === 'high') return penalty + 18; + if (level === 'moderate') return penalty + 8; + return penalty; + }, 0); + + return clampScore(nutriScore - nutrientPenalty); +} + +function sugarScore(sugarPer100g, sugarLevel) { + if (sugarLevel === 'high') { + return 25; + } + + if (sugarLevel === 'moderate') { + return 62; + } + + if (typeof sugarPer100g !== 'number') { + return 55; + } + + if (sugarPer100g <= 5) return 100; + if (sugarPer100g <= 8) return interpolate(sugarPer100g, 5, 8, 100, 78); + if (sugarPer100g <= 22) return interpolate(sugarPer100g, 8, 22, 78, 35); + return clampScore(interpolate(Math.min(sugarPer100g, 45), 22, 45, 35, 5)); +} + +function proteinScore(proteinPer100g) { + if (typeof proteinPer100g !== 'number') { + return 55; + } + + if (proteinPer100g >= 20) return 100; + if (proteinPer100g >= 10) return interpolate(proteinPer100g, 10, 20, 78, 100); + if (proteinPer100g >= 4) return interpolate(proteinPer100g, 4, 10, 45, 78); + return interpolate(Math.max(proteinPer100g, 0), 0, 4, 20, 45); +} + +function sustainabilityScore(product, preferences) { + const sustainability = product.sustainability || {}; + let score = ecoScoreValues[sustainability.ecoScore] ?? 50; + + if (bioPreferenceApplies(product, preferences) && !sustainability.isBio) { + score -= 18; + } + + if (!sustainability.originCountry) { + score -= 6; + } + + if (!sustainability.packaging) { + score -= 6; + } + + return clampScore(score); +} + +function budgetScore(share) { + if (share <= 0.08) { + return interpolate(share, 0, 0.08, 100, 86); + } + + if (share <= 0.18) { + return interpolate(share, 0.08, 0.18, 86, 55); + } + + return clampScore(interpolate(Math.min(share, 0.4), 0.18, 0.4, 55, 0)); +} + +function statusFromNutrientLevels(highLevels, moderateLevels) { + if (highLevels.length >= 2 || highLevels.includes('sugars') || highLevels.includes('salt')) { + return 'critical'; + } + + if (highLevels.length > 0 || moderateLevels.length >= 2) { + return 'medium'; + } + + return 'good'; +} + +function messageFromNutrientLevels(highLevels, moderateLevels) { + if (highLevels.length > 0) { + return `Hohe Nährwert-Level: ${formatNutrientLevelList(highLevels)}.`; + } + + if (moderateLevels.length >= 2) { + return `Mehrere mittlere Nährwert-Level: ${formatNutrientLevelList(moderateLevels)}.`; + } + + if (moderateLevels.length === 1) { + return `Mittleres Nährwert-Level bei ${formatNutrientLevelList(moderateLevels)}.`; + } + + return ''; +} + +function nutrientLevelEntries(levels, expectedLevel) { + return Object.entries(levels) + .filter(([, level]) => level === expectedLevel) + .map(([key]) => key); +} + +function formatNutrientLevelList(keys) { + return keys.map((key) => nutrientLevelLabels[key] || key).join(', '); } function budgetPeriodLabel(period) { @@ -157,3 +327,84 @@ function budgetPeriodLabel(period) { return labels[period] || labels.week; } + +function withScore(score, message, missingData = []) { + const roundedScore = Math.round(clampScore(score)); + return { + status: statusFromScore(roundedScore), + score: roundedScore, + message, + missingData + }; +} + +function interpolate(value, inputMin, inputMax, outputMin, outputMax) { + if (inputMax === inputMin) { + return outputMax; + } + + const ratio = (value - inputMin) / (inputMax - inputMin); + return outputMin + ratio * (outputMax - outputMin); +} + +function clampScore(score) { + return Math.max(0, Math.min(100, score)); +} + +function bioPreferenceApplies(product, preferences) { + return preferences.preferBio && !isBeverage(product); +} + +function isWaterLike(product) { + const terms = productTerms(product); + return terms.some((term) => + ['water', 'waters', 'wasser', 'mineral water', 'sparkling water', 'still water', 'eau'].includes(term) + || term.includes('mineralwasser') + ); +} + +function isBeverage(product) { + const terms = productTerms(product); + return terms.some((term) => + ['beverage', 'beverages', 'drink', 'drinks', 'getränk', 'getränke', 'water', 'waters', 'wasser', 'juice', 'soda', 'cola', 'tea', 'coffee'].includes(term) + || term.includes('beverage') + || term.includes('drink') + || term.includes('getränk') + ); +} + +function hasNoCriticalNutrients(nutrition, nutrientLevels) { + const sugar = nutrition.sugarPer100g; + const salt = nutrition.saltPer100g; + + return nutrientLevels.sugars !== 'high' + && nutrientLevels.salt !== 'high' + && nutrientLevels.fat !== 'high' + && nutrientLevels.saturatedFat !== 'high' + && (typeof sugar !== 'number' || sugar <= 5) + && (typeof salt !== 'number' || salt <= 0.3); +} + +function productTerms(product) { + return [ + product.category, + ...(product.categoryTags || []), + product.name + ] + .map(normalizeTerm) + .filter(Boolean); +} + +function normalizeTerm(value) { + return String(value || '') + .toLowerCase() + .replace(/^[a-z]{2}:/, '') + .replace(/-/g, ' ') + .trim(); +} + +function missingMessages(entries) { + return entries + .filter(([isMissing]) => isMissing) + .map(([, message]) => message); +} diff --git a/src/services/storageService.js b/src/services/storageService.js index f5d4cd7..c223187 100644 --- a/src/services/storageService.js +++ b/src/services/storageService.js @@ -4,6 +4,7 @@ const keys = { preferences: 'shoppy.preferences', shoppingList: 'shoppy.shoppingList', productCache: 'shoppy.productCache', + manualPrices: 'shoppy.manualPrices', recentProducts: 'shoppy.recentProducts' }; @@ -23,6 +24,8 @@ export const storageService = { saveShoppingList: (shoppingList) => writeJson(keys.shoppingList, shoppingList), loadProductCache: () => readJson(keys.productCache, {}), saveProductCache: (cache) => writeJson(keys.productCache, cache), + loadManualPrices: () => readJson(keys.manualPrices, {}), + saveManualPrices: (prices) => writeJson(keys.manualPrices, prices), loadRecentProducts: () => readJson(keys.recentProducts, []), saveRecentProducts: (products) => writeJson(keys.recentProducts, products) };