diff --git a/apps/api/src/lib/cooking.ts b/apps/api/src/lib/cooking.ts index 58781b5..77a4119 100644 --- a/apps/api/src/lib/cooking.ts +++ b/apps/api/src/lib/cooking.ts @@ -26,6 +26,7 @@ export interface CompleteCookingResult { mealIds: string[]; mealBoxId: string | null; inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>; + recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>; } /** @@ -251,5 +252,17 @@ export async function completeCookingSessionCore( await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt"); } - return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions }; + return { + ok: true, + mealIds, + mealBoxId, + inventoryDeductions: deductions, + recipeIngredients: recipe.ingredients.map((ing) => ({ + canonicalIngredientId: ing.canonicalIngredientId, + displayName: ing.displayNameSv, + quantity: ing.quantity, + unit: ing.unit, + optional: ing.optional, + })), + }; } diff --git a/apps/api/src/routes/cooking-sessions.ts b/apps/api/src/routes/cooking-sessions.ts index 1dc2b81..eb98f1a 100644 --- a/apps/api/src/routes/cooking-sessions.ts +++ b/apps/api/src/routes/cooking-sessions.ts @@ -15,6 +15,7 @@ import { } from "@app/analytics"; import { trackProductAnalytics } from "../lib/helpers.js"; import { completeCookingSessionCore } from "../lib/cooking.js"; +import { updateCookingAssumptionProfile } from "@app/inventory-engine"; import { z } from "zod"; /** @@ -137,6 +138,7 @@ export async function cookingSessionRoutes(app: FastifyInstance) { /** * Complete en session. * I steg 3a: anropar samma logik som gamla /cook, men länkar allt till cookingSessionId. + * I steg 3b: sparar svar på max 2–3 frågor och uppdaterar hushållsantagandeprofiler. */ app.post("/v1/cooking-sessions/:id/complete", auth, async (req) => { const { id } = parse(idParamSchema, req.params); @@ -146,7 +148,83 @@ export async function cookingSessionRoutes(app: FastifyInstance) { throw errors.conflict("Sessionen måste vara startad för att avslutas."); } - const result = await completeCookingSessionCore(app, session, req.userId, input, req.correlationId); + const mealBoxPortions = input.mealBoxPortions ?? 0; + const plannedPortions = input.portionsCooked ?? session.plannedPortions; + const actualPortionsEaten = input.actualPortionsEaten ?? Math.max(0, plannedPortions - mealBoxPortions); + const leftoverEstimatePortions = input.leftoverEstimatePortions ?? mealBoxPortions; + + if (actualPortionsEaten + leftoverEstimatePortions > plannedPortions) { + throw errors.badRequest("Åtna portioner + rester får inte överstiga totalt antal portioner."); + } + + const result = await completeCookingSessionCore( + app, + session, + req.userId, + { ...input, portionsCooked: plannedPortions, mealBoxPortions }, + req.correlationId, + ); + + await app.db + .update(schema.cookingSessions) + .set({ + actualPortionsEaten, + leftoverEstimatePortions, + leftoverNote: input.leftoverNote ?? null, + updatedAt: new Date(), + }) + .where(eq(schema.cookingSessions.id, id)); + + // Uppdatera antagandeprofiler per ingrediens (hushållsnivå). + const date = new Date().toISOString().slice(0, 10); + for (const ing of result.recipeIngredients) { + if (ing.optional) continue; + const [existing] = await app.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, session.householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId), + ), + ) + .limit(1); + + const updatedProfile = updateCookingAssumptionProfile( + { + householdId: session.householdId, + canonicalIngredientId: ing.canonicalIngredientId, + plannedPortions, + actualPortionsEaten, + leftoverEstimatePortions, + sessionId: id, + date, + }, + existing ?? { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 }, + ); + + await app.db + .insert(schema.cookingAssumptionProfiles) + .values({ + householdId: session.householdId, + canonicalIngredientId: ing.canonicalIngredientId, + ...updatedProfile, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + schema.cookingAssumptionProfiles.householdId, + schema.cookingAssumptionProfiles.canonicalIngredientId, + ], + set: { + averageEatenPortions: updatedProfile.averageEatenPortions, + averageLeftoverPortions: updatedProfile.averageLeftoverPortions, + observationCount: updatedProfile.observationCount, + lastSessionAnswers: updatedProfile.lastSessionAnswers, + updatedAt: new Date(), + }, + }); + } const [updated] = await app.db .select() @@ -162,8 +240,10 @@ export async function cookingSessionRoutes(app: FastifyInstance) { properties: { cookingSessionId: id, recipeId: session.recipeId, - portionsCooked: session.plannedPortions, - mealBoxPortions: input.mealBoxPortions ?? 0, + portionsCooked: plannedPortions, + actualPortionsEaten, + leftoverEstimatePortions, + mealBoxPortions, }, }), ); @@ -171,6 +251,39 @@ export async function cookingSessionRoutes(app: FastifyInstance) { return { ...result, session: updated }; }); + /** Hämta antagandeprofil för ett recept (per hushåll + ingrediens). */ + app.get("/v1/recipes/:id/cooking-assumptions", auth, async (req) => { + const { id } = parse(idParamSchema, req.params); + const householdId = await requireActiveHousehold(app.db, req.userId); + await requireMembership(app.db, householdId, req.userId); + + const ings = await app.db + .select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId }) + .from(schema.recipeIngredients) + .where(eq(schema.recipeIngredients.recipeId, id)); + if (ings.length === 0) throw errors.notFound("Receptet finns inte."); + + const profiles = await app.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ings[0]!.canonicalIngredientId), + ), + ) + .limit(1); + + const p = profiles[0]; + return { + householdId, + recipeId: id, + defaultActualPortionsEaten: p?.averageEatenPortions ?? null, + defaultLeftoverEstimatePortions: p?.averageLeftoverPortions ?? null, + observationCount: p?.observationCount ?? 0, + }; + }); + /** Lista hushållets aktiva sessioner. */ app.get("/v1/cooking-sessions", auth, async (req) => { const householdId = await requireActiveHousehold(app.db, req.userId); diff --git a/apps/api/test/cooking-sessions.test.ts b/apps/api/test/cooking-sessions.test.ts index f7f7655..3a5856b 100644 --- a/apps/api/test/cooking-sessions.test.ts +++ b/apps/api/test/cooking-sessions.test.ts @@ -1,6 +1,6 @@ import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; -import { eq, inArray, count } from "drizzle-orm"; +import { and, eq, inArray, count } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, schema } from "@app/database"; @@ -43,6 +43,7 @@ describe("cooking sessions", () => { for (const m of memberships) { await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, m.householdId)); await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, m.householdId)); + await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, m.householdId)); await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.householdId, m.householdId)); await testDb.db.delete(schema.households).where(eq(schema.households.id, m.householdId)); } @@ -234,6 +235,88 @@ describe("cooking sessions", () => { expect(body.mealBoxId).toBeDefined(); }); + it("stores actual portions and leftover estimate on complete", async () => { + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + const res = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { session: { actualPortionsEaten: number; leftoverEstimatePortions: number } }; + expect(body.session.actualPortionsEaten).toBe(2); + expect(body.session.leftoverEstimatePortions).toBe(1); + }); + + it("rejects complete when eaten + leftovers exceed planned portions", async () => { + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + const res = await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 0, actualPortionsEaten: 3, leftoverEstimatePortions: 2 }, + }); + expect(res.statusCode).toBe(400); + }); + + it("updates cooking assumption profiles per household and ingredient", async () => { + await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId)); + + const start = await app.inject({ + method: "POST", + url: `/v1/recipes/${recipeId}/cook/start`, + headers: { authorization: `Bearer ${token}` }, + payload: { startNow: true, portions: 4 }, + }); + const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id; + + const recipe = (await app.inject({ + method: "GET", + url: `/v1/recipes/${recipeId}`, + headers: { authorization: `Bearer ${token}` }, + })).json() as { ingredients: Array<{ canonicalIngredientId: string }> }; + const firstIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId)?.canonicalIngredientId; + + await app.inject({ + method: "POST", + url: `/v1/cooking-sessions/${sessionId}/complete`, + headers: { authorization: `Bearer ${token}` }, + payload: { mealBoxPortions: 1, actualPortionsEaten: 2, leftoverEstimatePortions: 1 }, + }); + + if (firstIngredientId) { + const profile = await testDb.db + .select() + .from(schema.cookingAssumptionProfiles) + .where( + and( + eq(schema.cookingAssumptionProfiles.householdId, householdId), + eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstIngredientId), + ), + ) + .limit(1); + expect(profile.length).toBe(1); + expect(profile[0]!.observationCount).toBe(1); + expect(profile[0]!.averageEatenPortions).toBe(2); + expect(profile[0]!.averageLeftoverPortions).toBe(1); + } + }); + it("emits cooking_session_completed on complete and cooking_session_cancelled on cancel", async () => { // complete const startComplete = await app.inject({ diff --git a/apps/mobile/src/app/cooking/[id].tsx b/apps/mobile/src/app/cooking/[id].tsx index 9206f0d..2a97659 100644 --- a/apps/mobile/src/app/cooking/[id].tsx +++ b/apps/mobile/src/app/cooking/[id].tsx @@ -49,6 +49,8 @@ export default function CookingScreen() { const timerRef = useRef | null>(null); const [finishing, setFinishing] = useState(false); const [mealBoxPortions, setMealBoxPortions] = useState(0); + const [actualPortionsEaten, setActualPortionsEaten] = useState(null); + const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState(null); const query = useQuery({ queryKey: ["recipe", id], @@ -100,6 +102,9 @@ export default function CookingScreen() { }; if (finishing) { + const defaultEaten = actualPortionsEaten ?? Math.max(0, portionsCooked - mealBoxPortions); + const defaultLeftovers = leftoverEstimatePortions ?? mealBoxPortions; + return ( {t("cooked.title")} @@ -127,6 +132,44 @@ export default function CookingScreen() { + + {t("cooked.actualPortionsEaten")} + +