fix(veckoplan+streckkod): byt ratt, planera om, variation, man-son + fota fram/bak

Veckoplan/kalender:
- Byt ratt: PATCH satter nu titeln (och portioner/status) efter det nya
  receptet, inte bara recipeId - annars visade kalendern kvar gamla ratten.
- Planera om veckan: ersatter nu veckans plan (raderar tidigare + entries via
  kaskad) sa man aldrig far dubbletter och alltid ser den nya planen. Nytt
  plan-id ger ocksa ny variation.
- Variation: liten deterministisk jitter per recept, seedad pa plan-id +
  veckostart, sa olika veckor OCH omgenereringar blir olika. Tackning/utgang
  dominerar fortfarande. Skalas av varietyLevel.
- Veckoplaneraren visar alltid mandag-sondag (mondayOf), oavsett dagens dag.

Streckkod (fota okand produkt):
- Fotar nu fram- OCH baksida (multi-foto) sa AI:n far bade namn (fram) och
  naring/innehall (bak). Prompt uppdaterad for flera bilder.
- Worker uppdaterar en befintlig delad produkt aven utan nytt namn (behaller
  namnet, fyller pa naring/innehall/allergener) - och sparar ny produkt aven
  fran baksidesfoto (fallback-namn) sa data inte tappas.
- Produktpanelen visar 'Fota baksidan (naring & innehall)' nar info saknas, sa
  man kan komplettera en halvfardig produkt. Fel visas som dialog utan att
  tappa vyn.
- 5 nya i18n-nycklar x 12 sprak.

