initial commit
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' })} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReason(product, preferences) {
|
const priceDiff = priceForSort(left.product) - priceForSort(right.product);
|
||||||
if (preferences.preferBio && product.sustainability?.isBio) {
|
if (priceDiff !== 0) {
|
||||||
return 'Bio-Alternative mit besserem Nachhaltigkeitsprofil.';
|
return priceDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferences.preferredDiet === 'lowSugar' && (product.nutrition?.sugarPer100g || 0) <= 6) {
|
return right.dataQualityScore - left.dataQualityScore;
|
||||||
return 'Weniger Zucker und dadurch besser passend zu deinem Ziel.';
|
})
|
||||||
|
.filter(uniqueByProductName)
|
||||||
|
.slice(0, INTERNAL_ALTERNATIVE_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((product.allergens || []).length === 0) {
|
async function loadRemoteCandidates(categoryTerms, productCache, offlineModeEnabled) {
|
||||||
return 'Keine bekannten Allergene im Beispieldatensatz.';
|
const queries = categoryTerms.slice(0, 3);
|
||||||
|
const results = await Promise.all(
|
||||||
|
queries.map((query) => searchProducts(query, productCache, offlineModeEnabled).catch(() => []))
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Bessere Gesamtbewertung nach deinen Präferenzen.';
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user