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";
|
} from "@app/validation";
|
||||||
import {
|
import {
|
||||||
checkRecipeSafety,
|
checkRecipeSafety,
|
||||||
|
computeCoverage,
|
||||||
deriveRecipeAllergens,
|
deriveRecipeAllergens,
|
||||||
scaleIngredients,
|
scaleIngredients,
|
||||||
type IngredientSafetyInfo,
|
type IngredientSafetyInfo,
|
||||||
|
type PantryItem,
|
||||||
} from "@app/recipe-engine";
|
} from "@app/recipe-engine";
|
||||||
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
|
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
|
||||||
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
|
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
|
||||||
@@ -232,6 +234,9 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt");
|
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 languageTag = await userLanguageTag(app.db, req.userId);
|
||||||
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
|
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
|
||||||
const ingredientNames = await resolveIngredientNames(
|
const ingredientNames = await resolveIngredientNames(
|
||||||
@@ -256,6 +261,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
}),
|
}),
|
||||||
safety,
|
safety,
|
||||||
variants,
|
variants,
|
||||||
|
coverage,
|
||||||
myRating: myRating ?? null,
|
myRating: myRating ?? null,
|
||||||
isFavorite: Boolean(favorite),
|
isFavorite: Boolean(favorite),
|
||||||
};
|
};
|
||||||
@@ -849,6 +855,91 @@ export async function loadFullRecipe(app: FastifyInstance, id: string) {
|
|||||||
return { ...recipe, ingredients, steps };
|
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(
|
async function ingredientSafetyMap(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ interface RecipeDetail {
|
|||||||
}>;
|
}>;
|
||||||
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
|
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
|
||||||
variants: Array<{ id: string; titleSv: string; variantType: 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;
|
myRating: { stars: number } | null;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
ratingAverage: number | null;
|
ratingAverage: number | null;
|
||||||
@@ -119,6 +128,37 @@ export default function RecipeScreen() {
|
|||||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
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<RecipeDetail["coverage"]>["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 <LoadingView />;
|
if (query.isLoading) return <LoadingView />;
|
||||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||||
const recipe = query.data;
|
const recipe = query.data;
|
||||||
@@ -177,6 +217,33 @@ export default function RecipeScreen() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{recipe.coverage && (
|
||||||
|
<Card>
|
||||||
|
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<Heading>Du har {recipe.coverage.percent}% hemma</Heading>
|
||||||
|
{recipe.coverage.missing.length > 0 && (
|
||||||
|
<Tag label={`Saknar ${recipe.coverage.missing.length}`} tone="warning" />
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{recipe.coverage.missing.length === 0 ? (
|
||||||
|
<Small>Du har allt som behövs.</Small>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Small>
|
||||||
|
Du saknar: {recipe.coverage.missing.map((m) => m.displayNameSv).join(", ")}
|
||||||
|
</Small>
|
||||||
|
<Spacer size={spacing.xs} />
|
||||||
|
<Button
|
||||||
|
label={`Lägg till ${recipe.coverage.missing.length} i inköpslistan`}
|
||||||
|
variant="primary"
|
||||||
|
loading={addMissing.isPending}
|
||||||
|
onPress={() => addMissing.mutate(recipe.coverage!.missing)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Heading>{t("recipe.ingredients")}</Heading>
|
<Heading>{t("recipe.ingredients")}</Heading>
|
||||||
|
|||||||
Reference in New Issue
Block a user