diff --git a/apps/api/src/routes/recipes.ts b/apps/api/src/routes/recipes.ts index c89953d..96a26d5 100644 --- a/apps/api/src/routes/recipes.ts +++ b/apps/api/src/routes/recipes.ts @@ -11,9 +11,11 @@ import { } from "@app/validation"; import { checkRecipeSafety, + computeCoverage, deriveRecipeAllergens, scaleIngredients, type IngredientSafetyInfo, + type PantryItem, } from "@app/recipe-engine"; import { allocateFefo, classifyExpiry } from "@app/inventory-engine"; import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine"; @@ -232,6 +234,9 @@ export async function recipeRoutes(app: FastifyInstance) { void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt"); } + // Täckning mot hushållets lager: vad har du hemma, vad saknas (→ inköpslista). + const coverage = await recipeCoverage(app, householdId, recipe.ingredients); + const languageTag = await userLanguageTag(app.db, req.userId); const translation = await resolveRecipeTranslation(app.db, id, languageTag); const ingredientNames = await resolveIngredientNames( @@ -256,6 +261,7 @@ export async function recipeRoutes(app: FastifyInstance) { }), safety, variants, + coverage, myRating: myRating ?? null, isFavorite: Boolean(favorite), }; @@ -849,6 +855,91 @@ export async function loadFullRecipe(app: FastifyInstance, id: string) { return { ...recipe, ingredients, steps }; } +/** + * Täckning mot hushållets lager: hur stor andel av ingredienserna du har hemma, + * och listan på vad som saknas (för "lägg till i inköpslistan"). Återanvänder + * computeCoverage – samma logik som rekommendationerna – så procenten stämmer. + */ +async function recipeCoverage( + app: FastifyInstance, + householdId: string | null, + ingredients: Array<{ + canonicalIngredientId: string; + displayNameSv: string; + quantity: number; + unit: PantryItem["unit"]; + optional: boolean; + }>, +): Promise<{ + percent: number; + missing: Array<{ + canonicalIngredientId: string; + displayNameSv: string; + quantity: number; + unit: PantryItem["unit"]; + }>; +} | null> { + if (!householdId) return null; + const stockRows = await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + density: schema.canonicalIngredients.densityGPerMl, + gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + + const pantry: PantryItem[] = stockRows.map((r) => ({ + id: r.item.id, + canonicalIngredientId: r.item.canonicalIngredientId, + quantity: r.item.quantity, + unit: r.item.unit, + bestBeforeDate: r.item.bestBeforeDate, + useByDate: r.item.useByDate, + openedAt: r.item.openedAt, + frozenAt: r.item.frozenAt, + thawedAt: r.item.thawedAt, + purchasedAt: r.item.purchasedAt, + storageLocationType: r.locationType, + shelfLifeGuidance: r.shelfLife, + })); + const unitInfo = new Map( + stockRows + .filter((r) => r.item.canonicalIngredientId) + .map((r) => [ + r.item.canonicalIngredientId!, + { densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece }, + ]), + ); + + const result = computeCoverage(ingredients, pantry, unitInfo, new Date()); + return { + percent: Math.round(result.coverage * 100), + missing: result.missing.map((m) => ({ + canonicalIngredientId: m.canonicalIngredientId, + displayNameSv: m.displayNameSv, + quantity: m.required, + unit: m.unit, + })), + }; +} + async function ingredientSafetyMap( app: FastifyInstance, ids: string[], diff --git a/apps/mobile/src/app/recipe/[id].tsx b/apps/mobile/src/app/recipe/[id].tsx index e4019b1..44bd6e9 100644 --- a/apps/mobile/src/app/recipe/[id].tsx +++ b/apps/mobile/src/app/recipe/[id].tsx @@ -56,6 +56,15 @@ interface RecipeDetail { }>; safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> }; variants: Array<{ id: string; titleSv: string; variantType: string }>; + coverage: { + percent: number; + missing: Array<{ + canonicalIngredientId: string; + displayNameSv: string; + quantity: number; + unit: string; + }>; + } | null; myRating: { stars: number } | null; isFavorite: boolean; ratingAverage: number | null; @@ -119,6 +128,37 @@ export default function RecipeScreen() { onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }), }); + // Lägg de saknade ingredienserna på inköpslistan (skapar en lista om ingen finns). + const addMissing = useMutation({ + mutationFn: async (missing: NonNullable["missing"]) => { + const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists"); + let listId = result.lists[0]?.id; + if (!listId) { + const created = await api<{ list: { id: string } }>("/v1/shopping-lists", { + method: "POST", + body: { name: t("shopping.title") }, + }); + listId = created.list.id; + } + for (const m of missing) { + await api(`/v1/shopping-lists/${listId}/items`, { + method: "POST", + body: { displayName: m.displayNameSv }, + }); + } + return missing.length; + }, + onSuccess: (count) => { + void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] }); + Alert.alert( + "Tillagt i inköpslistan", + `${count} ${count === 1 ? "vara" : "varor"} lades till.`, + ); + }, + onError: (err) => + Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")), + }); + if (query.isLoading) return ; if (query.isError || !query.data) return void query.refetch()} />; const recipe = query.data; @@ -177,6 +217,33 @@ export default function RecipeScreen() { )} + {recipe.coverage && ( + + + Du har {recipe.coverage.percent}% hemma + {recipe.coverage.missing.length > 0 && ( + + )} + + {recipe.coverage.missing.length === 0 ? ( + Du har allt som behövs. + ) : ( + <> + + Du saknar: {recipe.coverage.missing.map((m) => m.displayNameSv).join(", ")} + + +