initial commit

This commit is contained in:
2026-07-01 13:54:21 +02:00
parent ac7ba88422
commit 43ef81b8e8
16 changed files with 1061 additions and 228 deletions
+110 -9
View File
@@ -29,6 +29,7 @@ export default function App() {
const [preferences, setPreferences] = useState(defaultPreferences); const [preferences, setPreferences] = useState(defaultPreferences);
const [shoppingList, setShoppingList] = useState(initialShoppingList); const [shoppingList, setShoppingList] = useState(initialShoppingList);
const [productCache, setProductCache] = useState({}); const [productCache, setProductCache] = useState({});
const [manualPrices, setManualPrices] = useState({});
const [recentProducts, setRecentProducts] = useState([]); const [recentProducts, setRecentProducts] = useState([]);
const [isHydrated, setIsHydrated] = useState(false); const [isHydrated, setIsHydrated] = useState(false);
@@ -40,11 +41,13 @@ export default function App() {
storedPreferences, storedPreferences,
storedShoppingList, storedShoppingList,
storedProductCache, storedProductCache,
storedManualPrices,
storedRecentProducts storedRecentProducts
] = await Promise.all([ ] = await Promise.all([
storageService.loadPreferences(defaultPreferences), storageService.loadPreferences(defaultPreferences),
storageService.loadShoppingList(initialShoppingList), storageService.loadShoppingList(initialShoppingList),
storageService.loadProductCache(), storageService.loadProductCache(),
storageService.loadManualPrices(),
storageService.loadRecentProducts() storageService.loadRecentProducts()
]); ]);
@@ -52,6 +55,7 @@ export default function App() {
setPreferences(storedPreferences); setPreferences(storedPreferences);
setShoppingList(storedShoppingList); setShoppingList(storedShoppingList);
setProductCache(storedProductCache); setProductCache(storedProductCache);
setManualPrices(storedManualPrices);
setRecentProducts(storedRecentProducts); setRecentProducts(storedRecentProducts);
setIsHydrated(true); setIsHydrated(true);
} }
@@ -75,6 +79,10 @@ export default function App() {
if (isHydrated) storageService.saveProductCache(productCache); if (isHydrated) storageService.saveProductCache(productCache);
}, [isHydrated, productCache]); }, [isHydrated, productCache]);
useEffect(() => {
if (isHydrated) storageService.saveManualPrices(manualPrices);
}, [isHydrated, manualPrices]);
useEffect(() => { useEffect(() => {
if (isHydrated) storageService.saveRecentProducts(recentProducts); if (isHydrated) storageService.saveRecentProducts(recentProducts);
}, [isHydrated, recentProducts]); }, [isHydrated, recentProducts]);
@@ -100,6 +108,8 @@ export default function App() {
onDeleteItem={deleteListItem} onDeleteItem={deleteListItem}
onStartScan={() => setActiveTab('scan')} onStartScan={() => setActiveTab('scan')}
onToggleItem={toggleListItem} onToggleItem={toggleListItem}
onUpdateItemPrice={updateListItemPrice}
preferences={preferences}
shoppingList={shoppingList} shoppingList={shoppingList}
/> />
); );
@@ -122,7 +132,7 @@ export default function App() {
recentProducts={recentProducts} recentProducts={recentProducts}
/> />
); );
}, [activeTab, preferences, productCache, recentProducts, shoppingList]); }, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]);
async function scanProductByBarcode(barcode) { async function scanProductByBarcode(barcode) {
const result = await lookupProductByBarcode( const result = await lookupProductByBarcode(
@@ -132,18 +142,18 @@ export default function App() {
); );
if (result.status === 'found') { if (result.status === 'found') {
openProduct(result.product); const pricedProduct = applyManualPrice(result.product);
addProductToCache(result.product); openProduct(pricedProduct);
addRecentProduct(result.product);
} }
return result; return result;
} }
function openProduct(product) { function openProduct(product) {
addProductToCache(product); const pricedProduct = applyManualPrice(product);
addRecentProduct(product); addProductToCache(pricedProduct);
setRoute({ name: 'productDetail', product }); addRecentProduct(pricedProduct);
setRoute({ name: 'productDetail', product: pricedProduct });
} }
function showAlternatives(product) { function showAlternatives(product) {
@@ -160,17 +170,73 @@ export default function App() {
function addRecentProduct(product) { function addRecentProduct(product) {
setRecentProducts((current) => { setRecentProducts((current) => {
const withoutDuplicate = current.filter((item) => item.id !== product.id); 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) { function addProductToShoppingList(product) {
setShoppingList((current) => { setShoppingList((current) => {
const existingItem = current.items.find((item) => item.productId === product.id); const existingItem = current.items.find((item) => item.productId === product.id);
const items = existingItem const items = existingItem
? current.items.map((item) => ? current.items.map((item) =>
item.productId === product.id 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 : item
) )
: [ : [
@@ -181,6 +247,12 @@ export default function App() {
productName: product.name, productName: product.name,
quantity: 1, quantity: 1,
estimatedPrice: product.price, 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, checked: false,
addedAt: new Date().toISOString() 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) { function toggleListItem(itemId) {
setShoppingList((current) => ({ setShoppingList((current) => ({
...current, ...current,
@@ -240,6 +336,7 @@ export default function App() {
<ProductDetailScreen <ProductDetailScreen
onAddToList={addProductToShoppingList} onAddToList={addProductToShoppingList}
onBack={goBackToTabs} onBack={goBackToTabs}
onSaveManualPrice={saveManualPriceForProduct}
onShowAlternatives={showAlternatives} onShowAlternatives={showAlternatives}
preferences={preferences} preferences={preferences}
product={routeProduct} product={routeProduct}
@@ -285,6 +382,10 @@ function calculateEstimatedTotal(items) {
}, 0); }, 0);
} }
function priceKey(product) {
return product.barcode || product.id;
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: colors.background, backgroundColor: colors.background,
+83 -3
View File
@@ -1,9 +1,29 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../utils/constants'; import { colors } from '../../utils/constants';
import { formatPrice } from '../../utils/formatPrice'; import { formatPrice } from '../../utils/formatPrice';
export default function ShoppingListItem({ item, onToggle, onDelete }) { export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePrice }) {
const [priceText, setPriceText] = useState(priceToInputValue(item.estimatedPrice));
const [priceError, setPriceError] = useState('');
useEffect(() => {
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 ( return (
<View style={styles.row}> <View style={styles.row}>
<Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}> <Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}>
@@ -11,7 +31,25 @@ export default function ShoppingListItem({ item, onToggle, onDelete }) {
</Pressable> </Pressable>
<View style={styles.copy}> <View style={styles.copy}>
<Text style={[styles.name, item.checked ? styles.done : null]}>{item.productName}</Text> <Text style={[styles.name, item.checked ? styles.done : null]}>{item.productName}</Text>
<Text style={styles.meta}>Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')}</Text> <Text style={styles.meta}>Menge {item.quantity} · {formatPrice(item.estimatedPrice, item.currency || 'CHF')}</Text>
<View style={styles.priceRow}>
<Text style={styles.currency}>{item.currency || 'CHF'}</Text>
<TextInput
accessibilityLabel={`Preis für ${item.productName}`}
keyboardType="decimal-pad"
onBlur={savePrice}
onChangeText={(value) => {
setPriceText(value);
setPriceError('');
}}
onSubmitEditing={savePrice}
placeholder="0.00"
placeholderTextColor={colors.muted}
style={styles.priceInput}
value={priceText}
/>
</View>
{priceError ? <Text style={styles.errorText}>{priceError}</Text> : null}
</View> </View>
<Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}> <Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
<Text style={styles.deleteText}>Löschen</Text> <Text style={styles.deleteText}>Löschen</Text>
@@ -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({ const styles = StyleSheet.create({
row: { row: {
alignItems: 'center', alignItems: 'center',
@@ -63,6 +116,33 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
marginTop: 3 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: { deleteButton: {
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 6 paddingVertical: 6
+23 -1
View File
@@ -9,7 +9,7 @@ export default function ProductCard({ product, rating, onPress }) {
return ( return (
<Pressable accessibilityRole="button" onPress={onPress} style={styles.card}> <Pressable accessibilityRole="button" onPress={onPress} style={styles.card}>
<View style={styles.topRow}> <View style={styles.topRow}>
<ProductImage imageUrl={product.imageUrl} /> <ProductImage imageUrl={product.imageUrl} productName={product.name} />
<View style={styles.copy}> <View style={styles.copy}>
<Text style={styles.name}>{product.name}</Text> <Text style={styles.name}>{product.name}</Text>
<Text style={styles.meta}> <Text style={styles.meta}>
@@ -18,6 +18,7 @@ export default function ProductCard({ product, rating, onPress }) {
<Text style={styles.note}> <Text style={styles.note}>
{product.category || 'Produkt'} · Barcode {product.barcode} {product.category || 'Produkt'} · Barcode {product.barcode}
</Text> </Text>
<Text style={styles.source}>{sourceLabel(product.dataSource)}</Text>
</View> </View>
{rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null} {rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null}
</View> </View>
@@ -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({ const styles = StyleSheet.create({
card: { card: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -57,5 +68,16 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
lineHeight: 18, lineHeight: 18,
marginTop: 8 marginTop: 8
},
source: {
alignSelf: 'flex-start',
backgroundColor: '#eef3ef',
borderRadius: 8,
color: colors.primaryDark,
fontSize: 12,
fontWeight: '800',
marginTop: 8,
paddingHorizontal: 8,
paddingVertical: 4
} }
}); });
+18 -4
View File
@@ -2,13 +2,15 @@ import { Image, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants'; 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; const style = size === 'large' ? styles.large : styles.card;
if (!imageUrl) { if (!imageUrl) {
return ( return (
<View style={[styles.placeholder, style]}> <View style={[styles.placeholder, style]}>
<Text style={styles.placeholderText}>Kein Bild</Text> <Text style={[styles.placeholderText, size === 'large' ? styles.largePlaceholderText : null]}>
{initialsFromName(productName)}
</Text>
</View> </View>
); );
} }
@@ -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({ const styles = StyleSheet.create({
image: { image: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -48,9 +59,12 @@ const styles = StyleSheet.create({
justifyContent: 'center' justifyContent: 'center'
}, },
placeholderText: { placeholderText: {
color: colors.muted, color: colors.primaryDark,
fontSize: 12, fontSize: 22,
fontWeight: '800', fontWeight: '800',
textAlign: 'center' textAlign: 'center'
},
largePlaceholderText: {
fontSize: 54
} }
}); });
+11 -1
View File
@@ -16,8 +16,11 @@ export default function RatingCategoryRow({ category, rating }) {
<View style={styles.copy}> <View style={styles.copy}>
<Text style={styles.title}>{labels[category] || category}</Text> <Text style={styles.title}>{labels[category] || category}</Text>
<Text style={styles.message}>{rating.message}</Text> <Text style={styles.message}>{rating.message}</Text>
{(rating.missingData || []).map((message) => (
<Text key={message} style={styles.missingData}>{message}</Text>
))}
</View> </View>
<ProductScoreBadge status={rating.status} score={rating.status === 'good' ? 25 : rating.status === 'medium' ? 14 : 0} /> <ProductScoreBadge status={rating.status} score={rating.score} />
</View> </View>
); );
} }
@@ -46,5 +49,12 @@ const styles = StyleSheet.create({
color: colors.muted, color: colors.muted,
fontSize: 14, fontSize: 14,
lineHeight: 20 lineHeight: 20
},
missingData: {
color: colors.warning,
fontSize: 13,
fontWeight: '700',
lineHeight: 19,
marginTop: 4
} }
}); });
+2 -2
View File
@@ -3,7 +3,7 @@ import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants'; import { colors } from '../../utils/constants';
export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) { export default function BarcodeScannerView({ isLoadingProduct, isScanning, onBarcodeScanned }) {
return ( return (
<View style={styles.wrap}> <View style={styles.wrap}>
<CameraView <CameraView
@@ -16,7 +16,7 @@ export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) {
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.frame} /> <View style={styles.frame} />
<Text style={styles.hint}> <Text style={styles.hint}>
Barcode in den Rahmen halten. Bei Problemen manuell suchen. {isLoadingProduct ? 'Produkt wird geladen...' : 'Barcode in den Rahmen halten. Bei Problemen manuell suchen.'}
</Text> </Text>
</View> </View>
</CameraView> </CameraView>
-110
View File
@@ -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'
}
];
+4 -2
View File
@@ -7,6 +7,8 @@ import AlternativeProductCard from '../components/product/AlternativeProductCard
import { findAlternatives } from '../services/alternativesService'; import { findAlternatives } from '../services/alternativesService';
import { colors } from '../utils/constants'; import { colors } from '../utils/constants';
const DISPLAY_ALTERNATIVE_LIMIT = 5;
export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) { export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) {
const [alternatives, setAlternatives] = useState([]); const [alternatives, setAlternatives] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -41,11 +43,11 @@ export default function AlternativesScreen({ product, preferences, productCache,
{!isLoading && alternatives.length === 0 ? ( {!isLoading && alternatives.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyTitle}>Keine Alternative gefunden</Text> <Text style={styles.emptyTitle}>Keine Alternative gefunden</Text>
<Text style={styles.emptyCopy}>Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag.</Text> <Text style={styles.emptyCopy}>Für dieses Produkt wurde in ähnlichen Kategorien noch keine klar bessere Option gefunden.</Text>
</View> </View>
) : null} ) : null}
{alternatives.map((item) => ( {alternatives.slice(0, DISPLAY_ALTERNATIVE_LIMIT).map((item) => (
<AlternativeProductCard <AlternativeProductCard
item={item} item={item}
key={item.product.id} key={item.product.id}
+31
View File
@@ -9,6 +9,11 @@ import { calculateProductRating } from '../services/ratingService';
import { colors } from '../utils/constants'; import { colors } from '../utils/constants';
export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) { export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
const frequentProducts = [...recentProducts]
.filter((product) => (product.scanCount || 0) > 1)
.sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0))
.slice(0, 3);
return ( return (
<ScreenContainer> <ScreenContainer>
<AppHeader <AppHeader
@@ -25,6 +30,22 @@ export default function HomeScreen({ preferences, recentProducts, onStartScan, o
<PrimaryButton label="Produkt scannen" onPress={onStartScan} /> <PrimaryButton label="Produkt scannen" onPress={onStartScan} />
</View> </View>
{frequentProducts.length > 0 ? (
<>
<SectionTitle>Häufig gekauft</SectionTitle>
{frequentProducts.map((product) => (
<View key={`frequent-${product.id}`} style={styles.frequentWrap}>
<ProductCard
product={product}
rating={calculateProductRating(product, preferences)}
onPress={() => onOpenProduct(product)}
/>
<Text style={styles.scanCount}>{product.scanCount} Scans</Text>
</View>
))}
</>
) : null}
<SectionTitle>Letzte Scans</SectionTitle> <SectionTitle>Letzte Scans</SectionTitle>
{recentProducts.length === 0 ? ( {recentProducts.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
@@ -71,6 +92,16 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
lineHeight: 23 lineHeight: 23
}, },
frequentWrap: {
gap: 6
},
scanCount: {
alignSelf: 'flex-start',
color: colors.primary,
fontSize: 12,
fontWeight: '800',
marginLeft: 12
},
emptyState: { emptyState: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderColor: colors.border, borderColor: colors.border,
+35 -2
View File
@@ -13,7 +13,14 @@ import { calculateProductRating } from '../services/ratingService';
import { colors } from '../utils/constants'; import { colors } from '../utils/constants';
import { formatPrice } from '../utils/formatPrice'; 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 [savedMessage, setSavedMessage] = useState('');
const [manualPrice, setManualPrice] = useState(''); const [manualPrice, setManualPrice] = useState('');
const [priceError, setPriceError] = useState(''); const [priceError, setPriceError] = useState('');
@@ -34,6 +41,10 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
return; return;
} }
if (typeof pricedProduct.price === 'number') {
onSaveManualPrice(pricedProduct, pricedProduct.price);
}
onAddToList(pricedProduct); onAddToList(pricedProduct);
setPriceError(''); setPriceError('');
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.'); setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
@@ -44,7 +55,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} /> <AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
<View style={styles.productPanel}> <View style={styles.productPanel}>
<ProductImage imageUrl={product.imageUrl} size="large" /> <ProductImage imageUrl={product.imageUrl} productName={product.name} size="large" />
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.copy}> <View style={styles.copy}>
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text> <Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
@@ -52,6 +63,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
<Text style={styles.meta}> <Text style={styles.meta}>
{product.category || 'Produkt'} · {formatPrice(pricedProduct.price, currency)} {product.category || 'Produkt'} · {formatPrice(pricedProduct.price, currency)}
</Text> </Text>
<Text style={styles.source}>{sourceLabel(product.dataSource)}</Text>
</View> </View>
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
</View> </View>
@@ -116,6 +128,16 @@ function parseManualPrice(value) {
return Math.round(parsedValue * 100) / 100; 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({ const styles = StyleSheet.create({
productPanel: { productPanel: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -151,6 +173,17 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
marginTop: 6 marginTop: 6
}, },
source: {
alignSelf: 'flex-start',
backgroundColor: '#eef3ef',
borderRadius: 8,
color: colors.primaryDark,
fontSize: 12,
fontWeight: '800',
marginTop: 10,
paddingHorizontal: 8,
paddingVertical: 4
},
summary: { summary: {
color: colors.text, color: colors.text,
fontSize: 16, fontSize: 16,
+7 -2
View File
@@ -18,6 +18,7 @@ import { colors } from '../utils/constants';
export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) { export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) {
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [isScanning, setIsScanning] = useState(true); const [isScanning, setIsScanning] = useState(true);
const [isLoadingProduct, setIsLoadingProduct] = useState(false);
const [scanError, setScanError] = useState(''); const [scanError, setScanError] = useState('');
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
const [manualSearchQuery, setManualSearchQuery] = useState(''); const [manualSearchQuery, setManualSearchQuery] = useState('');
@@ -28,7 +29,10 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
if (!isScanning) return; if (!isScanning) return;
setIsScanning(false); setIsScanning(false);
const result = await onScanBarcode(event.data); setIsLoadingProduct(true);
const result = await onScanBarcode(event.data).finally(() => {
setIsLoadingProduct(false);
});
if (result?.status !== 'found') { if (result?.status !== 'found') {
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.'); setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
@@ -70,7 +74,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
{hasCameraAccess ? ( {hasCameraAccess ? (
<> <>
<BarcodeScannerView isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} /> <BarcodeScannerView isLoadingProduct={isLoadingProduct} isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
<SecondaryButton <SecondaryButton
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'} label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
onPress={() => setIsScanning((current) => !current)} onPress={() => setIsScanning((current) => !current)}
@@ -82,6 +86,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
<Text style={styles.permissionCopy}> <Text style={styles.permissionCopy}>
Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen. Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen.
</Text> </Text>
{isLoadingProduct ? <Text style={styles.note}>Produkt wird geladen...</Text> : null}
<View style={styles.demoRow}> <View style={styles.demoRow}>
<SecondaryButton label="Nutella" onPress={() => handleBarcodeScanned({ data: '3017620422003' })} /> <SecondaryButton label="Nutella" onPress={() => handleBarcodeScanned({ data: '3017620422003' })} />
<SecondaryButton label="OpenFoodFacts Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} /> <SecondaryButton label="OpenFoodFacts Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} />
+134 -2
View File
@@ -4,11 +4,24 @@ import SecondaryButton from '../components/SecondaryButton';
import AppHeader from '../components/layout/AppHeader'; import AppHeader from '../components/layout/AppHeader';
import ScreenContainer from '../components/layout/ScreenContainer'; import ScreenContainer from '../components/layout/ScreenContainer';
import ShoppingListItem from '../components/list/ShoppingListItem'; 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'; 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 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 ( return (
<ScreenContainer> <ScreenContainer>
@@ -22,7 +35,46 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet
<Text style={styles.summaryCopy}> <Text style={styles.summaryCopy}>
Produkte abhaken, löschen oder nach dem Einkauf abschließen. Produkte abhaken, löschen oder nach dem Einkauf abschließen.
</Text> </Text>
<View style={styles.budgetBox}>
<View style={styles.budgetHeader}>
<Text style={styles.budgetLabel}>Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'}</Text>
<Text style={[styles.budgetValue, styles[budgetStatus]]}>
{formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} von {formatPrice(budget, shoppingList.currency)}
</Text>
</View> </View>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
styles[`${budgetStatus}Fill`],
{ width: `${Math.min(budgetShare * 100, 100)}%` }
]}
/>
</View>
</View>
</View>
{dailyWarnings.length > 0 ? (
<View style={styles.warningList}>
{dailyWarnings.map((warning) => (
<View
key={warning.key}
style={[
styles.warningBox,
warning.severity === 'critical' ? styles.criticalWarningBox : styles.mediumWarningBox
]}
>
<Text style={[
styles.warningTitle,
warning.severity === 'critical' ? styles.critical : styles.medium
]}>
{warning.label}: {formatAmount(warning.total, warning.unit)} von {formatAmount(warning.limit, warning.unit)}
</Text>
<Text style={styles.warningCopy}>{warning.message}</Text>
</View>
))}
</View>
) : null}
{shoppingList.items.length === 0 ? ( {shoppingList.items.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
@@ -37,6 +89,7 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet
key={item.id} key={item.id}
onDelete={() => onDeleteItem(item.id)} onDelete={() => onDeleteItem(item.id)}
onToggle={() => onToggleItem(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({ const styles = StyleSheet.create({
summary: { summary: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -67,6 +128,77 @@ const styles = StyleSheet.create({
lineHeight: 20, lineHeight: 20,
marginTop: 4 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: { empty: {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderColor: colors.border, borderColor: colors.border,
+223 -22
View File
@@ -1,33 +1,234 @@
import { mockProducts } from '../data/mockProducts'; import { searchProducts } from './productApi';
import { calculateProductRating } from './ratingService'; import { calculateProductRating } from './ratingService';
export async function findAlternatives(product, preferences, productCache = {}) { const MIN_SCORE_IMPROVEMENT = 5;
const candidates = [...mockProducts, ...Object.values(productCache)] const INTERNAL_ALTERNATIVE_LIMIT = 8;
.filter((candidate) => candidate.id !== product.id)
.filter((candidate) => candidate.category === product.category || candidate.price <= (product.price || 999));
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) => ({ .map((candidate) => ({
product: candidate, product: candidate,
rating: calculateProductRating(candidate, preferences), rating: calculateProductRating(candidate, preferences),
reason: buildReason(candidate, preferences) dataQualityScore: knownDataScore(candidate)
})) }))
.sort((left, right) => right.rating.overallScore - left.rating.overallScore) .filter((item) => item.rating.overallScore >= productRating.overallScore + MIN_SCORE_IMPROVEMENT)
.slice(0, 3); .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) { async function loadRemoteCandidates(categoryTerms, productCache, offlineModeEnabled) {
if (preferences.preferBio && product.sustainability?.isBio) { const queries = categoryTerms.slice(0, 3);
return 'Bio-Alternative mit besserem Nachhaltigkeitsprofil.'; const results = await Promise.all(
} queries.map((query) => searchProducts(query, productCache, offlineModeEnabled).catch(() => []))
);
if (preferences.preferredDiet === 'lowSugar' && (product.nutrition?.sugarPer100g || 0) <= 6) { return results.flat();
return 'Weniger Zucker und dadurch besser passend zu deinem Ziel.'; }
}
function categoryTermsForProduct(product) {
if ((product.allergens || []).length === 0) { const rawTerms = [
return 'Keine bekannten Allergene im Beispieldatensatz.'; product.category,
} ...(product.categoryTags || [])
]
return 'Bessere Gesamtbewertung nach deinen Präferenzen.'; .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);
} }
+66 -8
View File
@@ -1,5 +1,3 @@
import { mockProducts } from '../data/mockProducts';
const openFoodFactsBaseUrl = 'https://world.openfoodfacts.org'; const openFoodFactsBaseUrl = 'https://world.openfoodfacts.org';
const productFields = [ const productFields = [
'code', 'code',
@@ -10,11 +8,15 @@ const productFields = [
'categories', 'categories',
'categories_tags', 'categories_tags',
'image_url', 'image_url',
'product_quantity',
'quantity',
'serving_quantity',
'ingredients_text', 'ingredients_text',
'ingredients_text_de', 'ingredients_text_de',
'ingredients_text_en', 'ingredients_text_en',
'allergens_tags', 'allergens_tags',
'nutriments', 'nutriments',
'nutrient_levels',
'nutriscore_grade', 'nutriscore_grade',
'ecoscore_grade', 'ecoscore_grade',
'labels_tags', 'labels_tags',
@@ -29,7 +31,7 @@ export async function lookupProductByBarcode(barcode, productCache = {}, offline
const cachedProduct = productCache[normalizedBarcode] || productCache[barcode]; const cachedProduct = productCache[normalizedBarcode] || productCache[barcode];
if (cachedProduct) { if (cachedProduct) {
return { status: 'found', product: cachedProduct, source: 'cache' }; return { status: 'found', product: { ...cachedProduct, dataSource: 'cache' }, source: 'cache' };
} }
if (offlineModeEnabled) { if (offlineModeEnabled) {
@@ -81,7 +83,7 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable
} }
if (offlineModeEnabled) { if (offlineModeEnabled) {
return filterProducts(cachedProducts, normalizedQuery); return filterProducts(markProductsSource(cachedProducts, 'cache'), normalizedQuery);
} }
try { try {
@@ -90,18 +92,24 @@ export async function searchProducts(query, productCache = {}, offlineModeEnable
); );
if (!response.ok) { if (!response.ok) {
return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); return filterProducts([
...markProductsSource(cachedProducts, 'cache')
], normalizedQuery);
} }
const payload = await response.json(); const payload = await response.json();
const apiProducts = (payload.products || []) const apiProducts = (payload.products || [])
.filter((product) => product.code) .filter((product) => product.code)
.map((product) => mapOpenFoodFactsProduct(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) { } catch (error) {
return filterProducts([...cachedProducts, ...mockProducts], normalizedQuery); return filterProducts([
...markProductsSource(cachedProducts, 'cache')
], normalizedQuery);
} }
} }
@@ -113,10 +121,15 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) {
return { return {
id: `off-${barcode}`, id: `off-${barcode}`,
barcode, barcode,
dataSource: 'open-food-facts',
name: product.product_name_de || product.product_name || product.product_name_en || `Produkt ${barcode}`, name: product.product_name_de || product.product_name || product.product_name_en || `Produkt ${barcode}`,
brand: firstTextValue(product.brands), brand: firstTextValue(product.brands),
category: firstCategory(product.categories, product.categories_tags), category: firstCategory(product.categories, product.categories_tags),
categoryTags: normalizeTags(product.categories_tags || []),
imageUrl: product.image_url, imageUrl: product.image_url,
packageQuantityGrams: numberOrUndefined(product.product_quantity),
packageQuantityLabel: firstTextValue(product.quantity),
servingQuantityGrams: numberOrUndefined(product.serving_quantity),
price: undefined, price: undefined,
currency: 'CHF', currency: 'CHF',
ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en), ingredients: parseIngredients(product.ingredients_text_de || product.ingredients_text || product.ingredients_text_en),
@@ -124,9 +137,11 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) {
nutrition: { nutrition: {
caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']), caloriesPer100g: numberOrUndefined(nutriments['energy-kcal_100g']),
sugarPer100g: numberOrUndefined(nutriments.sugars_100g), sugarPer100g: numberOrUndefined(nutriments.sugars_100g),
caffeineMgPer100g: normalizeCaffeineMg(nutriments.caffeine_100g),
fatPer100g: numberOrUndefined(nutriments.fat_100g), fatPer100g: numberOrUndefined(nutriments.fat_100g),
proteinPer100g: numberOrUndefined(nutriments.proteins_100g), proteinPer100g: numberOrUndefined(nutriments.proteins_100g),
saltPer100g: numberOrUndefined(nutriments.salt_100g), saltPer100g: numberOrUndefined(nutriments.salt_100g),
nutrientLevels: normalizeNutrientLevels(product.nutrient_levels),
nutriScore: normalizeGrade(product.nutriscore_grade) nutriScore: normalizeGrade(product.nutriscore_grade)
}, },
sustainability: { sustainability: {
@@ -135,11 +150,25 @@ function mapOpenFoodFactsProduct(product, fallbackBarcode) {
originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags), originCountry: firstOrigin(product.origins, product.origins_tags, product.countries_tags),
packaging: firstTextValue(product.packaging) 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, createdAt: now,
updatedAt: now updatedAt: now
}; };
} }
function markProductsSource(products, source) {
return products.map((product) => ({
...product,
dataSource: product.dataSource || source
}));
}
function normalizeBarcode(barcode) { function normalizeBarcode(barcode) {
return String(barcode || '').replace(/[^\d]/g, ''); return String(barcode || '').replace(/[^\d]/g, '');
} }
@@ -214,11 +243,31 @@ function normalizeAllergens(allergenTags) {
.filter(Boolean); .filter(Boolean);
} }
function normalizeTags(tags) {
return tags
.map((tag) => String(tag || '').trim().toLowerCase())
.filter(Boolean);
}
function normalizeGrade(value) { function normalizeGrade(value) {
const grade = String(value || '').trim().toUpperCase(); const grade = String(value || '').trim().toUpperCase();
return ['A', 'B', 'C', 'D', 'E'].includes(grade) ? grade : undefined; 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) { function hasOrganicLabel(labelTags) {
return labelTags.some((tag) => { return labelTags.some((tag) => {
const normalizedTag = String(tag).toLowerCase(); const normalizedTag = String(tag).toLowerCase();
@@ -231,6 +280,15 @@ function numberOrUndefined(value) {
return Number.isFinite(numberValue) ? numberValue : undefined; 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) { function cleanTag(tag) {
if (!tag) return undefined; if (!tag) return undefined;
const withoutLanguage = String(tag).replace(/^[a-z]{2}:/, ''); const withoutLanguage = String(tag).replace(/^[a-z]{2}:/, '');
+311 -60
View File
@@ -1,14 +1,25 @@
const scoreMap = { const nutrientLevelLabels = {
good: 25, fat: 'Fett',
medium: 14, saturatedFat: 'gesättigte Fettsäuren',
critical: 0 sugars: 'Zucker',
salt: 'Salz'
}; };
function priorityWeight(priority) { const nutriScoreValues = {
if (priority === 'high') return 1.2; A: 100,
if (priority === 'low') return 0.8; B: 86,
return 1; C: 65,
} D: 35,
E: 15
};
const ecoScoreValues = {
A: 100,
B: 86,
C: 65,
D: 35,
E: 15
};
function statusFromScore(score) { function statusFromScore(score) {
if (score >= 78) return 'good'; if (score >= 78) return 'good';
@@ -23,40 +34,19 @@ function severityFromStatus(status) {
} }
export function calculateProductRating(product, preferences) { 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 = { const categoryScores = {
allergies, allergies: rateAllergies(product, preferences),
budget, budget: rateBudget(product, preferences),
nutrition, nutrition: rateNutrition(product, preferences),
sustainability sustainability: rateSustainability(product, preferences)
}; };
const weightedScores = [ const weights = ratingWeights(preferences);
scoreMap[allergies.status] * 1.35, const overallScore = Math.round(
scoreMap[budget.status] * 1, Object.entries(categoryScores).reduce((sum, [category, value]) => (
scoreMap[nutrition.status] * priorityWeight(preferences.nutritionPriority), sum + value.score * weights[category]
scoreMap[sustainability.status] * priorityWeight(preferences.sustainabilityPriority) ), 0)
]; );
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 overallStatus = statusFromScore(overallScore); const overallStatus = statusFromScore(overallScore);
const reasons = Object.entries(categoryScores).map(([category, value]) => ({ 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) { function rateNutrition(product, preferences) {
const nutrition = product.nutrition || {}; 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 (preferences.preferredDiet === 'lowSugar') {
if ((nutrition.sugarPer100g || 0) > 22) { const score = Math.min(baseScore, sugarScore(nutrition.sugarPer100g, nutrientLevels.sugars));
return { status: 'critical', message: 'Sehr hoher Zuckergehalt für dein Ziel.' }; if (score < 48) {
return withScore(score, nutrientLevelMessage || 'Sehr hoher Zuckergehalt für dein Ziel.', missingData);
} }
if ((nutrition.sugarPer100g || 0) > 8) { if (score < 78) {
return { status: 'medium', message: 'Zuckergehalt prüfen, liegt über deinem Ziel.' }; 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 (preferences.preferredDiet === 'highProtein') {
if ((nutrition.proteinPer100g || 0) >= 10) { if (isBeverage(product)) {
return { status: 'good', message: 'Passt gut zu deinem Protein-Ziel.' }; 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')) { 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)) { 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') { 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) { function rateSustainability(product, preferences) {
const sustainability = product.sustainability || {}; 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) { if (bioPreferenceApplies(product, preferences) && !sustainability.isBio) {
return { status: 'medium', message: 'Nicht Bio, obwohl Bio bevorzugt wird.' }; return withScore(score, 'Nicht Bio, obwohl Bio bevorzugt wird.', missingData);
} }
if (['A', 'B'].includes(sustainability.ecoScore)) { 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') { 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) { 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') { 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 share = product.price / preferences.maxBudgetPerShoppingTrip;
const periodLabel = budgetPeriodLabel(preferences.budgetPeriod); const periodLabel = budgetPeriodLabel(preferences.budgetPeriod);
const score = budgetScore(share);
if (share <= 0.08) { 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) { 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) { function budgetPeriodLabel(period) {
@@ -157,3 +327,84 @@ function budgetPeriodLabel(period) {
return labels[period] || labels.week; 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);
}
+3
View File
@@ -4,6 +4,7 @@ const keys = {
preferences: 'shoppy.preferences', preferences: 'shoppy.preferences',
shoppingList: 'shoppy.shoppingList', shoppingList: 'shoppy.shoppingList',
productCache: 'shoppy.productCache', productCache: 'shoppy.productCache',
manualPrices: 'shoppy.manualPrices',
recentProducts: 'shoppy.recentProducts' recentProducts: 'shoppy.recentProducts'
}; };
@@ -23,6 +24,8 @@ export const storageService = {
saveShoppingList: (shoppingList) => writeJson(keys.shoppingList, shoppingList), saveShoppingList: (shoppingList) => writeJson(keys.shoppingList, shoppingList),
loadProductCache: () => readJson(keys.productCache, {}), loadProductCache: () => readJson(keys.productCache, {}),
saveProductCache: (cache) => writeJson(keys.productCache, cache), saveProductCache: (cache) => writeJson(keys.productCache, cache),
loadManualPrices: () => readJson(keys.manualPrices, {}),
saveManualPrices: (prices) => writeJson(keys.manualPrices, prices),
loadRecentProducts: () => readJson(keys.recentProducts, []), loadRecentProducts: () => readJson(keys.recentProducts, []),
saveRecentProducts: (products) => writeJson(keys.recentProducts, products) saveRecentProducts: (products) => writeJson(keys.recentProducts, products)
}; };