Verifierat: typecheck (ai-contracts/worker/api/mobil), worker 23/23,
ai-contracts 24/24, api 104/104, samt repro mot testdatabas (byt ratt satter
titel; planera om ger 1 plan; 3 veckor + omgenerering varierar; delvis produkt
kompletteras och behaller namnet).
This commit is contained in:
Claude
2026-08-20 13:45:35 +00:00
parent 2f596060b4
commit cac98b8dc7
18 changed files with 204 additions and 34 deletions
+33 -1
View File
@@ -30,7 +30,7 @@ export async function planningRoutes(app: FastifyInstance) {
.select()
.from(schema.weekPlans)
.where(and(...conditions))
.orderBy(desc(schema.weekPlans.weekStartDate))
.orderBy(desc(schema.weekPlans.weekStartDate), desc(schema.weekPlans.createdAt))
.limit(8);
const result = [];
@@ -50,6 +50,18 @@ export async function planningRoutes(app: FastifyInstance) {
const input = parse(generateWeekPlanInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
// "Planera om veckan" ersätter helt: ta bort ev. tidigare plan(er) för samma
// vecka (kaskad tar entries) så vi aldrig samlar dubbletter och alltid visar
// den nygenererade planen. Nytt plan-id ger dessutom ny variation (se workern).
await app.db
.delete(schema.weekPlans)
.where(
and(
eq(schema.weekPlans.householdId, householdId),
eq(schema.weekPlans.weekStartDate, input.weekStartDate),
),
);
const [plan] = await app.db
.insert(schema.weekPlans)
.values({
@@ -89,6 +101,26 @@ export async function planningRoutes(app: FastifyInstance) {
const input = parse(updatePlanEntryInputSchema, req.body);
const updates: Record<string, unknown> = { ...input };
// Byt rätt: när ett nytt recept väljs måste titeln (och portioner) följa med,
// annars visar kalendern kvar den gamla rätten trots att recipeId har bytts.
if (typeof input.recipeId === "string") {
const [recipe] = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
portions: schema.recipes.portions,
})
.from(schema.recipes)
.where(and(eq(schema.recipes.id, input.recipeId), eq(schema.recipes.status, "published")))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
updates.titleSv = recipe.titleSv;
updates.mealBoxId = null;
if (input.portions == null) updates.portions = recipe.portions ?? 2;
updates.status = input.status ?? "planned";
updates.rescheduleReasonSv = null;
}
// Dynamisk omplanering med förklaring (spec §25)
if (input.status === "skipped") {
const [entry] = await app.db
+14 -3
View File
@@ -60,6 +60,14 @@ function addDays(d: Date, n: number): Date {
copy.setDate(copy.getDate() + n);
return copy;
}
/** Måndagen i veckan som d ligger i veckoplaneraren visar alltid månsön. */
function mondayOf(d: Date): Date {
const copy = new Date(d);
const dow = copy.getDay(); // 0=sön, 1=mån … 6=lör
copy.setDate(copy.getDate() + (dow === 0 ? -6 : 1 - dow));
copy.setHours(0, 0, 0, 0);
return copy;
}
function cap(s: string): string {
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
}
@@ -82,9 +90,11 @@ export default function PlanScreen() {
d.setHours(0, 0, 0, 0);
return d;
}, []);
// Veckan börjar ALLTID på måndag (månsön), oavsett vilken dag det är idag.
const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]);
const weekStart = useMemo(
() => addDays(todayMidnight, weekOffset * 7),
[todayMidnight, weekOffset],
() => addDays(thisMonday, weekOffset * 7),
[thisMonday, weekOffset],
);
const weekStartKey = toKey(weekStart);
const weekDays = useMemo(
@@ -95,7 +105,8 @@ export default function PlanScreen() {
const goWeek = (delta: number) =>
setWeekOffset((o) => {
const next = Math.max(0, o + delta);
setSelectedKey(toKey(addDays(todayMidnight, next * 7)));
// Denna vecka: markera idag. Kommande veckor: markera måndagen.
setSelectedKey(next === 0 ? todayKey : toKey(addDays(thisMonday, next * 7)));
return next;
});
// Lunch + middag varje dag som default (fler fyllda måltider), går att toggla.
+51 -17
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import { Alert, ScrollView, StyleSheet, View } from "react-native";
import { router } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { CameraView, useCameraPermissions } from "expo-camera";
@@ -121,34 +121,57 @@ export default function BarcodeScreen() {
* streckkoden så nästa person som skannar samma kod får den direkt.
* Lägger INTE till något bland användarens egna varor (spec §11).
*/
// Fota fram- OCH baksida: framsidan ger namn/varumärke, baksidan näring +
// innehåll. AI:n slår ihop bilderna. Kamera med galleri-fallback.
const capturePackagePhotos = async (): Promise<string[]> => {
const perm = await ImagePicker.requestCameraPermissionsAsync();
const shoot = () =>
perm.granted
? ImagePicker.launchCameraAsync({ quality: 0.7 })
: ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
const uris: string[] = [];
const first = await shoot();
const a0 = first.assets?.[0];
if (first.canceled || !a0) return uris;
uris.push(a0.uri);
const wantBack = await new Promise<boolean>((resolve) =>
Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [
{ text: t("barcode.contributing.skipBack"), style: "cancel", onPress: () => resolve(false) },
{ text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) },
]),
);
if (wantBack) {
const second = await shoot();
const a1 = second.assets?.[0];
if (!second.canceled && a1) uris.push(a1.uri);
}
return uris;
};
const contributePhoto = async () => {
const gtin = lastGtin.current;
if (!gtin) return;
try {
// Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet).
const perm = await ImagePicker.requestCameraPermissionsAsync();
const picked = perm.granted
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
const asset = picked.assets?.[0];
if (picked.canceled || !asset) return; // avbröt stanna kvar i "okänd"-vyn
const uris = await capturePackagePhotos();
if (uris.length === 0) return; // avbröt stanna kvar i nuvarande vy
setContributing(true);
setErrorMsg(null);
// Produktskanning kopplad till streckkoden delad katalog, inte lagret.
const created = await api<CreateScanResponse>("/v1/scans", {
method: "POST",
body: {
scanType: "product_package",
imageCount: 1,
imageCount: uris.length,
contentType: "image/jpeg",
context: { barcode: gtin },
},
});
const upload = created.uploads[0];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, asset.uri);
for (let i = 0; i < uris.length; i++) {
const upload = created.uploads[i];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, uris[i]!);
}
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
const found = await pollScan(created.scan.id);
@@ -156,13 +179,14 @@ export default function BarcodeScreen() {
setUnknown(false);
setProduct(found);
} else {
setErrorMsg(t("barcode.contributing.failed"));
// Behåll nuvarande vy (okänd/produkt) och visa felet som dialog.
Alert.alert(t("common.oops"), t("barcode.contributing.failed"));
}
} catch (err) {
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
router.push("/paywall");
} else {
setErrorMsg(err instanceof Error ? err.message : t("common.error"));
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
}
} finally {
setContributing(false);
@@ -194,6 +218,8 @@ export default function BarcodeScreen() {
const n = product?.nutrition?.values;
const allergens = allergenLabels(product?.allergens);
const mayContain = allergenLabels(product?.mayContainAllergens);
// Saknar produkten näring eller innehåll? Erbjud att komplettera med baksidesfoto.
const incomplete = !!product && (!n || !product.ingredientsText);
return (
<View style={styles.container}>
@@ -219,7 +245,7 @@ export default function BarcodeScreen() {
)}
{/* Produktinfo-panel (nedre delen, scrollbar) */}
{product && (
{product && !contributing && (
<View style={styles.panel}>
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: spacing.md }}>
<Heading>{product.name}</Heading>
@@ -282,7 +308,15 @@ export default function BarcodeScreen() {
</Small>
</ScrollView>
<View style={styles.panelFooter}>
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
{incomplete ? (
<>
<Button label={t("barcode.complete")} onPress={() => void contributePhoto()} />
<Spacer size={spacing.xs} />
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
</>
) : (
<Button label={t("barcode.info.scanAgain")} onPress={scanAgain} />
)}
</View>
</View>
)}
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Kunne ikke aflæse emballagen. Prøv igen med et skarpt billede og godt lys.",
"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.skipBack": "Færdig, analysér",
"barcode.contributing.shootBack": "Tag billede af bagsiden",
"barcode.complete": "Tag billede af bagsiden (næring & indhold)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du skal først have en husstand.",
"barcode.noLocation": "Ingen opbevaringssteder fundet.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Die Verpackung konnte nicht gelesen werden. Versuche es mit einem scharfen Foto und gutem Licht erneut.",
"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.skipBack": "Fertig, analysieren",
"barcode.contributing.shootBack": "Rückseite fotografieren",
"barcode.complete": "Rückseite fotografieren (Nährwerte & Zutaten)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du brauchst zuerst einen Haushalt.",
"barcode.noLocation": "Kein Lagerort gefunden.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Couldn't read the package. Try again with a sharp photo and good lighting.",
"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.skipBack": "Done, analyse",
"barcode.contributing.shootBack": "Photograph the back",
"barcode.complete": "Photograph the back (nutrition & ingredients)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "You need a household first.",
"barcode.noLocation": "No storage location found.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "No se pudo leer el envase. Inténtalo de nuevo con una foto nítida y buena luz.",
"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.skipBack": "Listo, analizar",
"barcode.contributing.shootBack": "Fotografiar la parte de atrás",
"barcode.complete": "Fotografiar la parte de atrás (nutrición e ingredientes)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Primero necesitas un hogar.",
"barcode.noLocation": "No se encontró ningún lugar de almacenamiento.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Pakkausta ei voitu lukea. Yritä uudelleen terävällä kuvalla ja hyvässä valossa.",
"barcode.contributing.frontDone": "Etupuoli kuvattu",
"barcode.contributing.backPrompt": "Kuvaa nyt takapuoli, jossa on ravintosisältö ja ainesosat näin tuote täydentyy.",
"barcode.contributing.skipBack": "Valmis, analysoi",
"barcode.contributing.shootBack": "Kuvaa takapuoli",
"barcode.complete": "Kuvaa takapuoli (ravinto & ainesosat)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Tarvitset ensin kotitalouden.",
"barcode.noLocation": "Säilytyspaikkaa ei löytynyt.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Impossible de lire l'emballage. Réessayez avec une photo nette et un bon éclairage.",
"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.skipBack": "Terminé, analyser",
"barcode.contributing.shootBack": "Photographier l'arrière",
"barcode.complete": "Photographier l'arrière (nutrition et ingrédients)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Il vous faut d'abord un foyer.",
"barcode.noLocation": "Aucun lieu de stockage trouvé.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Impossibile leggere la confezione. Riprova con una foto nitida e una buona illuminazione.",
"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.skipBack": "Fatto, analizza",
"barcode.contributing.shootBack": "Fotografa il retro",
"barcode.complete": "Fotografa il retro (valori e ingredienti)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Prima ti serve una famiglia.",
"barcode.noLocation": "Nessun luogo di conservazione trovato.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Kunne ikke lese av emballasjen. Prøv igjen med et skarpt bilde og godt lys.",
"barcode.contributing.frontDone": "Forside tatt",
"barcode.contributing.backPrompt": "Ta nå bilde av baksiden med næringsinnhold og ingredienser da blir produktet komplett.",
"barcode.contributing.skipBack": "Ferdig, analyser",
"barcode.contributing.shootBack": "Ta bilde av baksiden",
"barcode.complete": "Ta bilde av baksiden (næring og innhold)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du trenger en husstand først.",
"barcode.noLocation": "Fant ingen oppbevaringssteder.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Kon de verpakking niet lezen. Probeer het opnieuw met een scherpe foto en goed licht.",
"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.skipBack": "Klaar, analyseren",
"barcode.contributing.shootBack": "Achterkant fotograferen",
"barcode.complete": "Achterkant fotograferen (voeding & ingrediënten)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Je hebt eerst een huishouden nodig.",
"barcode.noLocation": "Geen bewaarplek gevonden.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Nie udało się odczytać opakowania. Spróbuj ponownie z ostrym zdjęciem i dobrym oświetleniem.",
"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.skipBack": "Gotowe, analizuj",
"barcode.contributing.shootBack": "Sfotografuj tył",
"barcode.complete": "Sfotografuj tył (wartości i składniki)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Najpierw potrzebujesz gospodarstwa.",
"barcode.noLocation": "Nie znaleziono miejsca przechowywania.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Não foi possível ler a embalagem. Tente novamente com uma foto nítida e boa iluminação.",
"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.skipBack": "Pronto, analisar",
"barcode.contributing.shootBack": "Fotografar a parte de trás",
"barcode.complete": "Fotografar a parte de trás (nutrição e ingredientes)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Primeiro precisa de um agregado.",
"barcode.noLocation": "Nenhum local de armazenamento encontrado.",
+5
View File
@@ -33,6 +33,11 @@
"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.failed": "Kunde inte läsa av förpackningen. Försök igen med skarp bild och bra ljus.",
"barcode.contributing.frontDone": "Framsida tagen",
"barcode.contributing.backPrompt": "Fota nu baksidan med näringsdeklaration och innehåll då blir produkten komplett.",
"barcode.contributing.skipBack": "Klar, analysera",
"barcode.contributing.shootBack": "Fota baksidan",
"barcode.complete": "Fota baksidan (näring & innehåll)",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du behöver ett hushåll först.",
"barcode.noLocation": "Ingen förvaringsplats hittades.",
+17 -7
View File
@@ -149,32 +149,41 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
}
: null;
let product: Record<string, unknown> | null = null;
if (gtin && o.productName) {
if (gtin) {
const [existing] = await ctx.db
.select()
.from(schema.products)
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
.limit(1);
if (existing) {
// Uppdatera den DELADE produkten: fyll på/uppdatera de fält fotot gav.
// Behåll namnet om inget nytt lästes (t.ex. ett baksidesfoto som bara
// ger näring/innehåll) så kan man komplettera en halvfärdig produkt.
const mergedAllergens =
o.allergensDeclared && o.allergensDeclared.length > 0
? o.allergensDeclared
: existing.allergens;
const [updated] = await ctx.db
.update(schema.products)
.set({
name: o.productName,
name: o.productName ?? existing.name,
brand: o.brand ?? existing.brand,
ingredientsText: o.ingredientsText ?? existing.ingredientsText,
allergens: (o.allergensDeclared ?? existing.allergens) as never,
allergens: mergedAllergens as never,
nutrition: (nutrition ?? existing.nutrition) as never,
updatedAt: new Date(),
})
.where(eq(schema.products.id, existing.id))
.returning();
product = updated as Record<string, unknown>;
} else {
} else if (o.productName || nutrition || o.ingredientsText) {
// Ny produkt. Helst med namn (framsida); annars fallback så datan inte
// tappas nästa skanning med framsida fyller i det riktiga namnet.
const [inserted] = await ctx.db
.insert(schema.products)
.values({
gtin,
name: o.productName,
name: o.productName ?? `Produkt ${gtin}`,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: (o.allergensDeclared ?? []) as never,
@@ -187,9 +196,10 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
.returning();
product = inserted as Record<string, unknown>;
}
} else if (o.productName) {
} else if (o.productName || nutrition || o.ingredientsText) {
// Ingen streckkod (fristående etikett) returnera transient info, spara ej.
product = {
name: o.productName,
name: o.productName ?? null,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: o.allergensDeclared ?? [],
+24 -1
View File
@@ -158,6 +158,15 @@ export async function processGenerateWeekPlan(
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
}
// Variation mellan veckor OCH mellan omgenereringar: en liten deterministisk
// "jitter" per recept, seedad på planens id + veckostart. Täckning och
// utgångsdatum dominerar fortfarande (jitter är liten), men bland likvärdiga
// rätter växlar urvalet så två veckor och en "planera om" inte blir
// identiska. Nivån styrs av varietyLevel (kriteriet användaren valt).
const varietyAmp =
data.input.varietyLevel === "high" ? 0.35 : data.input.varietyLevel === "low" ? 0.1 : 0.2;
const varietySeed = `${weekPlanId}|${data.input.weekStartDate}`;
const scored = candidates
.filter((recipe) => {
const ings = allIngredients.filter((i) => i.recipeId === recipe.id);
@@ -198,7 +207,11 @@ export async function processGenerateWeekPlan(
return {
recipe,
coverage,
score: coverage.coverage + expiryBoost + (budgetOk ? 0 : -0.5),
score:
coverage.coverage +
expiryBoost +
(budgetOk ? 0 : -0.5) +
varietyAmp * hashUnit(`${recipe.id}|${varietySeed}`),
};
})
.sort((a, b) => b.score - a.score);
@@ -337,6 +350,16 @@ export async function processGenerateWeekPlan(
});
}
/** Deterministisk sträng-hash → [0,1). FNV-1a. Ger reproducerbar variation. */
function hashUnit(s: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return (h >>> 0) / 4294967296;
}
async function defaultPortions(ctx: WorkerContext, householdId: string): Promise<number> {
const [row] = await ctx.db
.select({ total: sql<number>`coalesce(sum(${schema.householdMembers.portionFactor}), 2)` })
+5 -5
View File
@@ -968,15 +968,15 @@ Språk: ${lang}.`;
private buildNutritionPrompt(locale: LocaleContext): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Bilden visar en näringsdeklaration/ingredienslista.
return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Du kan få en eller flera bilder av SAMMA produkt typiskt framsidan (produktnamn/varumärke) och baksidan (näringsdeklaration + ingredienslista). Läs av all synlig information över ALLA bilder och slå ihop den till ett svar.
Läs av exakt det som står på etiketten. Gissa aldrig värden som inte syns. Returnera:
- basis: "per_100_g", "per_100_ml" eller "per_portion" vad gäller tabellen?
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?
- values: { kcal, proteinG, carbsG, fatG, saturatedFatG, fiberG, sugarG, saltG } null om ej synligt.
- ingredientsText: rå text med ingredienser, 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"]).
- gtin: streckkod/EAN om synlig, annars null.
- productName: produktnamn om synligt, annars null.
- productName: produktnamn (oftast på framsidan) om synligt, annars null.
- brand: varumärke om synligt, annars null.
- confidence: 0.01.0.