diff --git a/.env.example b/.env.example index c2c2003..0016a3b 100644 --- a/.env.example +++ b/.env.example @@ -44,8 +44,10 @@ AAMOS_MODE=mock AAMOS_API_URL= AAMOS_API_KEY= AAMOS_TIMEOUT_MS=60000 +# GEMINI_API_KEY lämnas tom lokalt – hämtas från AWS SSM (/cibello/prod/gemini-api-key) i prod, +# inte inskriven för hand. Skriv aldrig over en .env som redan har riktiga varden. GEMINI_API_KEY= -GEMINI_MODEL=gemini-2.5-flash +GEMINI_MODEL=gemini-3.5-flash-lite GEMINI_TIMEOUT_MS=60000 GEMINI_DAILY_BUDGET_USD=25 diff --git a/README.md b/README.md index a59f85c..09eb712 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ pnpm install docker compose -f infrastructure/docker/docker-compose.dev.yml up -d # 3. Miljövariabler -cp .env.example .env +cp -n .env.example .env # -n = skriv inte over en befintlig .env # 4. Migrera + seeda databasen pnpm db:migrate diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 703d493..6de2268 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -48,7 +48,7 @@ const configSchema = z.object({ AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000), GEMINI_API_KEY: z.string().optional(), - GEMINI_MODEL: z.string().default("gemini-2.5-flash"), + GEMINI_MODEL: z.string().default("gemini-3.5-flash-lite"), GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000), GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0), @@ -104,8 +104,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion."); process.exit(1); } - if (cfg.AAMOS_MODE === "gemini" && !cfg.GEMINI_API_KEY) { - console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion."); + if ( + cfg.AAMOS_MODE === "gemini" && + (!cfg.GEMINI_API_KEY || cfg.GEMINI_API_KEY.startsWith("ROTATE")) + ) { + console.error( + "SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver en riktig GEMINI_API_KEY (hämtas från SSM /cibello/prod/gemini-api-key), inte tom eller placeholder.", + ); process.exit(1); } if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) { diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts index 2580f68..2d9de75 100644 --- a/apps/api/src/routes/scans.ts +++ b/apps/api/src/routes/scans.ts @@ -150,6 +150,43 @@ export async function scanRoutes(app: FastifyInstance) { if (!locationId) throw errors.badRequest("storageLocationId saknas och ingen standardplats finns."); + // Dedup (#1): finns varan redan aktiv i hushållet? Uppdatera + hoppa över, + // så överlappande foton / omfotografering inte skapar dubletter. + const dedupCond = item.canonicalIngredientId + ? and( + eq(schema.inventoryItems.householdId, householdId), + eq(schema.inventoryItems.canonicalIngredientId, item.canonicalIngredientId), + gt(schema.inventoryItems.quantity, 0), + ) + : and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.canonicalIngredientId), + eq(schema.inventoryItems.displayName, item.displayName), + gt(schema.inventoryItems.quantity, 0), + ); + const [dupe] = await app.db + .select({ id: schema.inventoryItems.id }) + .from(schema.inventoryItems) + .where(dedupCond) + .limit(1); + if (dupe) { + await app.db + .update(schema.inventoryItems) + .set({ + quantity: item.quantity, + unit: item.unit, + brand: item.brand ?? null, + bestBeforeDate: item.bestBeforeDate ?? null, + useByDate: item.useByDate ?? null, + lastVerifiedAt: new Date(), + verifiedByUser: true, + updatedAt: new Date(), + }) + .where(eq(schema.inventoryItems.id, dupe.id)); + created.push(dupe.id); + continue; + } + const [inv] = await app.db .insert(schema.inventoryItems) .values({ diff --git a/apps/api/test/scans.test.ts b/apps/api/test/scans.test.ts index e4d36c8..e171aa6 100644 --- a/apps/api/test/scans.test.ts +++ b/apps/api/test/scans.test.ts @@ -109,7 +109,7 @@ describe("scan confirmation → ai_corrections", () => { }, ], }, - modelVersion: "gemini-2.5-flash", + modelVersion: "gemini-3.5-flash-lite", promptVersion: "gemini-fridge-v1", }) .returning(); diff --git a/apps/mobile/src/app/(tabs)/home.tsx b/apps/mobile/src/app/(tabs)/home.tsx index 946c779..f123f12 100644 --- a/apps/mobile/src/app/(tabs)/home.tsx +++ b/apps/mobile/src/app/(tabs)/home.tsx @@ -117,6 +117,9 @@ export default function HomeScreen() { router.push("/saved-recipes")}> ⭐ Dina sparade recept + router.push("/kitchen")}> + 🧺 Ditt kök + {budget.data && ( diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 5a7169b..96af839 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -58,7 +58,7 @@ export default function WhatToEatScreen() { queryKey: ["what-to-eat", submittedCraving], queryFn: () => api( - `/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`, + `/v1/recommendations/what-to-eat?limit=15&view=pantry${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`, ), }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 5ee9f7a..56595f7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -76,15 +76,16 @@ export default function RootLayout() { name="cooking/[id]" options={{ title: "", presentation: "fullScreenModal" }} /> - - + + - + + ; + ingredients: Array<{ + id: string; + displayNameSv: string; + quantity: number; + unit: string; + optional: boolean; + }>; } export default function CookingScreen() { @@ -45,6 +53,7 @@ export default function CookingScreen() { const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>(); const queryClient = useQueryClient(); const [stepIndex, setStepIndex] = useState(0); + const [showIngredients, setShowIngredients] = useState(false); const [timerLeft, setTimerLeft] = useState(null); const timerRef = useRef | null>(null); const [finishing, setFinishing] = useState(false); @@ -265,6 +274,40 @@ export default function CookingScreen() { {step?.temperatureC ? ` (${step.temperatureC} °C)` : ""} {step?.tip && 💡 {step.tip}} + setShowIngredients((v) => !v)} + hitSlop={8} + style={{ alignSelf: "flex-start" }} + > + + {showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")} + + + {showIngredients && ( + + + {recipe.ingredients.map((ing) => { + const scaledQty = (ing.quantity * portionsCooked) / recipe.portions; + return ( + + + {ing.displayNameSv} + {ing.optional ? " (valfritt)" : ""} + + {formatQuantity(scaledQty, ing.unit)} + + ); + })} + + + )} {step?.timerSeconds != null && ( api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"), + }); + + const remove = useMutation({ + mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }), + onMutate: async (id: string) => { + await queryClient.cancelQueries({ queryKey: ["inventory", "all"] }); + const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]); + queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) => + old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old, + ); + return { previous }; + }, + onError: (err, _id, context) => { + if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous); + Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["inventory"] }); + void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] }); + }, + }); + + if (query.isLoading) return ; + if (query.isError) return void query.refetch()} />; + const items = query.data?.items ?? []; + + return ( + + {items.length === 0 && ( + + )} + {items.map((item) => ( + + + + {item.displayName} · {formatQuantity(item.quantity, item.unit)} + +