fix(streckkod+kvitto): sparad-bekraftelse, fettvarden, kvitto-antal

Streckkod / produktbidrag:
- Tydlig bekraftelse 'Sparad i delade katalogen - tack!' visas nu efter att en
  fotad produkt lasts av, sa man ser att bidraget gick fram (forut bara
  'Skanna igen' utan aterkoppling).
- Naringsetiketter laser nu ocksa enkelomattat och fleromattat fett (t.ex.
  olivolja visar tre fettrader). Nya valfria falt monounsaturatedFatG/
  polyunsaturatedFatG i NutritionValues + READ_NUTRITION_LABEL + prompt +
  produktpanelen. Obligatoriska EU-falt oforandrade.

Kvitto:
- Rader som ar samma vara (samma normaliserade namn + enhet) slas nu ihop till
  EN vara med ratt antal - '2 x mjolk' pa kvittot blir en vara a 2 st i stallet
  for tva rader a 1 st. Priser summeras, rabatter dras som forut. En kvittorad
  raknas som minst 1 exemplar aven om antal saknas.

Verifierat: typecheck hela repot gront, nutrition-engine 19/19, ai-contracts
24/24, worker 23/23, api 104/104, samt repro mot testdatabas (2 mjolk-rader ->
1 vara a 2; olivolja sparar fett/mattat/enkel-/fleromattat).
This commit is contained in:
Claude
2026-08-20 14:37:45 +00:00
parent cac98b8dc7
commit d75500bebd
17 changed files with 127 additions and 27 deletions
+25
View File
@@ -29,6 +29,8 @@ type Nutrition = {
carbsG: number; carbsG: number;
fatG: number; fatG: number;
saturatedFatG: number; saturatedFatG: number;
monounsaturatedFatG?: number;
polyunsaturatedFatG?: number;
fiberG: number; fiberG: number;
sugarG: number; sugarG: number;
saltG: number; saltG: number;
@@ -87,6 +89,7 @@ export default function BarcodeScreen() {
const [product, setProduct] = useState<ProductInfo | null>(null); const [product, setProduct] = useState<ProductInfo | null>(null);
const [unknown, setUnknown] = useState(false); const [unknown, setUnknown] = useState(false);
const [contributing, setContributing] = useState(false); const [contributing, setContributing] = useState(false);
const [justContributed, setJustContributed] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag. // Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag.
const locked = useRef(false); const locked = useRef(false);
@@ -101,6 +104,7 @@ export default function BarcodeScreen() {
lastGtin.current = gtin; lastGtin.current = gtin;
setBusy(true); setBusy(true);
setErrorMsg(null); setErrorMsg(null);
setJustContributed(false);
try { try {
const result = await api<BarcodeScanResponse>("/v1/scans", { const result = await api<BarcodeScanResponse>("/v1/scans", {
method: "POST", method: "POST",
@@ -177,6 +181,7 @@ export default function BarcodeScreen() {
const found = await pollScan(created.scan.id); const found = await pollScan(created.scan.id);
if (found) { if (found) {
setUnknown(false); setUnknown(false);
setJustContributed(true);
setProduct(found); setProduct(found);
} else { } else {
// Behåll nuvarande vy (okänd/produkt) och visa felet som dialog. // Behåll nuvarande vy (okänd/produkt) och visa felet som dialog.
@@ -197,6 +202,7 @@ export default function BarcodeScreen() {
setProduct(null); setProduct(null);
setUnknown(false); setUnknown(false);
setErrorMsg(null); setErrorMsg(null);
setJustContributed(false);
locked.current = false; locked.current = false;
lastGtin.current = null; lastGtin.current = null;
}; };
@@ -248,6 +254,11 @@ export default function BarcodeScreen() {
{product && !contributing && ( {product && !contributing && (
<View style={styles.panel}> <View style={styles.panel}>
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: spacing.md }}> <ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: spacing.md }}>
{justContributed && (
<View style={styles.savedBanner}>
<Small style={styles.savedText}>{t("barcode.contributing.saved")}</Small>
</View>
)}
<Heading>{product.name}</Heading> <Heading>{product.name}</Heading>
{(product.brand || product.packageSizeValue) && ( {(product.brand || product.packageSizeValue) && (
<Small style={styles.muted}> <Small style={styles.muted}>
@@ -269,6 +280,12 @@ export default function BarcodeScreen() {
<NutRow label={t("barcode.info.kcal")} value={`${Math.round(n.kcal)} kcal`} strong /> <NutRow label={t("barcode.info.kcal")} value={`${Math.round(n.kcal)} kcal`} strong />
<NutRow label={t("barcode.info.fat")} value={fmtG(n.fatG)} /> <NutRow label={t("barcode.info.fat")} value={fmtG(n.fatG)} />
<NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub /> <NutRow label={t("barcode.info.satfat")} value={fmtG(n.saturatedFatG)} sub />
{n.monounsaturatedFatG != null && (
<NutRow label={t("barcode.info.monofat")} value={fmtG(n.monounsaturatedFatG)} sub />
)}
{n.polyunsaturatedFatG != null && (
<NutRow label={t("barcode.info.polyfat")} value={fmtG(n.polyunsaturatedFatG)} sub />
)}
<NutRow label={t("barcode.info.carbs")} value={fmtG(n.carbsG)} /> <NutRow label={t("barcode.info.carbs")} value={fmtG(n.carbsG)} />
<NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub /> <NutRow label={t("barcode.info.sugar")} value={fmtG(n.sugarG)} sub />
<NutRow label={t("barcode.info.fiber")} value={fmtG(n.fiberG)} /> <NutRow label={t("barcode.info.fiber")} value={fmtG(n.fiberG)} />
@@ -426,4 +443,12 @@ const styles = StyleSheet.create({
muted: { color: colors.textMuted }, muted: { color: colors.textMuted },
strong: { fontWeight: "700" }, strong: { fontWeight: "700" },
source: { color: colors.textMuted, fontStyle: "italic" }, source: { color: colors.textMuted, fontStyle: "italic" },
savedBanner: {
backgroundColor: colors.primarySoft,
borderRadius: 10,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
marginBottom: spacing.sm,
},
savedText: { color: colors.primary, fontWeight: "700" },
}); });
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Aflæser emballagen…", "barcode.contributing.title": "Aflæser emballagen…",
"barcode.contributing.body": "Vi finder næring, allergener og indhold og gemmer produktet i det delte katalog, så den næste slipper for at fotografere det.", "barcode.contributing.body": "Vi finder næring, allergener og indhold og gemmer produktet i det delte katalog, så den næste slipper for at fotografere det.",
"barcode.contributing.failed": "Kunne ikke aflæse emballagen. Prøv igen med et skarpt billede og godt lys.", "barcode.contributing.failed": "Kunne ikke aflæse emballagen. Prøv igen med et skarpt billede og godt lys.",
"barcode.contributing.saved": "✓ Gemt i det delte katalog tak fordi du bidrager!",
"barcode.contributing.frontDone": "Forside taget", "barcode.contributing.frontDone": "Forside taget",
"barcode.contributing.backPrompt": "Tag nu et billede af bagsiden med næringsdeklaration og indhold så bliver produktet komplet.", "barcode.contributing.backPrompt": "Tag nu et billede af bagsiden med næringsdeklaration og indhold så bliver produktet komplet.",
"barcode.contributing.skipBack": "Færdig, analysér", "barcode.contributing.skipBack": "Færdig, analysér",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " heraf sukkerarter", "barcode.info.sugar": " heraf sukkerarter",
"barcode.info.fat": "Fedt", "barcode.info.fat": "Fedt",
"barcode.info.satfat": " heraf mættede", "barcode.info.satfat": " heraf mættede",
"barcode.info.monofat": " heraf enkeltumættede",
"barcode.info.polyfat": " heraf flerumættede",
"barcode.info.fiber": "Fibre", "barcode.info.fiber": "Fibre",
"barcode.info.salt": "Salt", "barcode.info.salt": "Salt",
"barcode.info.allergens": "Allergener", "barcode.info.allergens": "Allergener",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Verpackung wird gelesen…", "barcode.contributing.title": "Verpackung wird gelesen…",
"barcode.contributing.body": "Wir ermitteln Nährwerte, Allergene und Zutaten und speichern das Produkt im gemeinsamen Katalog, damit es der Nächste nicht fotografieren muss.", "barcode.contributing.body": "Wir ermitteln Nährwerte, Allergene und Zutaten und speichern das Produkt im gemeinsamen Katalog, damit es der Nächste nicht fotografieren muss.",
"barcode.contributing.failed": "Die Verpackung konnte nicht gelesen werden. Versuche es mit einem scharfen Foto und gutem Licht erneut.", "barcode.contributing.failed": "Die Verpackung konnte nicht gelesen werden. Versuche es mit einem scharfen Foto und gutem Licht erneut.",
"barcode.contributing.saved": "✓ Im gemeinsamen Katalog gespeichert danke fürs Beitragen!",
"barcode.contributing.frontDone": "Vorderseite aufgenommen", "barcode.contributing.frontDone": "Vorderseite aufgenommen",
"barcode.contributing.backPrompt": "Fotografiere jetzt die Rückseite mit Nährwerttabelle und Zutaten so wird das Produkt vollständig.", "barcode.contributing.backPrompt": "Fotografiere jetzt die Rückseite mit Nährwerttabelle und Zutaten so wird das Produkt vollständig.",
"barcode.contributing.skipBack": "Fertig, analysieren", "barcode.contributing.skipBack": "Fertig, analysieren",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " davon Zucker", "barcode.info.sugar": " davon Zucker",
"barcode.info.fat": "Fett", "barcode.info.fat": "Fett",
"barcode.info.satfat": " davon gesättigte", "barcode.info.satfat": " davon gesättigte",
"barcode.info.monofat": " davon einfach ungesättigt",
"barcode.info.polyfat": " davon mehrfach ungesättigt",
"barcode.info.fiber": "Ballaststoffe", "barcode.info.fiber": "Ballaststoffe",
"barcode.info.salt": "Salz", "barcode.info.salt": "Salz",
"barcode.info.allergens": "Allergene", "barcode.info.allergens": "Allergene",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Reading the package…", "barcode.contributing.title": "Reading the package…",
"barcode.contributing.body": "We're extracting nutrition, allergens and ingredients and saving the product to the shared catalogue so the next person doesn't have to photograph it.", "barcode.contributing.body": "We're extracting nutrition, allergens and ingredients and saving the product to the shared catalogue so the next person doesn't have to photograph it.",
"barcode.contributing.failed": "Couldn't read the package. Try again with a sharp photo and good lighting.", "barcode.contributing.failed": "Couldn't read the package. Try again with a sharp photo and good lighting.",
"barcode.contributing.saved": "✓ Saved to the shared catalogue thanks for contributing!",
"barcode.contributing.frontDone": "Front photo taken", "barcode.contributing.frontDone": "Front photo taken",
"barcode.contributing.backPrompt": "Now photograph the back with the nutrition label and ingredients that completes the product.", "barcode.contributing.backPrompt": "Now photograph the back with the nutrition label and ingredients that completes the product.",
"barcode.contributing.skipBack": "Done, analyse", "barcode.contributing.skipBack": "Done, analyse",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " of which sugars", "barcode.info.sugar": " of which sugars",
"barcode.info.fat": "Fat", "barcode.info.fat": "Fat",
"barcode.info.satfat": " of which saturates", "barcode.info.satfat": " of which saturates",
"barcode.info.monofat": " of which monounsaturated",
"barcode.info.polyfat": " of which polyunsaturated",
"barcode.info.fiber": "Fibre", "barcode.info.fiber": "Fibre",
"barcode.info.salt": "Salt", "barcode.info.salt": "Salt",
"barcode.info.allergens": "Allergens", "barcode.info.allergens": "Allergens",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Leyendo el envase…", "barcode.contributing.title": "Leyendo el envase…",
"barcode.contributing.body": "Estamos extrayendo la información nutricional, los alérgenos y los ingredientes, y guardando el producto en el catálogo compartido para que la próxima persona no tenga que fotografiarlo.", "barcode.contributing.body": "Estamos extrayendo la información nutricional, los alérgenos y los ingredientes, y guardando el producto en el catálogo compartido para que la próxima persona no tenga que fotografiarlo.",
"barcode.contributing.failed": "No se pudo leer el envase. Inténtalo de nuevo con una foto nítida y buena luz.", "barcode.contributing.failed": "No se pudo leer el envase. Inténtalo de nuevo con una foto nítida y buena luz.",
"barcode.contributing.saved": "✓ Guardado en el catálogo compartido: ¡gracias por contribuir!",
"barcode.contributing.frontDone": "Foto del frente tomada", "barcode.contributing.frontDone": "Foto del frente tomada",
"barcode.contributing.backPrompt": "Ahora fotografía la parte de atrás con la información nutricional y los ingredientes: así el producto queda completo.", "barcode.contributing.backPrompt": "Ahora fotografía la parte de atrás con la información nutricional y los ingredientes: así el producto queda completo.",
"barcode.contributing.skipBack": "Listo, analizar", "barcode.contributing.skipBack": "Listo, analizar",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " de los cuales azúcares", "barcode.info.sugar": " de los cuales azúcares",
"barcode.info.fat": "Grasas", "barcode.info.fat": "Grasas",
"barcode.info.satfat": " de las cuales saturadas", "barcode.info.satfat": " de las cuales saturadas",
"barcode.info.monofat": " de las cuales monoinsaturadas",
"barcode.info.polyfat": " de las cuales poliinsaturadas",
"barcode.info.fiber": "Fibra", "barcode.info.fiber": "Fibra",
"barcode.info.salt": "Sal", "barcode.info.salt": "Sal",
"barcode.info.allergens": "Alérgenos", "barcode.info.allergens": "Alérgenos",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Luetaan pakkausta…", "barcode.contributing.title": "Luetaan pakkausta…",
"barcode.contributing.body": "Poimimme ravintoarvot, allergeenit ja ainesosat ja tallennamme tuotteen jaettuun luetteloon, jottei seuraavan tarvitse kuvata sitä.", "barcode.contributing.body": "Poimimme ravintoarvot, allergeenit ja ainesosat ja tallennamme tuotteen jaettuun luetteloon, jottei seuraavan tarvitse kuvata sitä.",
"barcode.contributing.failed": "Pakkausta ei voitu lukea. Yritä uudelleen terävällä kuvalla ja hyvässä valossa.", "barcode.contributing.failed": "Pakkausta ei voitu lukea. Yritä uudelleen terävällä kuvalla ja hyvässä valossa.",
"barcode.contributing.saved": "✓ Tallennettu jaettuun luetteloon kiitos osallistumisesta!",
"barcode.contributing.frontDone": "Etupuoli kuvattu", "barcode.contributing.frontDone": "Etupuoli kuvattu",
"barcode.contributing.backPrompt": "Kuvaa nyt takapuoli, jossa on ravintosisältö ja ainesosat näin tuote täydentyy.", "barcode.contributing.backPrompt": "Kuvaa nyt takapuoli, jossa on ravintosisältö ja ainesosat näin tuote täydentyy.",
"barcode.contributing.skipBack": "Valmis, analysoi", "barcode.contributing.skipBack": "Valmis, analysoi",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " josta sokereita", "barcode.info.sugar": " josta sokereita",
"barcode.info.fat": "Rasva", "barcode.info.fat": "Rasva",
"barcode.info.satfat": " josta tyydyttynyttä", "barcode.info.satfat": " josta tyydyttynyttä",
"barcode.info.monofat": " josta kertatyydyttymätöntä",
"barcode.info.polyfat": " josta monityydyttymätöntä",
"barcode.info.fiber": "Kuitu", "barcode.info.fiber": "Kuitu",
"barcode.info.salt": "Suola", "barcode.info.salt": "Suola",
"barcode.info.allergens": "Allergeenit", "barcode.info.allergens": "Allergeenit",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Lecture de l'emballage…", "barcode.contributing.title": "Lecture de l'emballage…",
"barcode.contributing.body": "Nous extrayons les valeurs nutritionnelles, les allergènes et les ingrédients, et enregistrons le produit dans le catalogue partagé pour que la prochaine personne n'ait pas à le photographier.", "barcode.contributing.body": "Nous extrayons les valeurs nutritionnelles, les allergènes et les ingrédients, et enregistrons le produit dans le catalogue partagé pour que la prochaine personne n'ait pas à le photographier.",
"barcode.contributing.failed": "Impossible de lire l'emballage. Réessayez avec une photo nette et un bon éclairage.", "barcode.contributing.failed": "Impossible de lire l'emballage. Réessayez avec une photo nette et un bon éclairage.",
"barcode.contributing.saved": "✓ Enregistré dans le catalogue partagé merci de contribuer !",
"barcode.contributing.frontDone": "Photo de l'avant prise", "barcode.contributing.frontDone": "Photo de l'avant prise",
"barcode.contributing.backPrompt": "Photographiez maintenant l'arrière avec la déclaration nutritionnelle et les ingrédients le produit sera ainsi complet.", "barcode.contributing.backPrompt": "Photographiez maintenant l'arrière avec la déclaration nutritionnelle et les ingrédients le produit sera ainsi complet.",
"barcode.contributing.skipBack": "Terminé, analyser", "barcode.contributing.skipBack": "Terminé, analyser",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " dont sucres", "barcode.info.sugar": " dont sucres",
"barcode.info.fat": "Matières grasses", "barcode.info.fat": "Matières grasses",
"barcode.info.satfat": " dont saturées", "barcode.info.satfat": " dont saturées",
"barcode.info.monofat": " dont monoinsaturés",
"barcode.info.polyfat": " dont polyinsaturés",
"barcode.info.fiber": "Fibres", "barcode.info.fiber": "Fibres",
"barcode.info.salt": "Sel", "barcode.info.salt": "Sel",
"barcode.info.allergens": "Allergènes", "barcode.info.allergens": "Allergènes",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Lettura della confezione…", "barcode.contributing.title": "Lettura della confezione…",
"barcode.contributing.body": "Stiamo estraendo valori nutrizionali, allergeni e ingredienti e salvando il prodotto nel catalogo condiviso, così il prossimo non dovrà fotografarlo.", "barcode.contributing.body": "Stiamo estraendo valori nutrizionali, allergeni e ingredienti e salvando il prodotto nel catalogo condiviso, così il prossimo non dovrà fotografarlo.",
"barcode.contributing.failed": "Impossibile leggere la confezione. Riprova con una foto nitida e una buona illuminazione.", "barcode.contributing.failed": "Impossibile leggere la confezione. Riprova con una foto nitida e una buona illuminazione.",
"barcode.contributing.saved": "✓ Salvato nel catalogo condiviso grazie per il contributo!",
"barcode.contributing.frontDone": "Foto del fronte scattata", "barcode.contributing.frontDone": "Foto del fronte scattata",
"barcode.contributing.backPrompt": "Ora fotografa il retro con la tabella nutrizionale e gli ingredienti: così il prodotto è completo.", "barcode.contributing.backPrompt": "Ora fotografa il retro con la tabella nutrizionale e gli ingredienti: così il prodotto è completo.",
"barcode.contributing.skipBack": "Fatto, analizza", "barcode.contributing.skipBack": "Fatto, analizza",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " di cui zuccheri", "barcode.info.sugar": " di cui zuccheri",
"barcode.info.fat": "Grassi", "barcode.info.fat": "Grassi",
"barcode.info.satfat": " di cui saturi", "barcode.info.satfat": " di cui saturi",
"barcode.info.monofat": " di cui monoinsaturi",
"barcode.info.polyfat": " di cui polinsaturi",
"barcode.info.fiber": "Fibre", "barcode.info.fiber": "Fibre",
"barcode.info.salt": "Sale", "barcode.info.salt": "Sale",
"barcode.info.allergens": "Allergeni", "barcode.info.allergens": "Allergeni",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Leser av emballasjen…", "barcode.contributing.title": "Leser av emballasjen…",
"barcode.contributing.body": "Vi henter næring, allergener og innhold og lagrer produktet i den delte katalogen, så den neste slipper å fotografere det.", "barcode.contributing.body": "Vi henter næring, allergener og innhold og lagrer produktet i den delte katalogen, så den neste slipper å fotografere det.",
"barcode.contributing.failed": "Kunne ikke lese av emballasjen. Prøv igjen med et skarpt bilde og godt lys.", "barcode.contributing.failed": "Kunne ikke lese av emballasjen. Prøv igjen med et skarpt bilde og godt lys.",
"barcode.contributing.saved": "✓ Lagret i den delte katalogen takk for at du bidrar!",
"barcode.contributing.frontDone": "Forside tatt", "barcode.contributing.frontDone": "Forside tatt",
"barcode.contributing.backPrompt": "Ta nå bilde av baksiden med næringsinnhold og ingredienser da blir produktet komplett.", "barcode.contributing.backPrompt": "Ta nå bilde av baksiden med næringsinnhold og ingredienser da blir produktet komplett.",
"barcode.contributing.skipBack": "Ferdig, analyser", "barcode.contributing.skipBack": "Ferdig, analyser",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " hvorav sukkerarter", "barcode.info.sugar": " hvorav sukkerarter",
"barcode.info.fat": "Fett", "barcode.info.fat": "Fett",
"barcode.info.satfat": " hvorav mettede", "barcode.info.satfat": " hvorav mettede",
"barcode.info.monofat": " hvorav enumettede",
"barcode.info.polyfat": " hvorav flerumettede",
"barcode.info.fiber": "Fiber", "barcode.info.fiber": "Fiber",
"barcode.info.salt": "Salt", "barcode.info.salt": "Salt",
"barcode.info.allergens": "Allergener", "barcode.info.allergens": "Allergener",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Verpakking wordt gelezen…", "barcode.contributing.title": "Verpakking wordt gelezen…",
"barcode.contributing.body": "We halen voedingswaarde, allergenen en ingrediënten op en slaan het product op in de gedeelde catalogus, zodat de volgende het niet hoeft te fotograferen.", "barcode.contributing.body": "We halen voedingswaarde, allergenen en ingrediënten op en slaan het product op in de gedeelde catalogus, zodat de volgende het niet hoeft te fotograferen.",
"barcode.contributing.failed": "Kon de verpakking niet lezen. Probeer het opnieuw met een scherpe foto en goed licht.", "barcode.contributing.failed": "Kon de verpakking niet lezen. Probeer het opnieuw met een scherpe foto en goed licht.",
"barcode.contributing.saved": "✓ Opgeslagen in de gedeelde catalogus bedankt voor je bijdrage!",
"barcode.contributing.frontDone": "Voorkant gefotografeerd", "barcode.contributing.frontDone": "Voorkant gefotografeerd",
"barcode.contributing.backPrompt": "Fotografeer nu de achterkant met de voedingswaarde en ingrediënten dan is het product compleet.", "barcode.contributing.backPrompt": "Fotografeer nu de achterkant met de voedingswaarde en ingrediënten dan is het product compleet.",
"barcode.contributing.skipBack": "Klaar, analyseren", "barcode.contributing.skipBack": "Klaar, analyseren",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " waarvan suikers", "barcode.info.sugar": " waarvan suikers",
"barcode.info.fat": "Vet", "barcode.info.fat": "Vet",
"barcode.info.satfat": " waarvan verzadigd", "barcode.info.satfat": " waarvan verzadigd",
"barcode.info.monofat": " waarvan enkelvoudig onverzadigd",
"barcode.info.polyfat": " waarvan meervoudig onverzadigd",
"barcode.info.fiber": "Vezels", "barcode.info.fiber": "Vezels",
"barcode.info.salt": "Zout", "barcode.info.salt": "Zout",
"barcode.info.allergens": "Allergenen", "barcode.info.allergens": "Allergenen",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Odczytywanie opakowania…", "barcode.contributing.title": "Odczytywanie opakowania…",
"barcode.contributing.body": "Odczytujemy wartości odżywcze, alergeny i składniki i zapisujemy produkt we wspólnym katalogu, aby następna osoba nie musiała go fotografować.", "barcode.contributing.body": "Odczytujemy wartości odżywcze, alergeny i składniki i zapisujemy produkt we wspólnym katalogu, aby następna osoba nie musiała go fotografować.",
"barcode.contributing.failed": "Nie udało się odczytać opakowania. Spróbuj ponownie z ostrym zdjęciem i dobrym oświetleniem.", "barcode.contributing.failed": "Nie udało się odczytać opakowania. Spróbuj ponownie z ostrym zdjęciem i dobrym oświetleniem.",
"barcode.contributing.saved": "✓ Zapisano we wspólnym katalogu dziękujemy za wkład!",
"barcode.contributing.frontDone": "Zrobiono zdjęcie przodu", "barcode.contributing.frontDone": "Zrobiono zdjęcie przodu",
"barcode.contributing.backPrompt": "Teraz sfotografuj tył z tabelą wartości odżywczych i składnikami dzięki temu produkt będzie kompletny.", "barcode.contributing.backPrompt": "Teraz sfotografuj tył z tabelą wartości odżywczych i składnikami dzięki temu produkt będzie kompletny.",
"barcode.contributing.skipBack": "Gotowe, analizuj", "barcode.contributing.skipBack": "Gotowe, analizuj",
@@ -477,6 +478,8 @@
"barcode.info.sugar": " w tym cukry", "barcode.info.sugar": " w tym cukry",
"barcode.info.fat": "Tłuszcz", "barcode.info.fat": "Tłuszcz",
"barcode.info.satfat": " w tym nasycone", "barcode.info.satfat": " w tym nasycone",
"barcode.info.monofat": " w tym jednonienasycone",
"barcode.info.polyfat": " w tym wielonienasycone",
"barcode.info.fiber": "Błonnik", "barcode.info.fiber": "Błonnik",
"barcode.info.salt": "Sól", "barcode.info.salt": "Sól",
"barcode.info.allergens": "Alergeny", "barcode.info.allergens": "Alergeny",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "A ler a embalagem…", "barcode.contributing.title": "A ler a embalagem…",
"barcode.contributing.body": "Estamos a extrair informação nutricional, alergénios e ingredientes e a guardar o produto no catálogo partilhado, para que a próxima pessoa não precise de o fotografar.", "barcode.contributing.body": "Estamos a extrair informação nutricional, alergénios e ingredientes e a guardar o produto no catálogo partilhado, para que a próxima pessoa não precise de o fotografar.",
"barcode.contributing.failed": "Não foi possível ler a embalagem. Tente novamente com uma foto nítida e boa iluminação.", "barcode.contributing.failed": "Não foi possível ler a embalagem. Tente novamente com uma foto nítida e boa iluminação.",
"barcode.contributing.saved": "✓ Guardado no catálogo partilhado obrigado por contribuir!",
"barcode.contributing.frontDone": "Foto da frente tirada", "barcode.contributing.frontDone": "Foto da frente tirada",
"barcode.contributing.backPrompt": "Agora fotografe a parte de trás com a declaração nutricional e os ingredientes assim o produto fica completo.", "barcode.contributing.backPrompt": "Agora fotografe a parte de trás com a declaração nutricional e os ingredientes assim o produto fica completo.",
"barcode.contributing.skipBack": "Pronto, analisar", "barcode.contributing.skipBack": "Pronto, analisar",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " dos quais açúcares", "barcode.info.sugar": " dos quais açúcares",
"barcode.info.fat": "Gordura", "barcode.info.fat": "Gordura",
"barcode.info.satfat": " das quais saturadas", "barcode.info.satfat": " das quais saturadas",
"barcode.info.monofat": " das quais monoinsaturadas",
"barcode.info.polyfat": " das quais poli-insaturadas",
"barcode.info.fiber": "Fibra", "barcode.info.fiber": "Fibra",
"barcode.info.salt": "Sal", "barcode.info.salt": "Sal",
"barcode.info.allergens": "Alergénios", "barcode.info.allergens": "Alergénios",
+3
View File
@@ -33,6 +33,7 @@
"barcode.contributing.title": "Läser av förpackningen…", "barcode.contributing.title": "Läser av förpackningen…",
"barcode.contributing.body": "Vi tar fram näring, allergener och innehåll och sparar produkten i den delade katalogen så nästa person slipper fota den.", "barcode.contributing.body": "Vi tar fram näring, allergener och innehåll och sparar produkten i den delade katalogen så nästa person slipper fota den.",
"barcode.contributing.failed": "Kunde inte läsa av förpackningen. Försök igen med skarp bild och bra ljus.", "barcode.contributing.failed": "Kunde inte läsa av förpackningen. Försök igen med skarp bild och bra ljus.",
"barcode.contributing.saved": "✓ Sparad i delade katalogen tack för att du bidrar!",
"barcode.contributing.frontDone": "Framsida tagen", "barcode.contributing.frontDone": "Framsida tagen",
"barcode.contributing.backPrompt": "Fota nu baksidan med näringsdeklaration och innehåll då blir produkten komplett.", "barcode.contributing.backPrompt": "Fota nu baksidan med näringsdeklaration och innehåll då blir produkten komplett.",
"barcode.contributing.skipBack": "Klar, analysera", "barcode.contributing.skipBack": "Klar, analysera",
@@ -463,6 +464,8 @@
"barcode.info.sugar": " varav socker", "barcode.info.sugar": " varav socker",
"barcode.info.fat": "Fett", "barcode.info.fat": "Fett",
"barcode.info.satfat": " varav mättat", "barcode.info.satfat": " varav mättat",
"barcode.info.monofat": " varav enkelomättat",
"barcode.info.polyfat": " varav fleromättat",
"barcode.info.fiber": "Fiber", "barcode.info.fiber": "Fiber",
"barcode.info.salt": "Salt", "barcode.info.salt": "Salt",
"barcode.info.allergens": "Allergener", "barcode.info.allergens": "Allergener",
+58 -25
View File
@@ -83,33 +83,64 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
discountByName.set(k, (discountByName.get(k) ?? 0) + l.totalPriceMinor); // negativt belopp discountByName.set(k, (discountByName.get(k) ?? 0) + l.totalPriceMinor); // negativt belopp
} }
} }
(output as Record<string, unknown>).items = lines // Slå ihop rader som är SAMMA vara (samma normaliserade namn + enhet) så att
.filter((l) => l.isDiscount !== true && (l.normalizedName ?? l.rawText)) // t.ex. "2 st mjölk" på kvittot blir EN vara med antal 2, inte två rader à 1 st.
.map((l, idx) => { // (Många butiker skriver ut varje köpt exemplar på egen rad.)
const qty = typeof l.quantity === "number" ? l.quantity : null; type MergedLine = {
const gross = detectedName: string;
typeof l.totalPriceMinor === "number" canonicalIngredientId: string | null;
? l.totalPriceMinor unit: string | null;
: typeof l.unitPriceMinor === "number" && qty != null quantity: number;
? Math.round(l.unitPriceMinor * qty) gross: number | null;
: null; confidence: number;
const disc = l.normalizedName };
? (discountByName.get(String(l.normalizedName).toLowerCase()) ?? 0) const mergedItems = new Map<string, MergedLine>();
: 0; for (const l of lines) {
const priceMinor = gross != null ? Math.max(0, gross + disc) : null; if (l.isDiscount === true) continue;
return { const name = (l.normalizedName ?? l.rawText) as string | undefined;
tempId: String(idx), if (!name) continue;
detectedName: String(l.normalizedName ?? l.rawText ?? ""), const unit = (l.unit as string | null) ?? null;
const key = `${String(name).toLowerCase().trim()}|${unit ?? ""}`;
// En kvittorad är minst 1 exemplar även om antal saknas.
const qty = typeof l.quantity === "number" && l.quantity > 0 ? l.quantity : 1;
const lineGross =
typeof l.totalPriceMinor === "number"
? l.totalPriceMinor
: typeof l.unitPriceMinor === "number"
? Math.round(l.unitPriceMinor * qty)
: null;
const cur = mergedItems.get(key);
if (cur) {
cur.quantity += qty;
cur.gross =
cur.gross == null && lineGross == null ? null : (cur.gross ?? 0) + (lineGross ?? 0);
} else {
mergedItems.set(key, {
detectedName: String(name),
canonicalIngredientId: (l.canonicalIngredientId as string | null) ?? null, canonicalIngredientId: (l.canonicalIngredientId as string | null) ?? null,
brand: null, unit,
estimatedQuantity: qty, quantity: qty,
unit: (l.unit as string | null) ?? null, gross: lineGross,
bestBeforeDate: null,
confidence: typeof l.confidence === "number" ? l.confidence : 0.5, confidence: typeof l.confidence === "number" ? l.confidence : 0.5,
requiresConfirmation: true, });
priceMinor, // kvittots (netto)pris i ören, förs vidare till lagret vid bekräftelse }
}; }
}); (output as Record<string, unknown>).items = [...mergedItems.values()].map((m, idx) => {
const disc = discountByName.get(m.detectedName.toLowerCase()) ?? 0;
const priceMinor = m.gross != null ? Math.max(0, m.gross + disc) : null;
return {
tempId: String(idx),
detectedName: m.detectedName,
canonicalIngredientId: m.canonicalIngredientId,
brand: null,
estimatedQuantity: m.quantity,
unit: m.unit,
bestBeforeDate: null,
confidence: m.confidence,
requiresConfirmation: true,
priceMinor, // kvittots (netto)pris i ören, förs vidare till lagret vid bekräftelse
};
});
output = await mapDetectedItemsToCanonical(ctx, output); output = await mapDetectedItemsToCanonical(ctx, output);
} }
@@ -142,6 +173,8 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
carbsG: v.carbsG ?? 0, carbsG: v.carbsG ?? 0,
fatG: v.fatG ?? 0, fatG: v.fatG ?? 0,
saturatedFatG: v.saturatedFatG ?? 0, saturatedFatG: v.saturatedFatG ?? 0,
monounsaturatedFatG: v.monounsaturatedFatG ?? undefined,
polyunsaturatedFatG: v.polyunsaturatedFatG ?? undefined,
fiberG: v.fiberG ?? 0, fiberG: v.fiberG ?? 0,
sugarG: v.sugarG ?? 0, sugarG: v.sugarG ?? 0,
saltG: v.saltG ?? 0, saltG: v.saltG ?? 0,
+2 -2
View File
@@ -972,7 +972,7 @@ Språk: ${lang}.`;
Läs av exakt det som står på förpackningen. Gissa aldrig värden som inte syns. Returnera: Läs av exakt det som står på förpackningen. Gissa aldrig värden som inte syns. Returnera:
- basis: "per_100_g", "per_100_ml" eller "per_portion" vad gäller näringstabellen? - basis: "per_100_g", "per_100_ml" eller "per_portion" vad gäller näringstabellen?
- values: { kcal, proteinG, carbsG, fatG, saturatedFatG, fiberG, sugarG, saltG } null om ej synligt. - values: { kcal, proteinG, carbsG, fatG, saturatedFatG, monounsaturatedFatG, polyunsaturatedFatG, fiberG, sugarG, saltG } null om ej synligt. Läs ALLA "varav"-rader under Fett: varav mättat (saturatedFatG), varav enkelomättat (monounsaturatedFatG), varav fleromättat (polyunsaturatedFatG).
- ingredientsText: rå text med ingredienser (från baksidan), eller null. - ingredientsText: rå text med ingredienser (från baksidan), eller null.
- allergensDeclared: lista med allergener som explicit nämns (t.ex. ["gluten", "mjölk", "nötter"]). - allergensDeclared: lista med allergener som explicit nämns (t.ex. ["gluten", "mjölk", "nötter"]).
- gtin: streckkod/EAN om synlig, annars null. - gtin: streckkod/EAN om synlig, annars null.
@@ -983,7 +983,7 @@ Läs av exakt det som står på förpackningen. Gissa aldrig värden som inte sy
Svara ENDAST med giltig JSON som matchar detta schema: Svara ENDAST med giltig JSON som matchar detta schema:
{ {
"basis": "per_100_g" | "per_100_ml" | "per_portion" | null, "basis": "per_100_g" | "per_100_ml" | "per_portion" | null,
"values": { "kcal": number|null, "proteinG": number|null, "carbsG": number|null, "fatG": number|null, "saturatedFatG": number|null, "fiberG": number|null, "sugarG": number|null, "saltG": number|null } | null, "values": { "kcal": number|null, "proteinG": number|null, "carbsG": number|null, "fatG": number|null, "saturatedFatG": number|null, "monounsaturatedFatG": number|null, "polyunsaturatedFatG": number|null, "fiberG": number|null, "sugarG": number|null, "saltG": number|null } | null,
"ingredientsText": string | null, "ingredientsText": string | null,
"allergensDeclared": string[], "allergensDeclared": string[],
"gtin": string | null, "gtin": string | null,
+2
View File
@@ -222,6 +222,8 @@ export const readNutritionLabelOutput = z.object({
carbsG: z.number().nullable(), carbsG: z.number().nullable(),
fatG: z.number().nullable(), fatG: z.number().nullable(),
saturatedFatG: z.number().nullable(), saturatedFatG: z.number().nullable(),
monounsaturatedFatG: z.number().nullable().default(null),
polyunsaturatedFatG: z.number().nullable().default(null),
fiberG: z.number().nullable(), fiberG: z.number().nullable(),
sugarG: z.number().nullable(), sugarG: z.number().nullable(),
saltG: z.number().nullable(), saltG: z.number().nullable(),
+4
View File
@@ -16,6 +16,10 @@ export interface NutritionValues {
fatG: number; fatG: number;
/** gram */ /** gram */
saturatedFatG: number; saturatedFatG: number;
/** gram enkelomättat fett. Valfritt (står oftast bara på vissa etiketter, t.ex. olja/nötter). */
monounsaturatedFatG?: number;
/** gram fleromättat fett. Valfritt (som ovan). */
polyunsaturatedFatG?: number;
/** gram */ /** gram */
fiberG: number; fiberG: number;
/** gram */ /** gram */