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:
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user