feat(recept): visa vad du saknar + lagg till i inkopslistan

Receptdetaljen (GET /v1/recipes/:id) returnerar nu coverage {percent, missing}
via samma computeCoverage som rekommendationerna, sa "du har X% hemma" stammer.
Recept-skarmen visar procenten + listan pa saknade ingredienser och en
"Lagg till N i inkopslistan"-knapp (skapar lista om ingen finns). Uppfyller
loftet i explain.ts ("resten hamnar pa inkopslistan").
This commit is contained in:
Claude
2026-08-18 22:09:34 +00:00
parent e7e884d8aa
commit d141196c21
2 changed files with 158 additions and 0 deletions
+91
View File
@@ -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[],