3c: partial consumption + undo for cooking sessions

- Append-only undo via correction transactions tied to cookingSessionId.
- POST /v1/cooking-sessions/:id/undo with 24h UTC window (409 + i18n after expiry).
- Complete undo scope: meals removed, meal boxes discarded, recipe_cooks
deleted, cookCount decremented; activation milestones left intact.
- Deterministic assumption-profile rollback via lastSessionAnswers.sessionId;
profile deleted when observationCount reaches 0.
- New status 'undone' as TEXT validated against COOKING_SESSION_STATUSES.
- Analytics event cooking_session_undone + domain events MEAL_BOX_DISCARDED,
MEAL_REMOVED, COOKING_SESSION_UNDONE.
- Partial consumption uses actualPortionsEaten/plannedPortions factor.
- i18n coverage for cooked.undoWindowExpired across all 12 locales + server.
- Invariant tests: computeBalance(transactions) === item.quantity after
complete, undo, and undo + new complete; transaction count increases on undo.
- Migration 0014_expand_event_types.sql adds new enum values.
- Updated FAS3-COOKING-SESSIONS-AUDIT.md with chosen semantics.
This commit is contained in:
Sven (AAMOS AI)
2026-08-07 05:47:54 +07:00
parent bbe7526ef3
commit cb493010d0
25 changed files with 969 additions and 30 deletions
+252 -4
View File
@@ -1,16 +1,29 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull, sql } from "drizzle-orm";
import { schema, markMilestone } from "@app/database";
import { allocateFefo, updateCookingAssumptionProfile } from "@app/inventory-engine";
import {
allocateFefo,
computeBalance,
updateCookingAssumptionProfile,
rollbackCookingAssumptionProfile,
} from "@app/inventory-engine";
import { scaleNutrition } from "@app/nutrition-engine";
import type { Unit } from "@app/shared-types";
import { cookingSessionStarted, cookingSessionCompleted } from "@app/analytics";
import {
COOKING_SESSION_STATUSES,
MEAL_BOX_STATUSES,
MEAL_TYPES,
} from "@app/shared-types";
import {
cookingSessionStarted,
cookingSessionCompleted,
cookingSessionUndone,
} from "@app/analytics";
import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js";
import { errors } from "./errors.js";
import { loadFullRecipe } from "../routes/recipes.js";
import { userLanguageTag } from "./contentLanguage.js";
import { t } from "./i18n.js";
import { MEAL_TYPES } from "@app/shared-types";
export interface CompleteCookingInput {
portionsCooked?: number;
@@ -191,7 +204,11 @@ export async function completeCookingSessionCore(
// 1. FEFO-avdrag
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory !== false) {
const factor = portionsCooked / recipe.portions;
// Partiell förbrukning (Fas 3 §6.3): om actualPortionsEaten anges drar vi
// endast råvaror för de faktiskt ätna portionerna. Rester/överskott stannar
// kvar som råvara i lagret tills de förbrukas på annat sätt.
const consumptionPortions = input.actualPortionsEaten ?? portionsCooked;
const factor = consumptionPortions / recipe.portions;
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
for (const ing of recipe.ingredients) {
@@ -405,3 +422,234 @@ export async function completeCookingSessionCore(
})),
};
}
export interface UndoCookingSessionResult {
ok: boolean;
reversedTransactions: number;
restoredItemIds: string[];
removedMealIds: string[];
discardedMealBoxIds: string[];
}
/**
* Ångra en completed cooking session (Fas 3 §6.3).
*
* - Ledger är append-only: befintliga transaktioner rörs inte; istället skrivs
* reversal-transaktioner (type='correction') med motsatt delta.
* - Inventory-saldo räknas om från transaktionshistoriken; depletedAt nollas där
* saldot blir > 0 igen.
* - meals och meal_boxes som skapades av sessionen tas bort resp. markeras
* discarded; recipe_cooks-raden tas bort och cookCount backas.
* - Aktiveringsmilstolpar rörs inte (once-ever).
* - Antagandeprofiler rullas tillbaka deterministiskt via lastSessionAnswers.
* - undo_until = completedAt + 24 h, verkställt server-side i UTC.
*/
export async function undoCookingSession(
app: FastifyInstance,
session: typeof schema.cookingSessions.$inferSelect,
userId: string,
correlationId: string,
): Promise<UndoCookingSessionResult> {
if (session.status !== "completed") {
throw errors.conflict("Sessionen måste vara avslutad för att kunna ångras.");
}
if (!session.completedAt) {
throw errors.internal("Sessionen saknar completedAt.");
}
const now = new Date();
const deadline = new Date(session.completedAt.getTime() + 24 * 60 * 60 * 1000);
if (now > deadline) {
const languageTag = await userLanguageTag(app.db, userId);
throw errors.conflict(t("cooked.undoWindowExpired", languageTag));
}
// 1. Reversera inventory-transaktioner (append-only).
const cookUses = await app.db
.select()
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.cookingSessionId, session.id),
eq(schema.inventoryTransactions.type, "cook_use"),
),
);
const affectedItemIds = new Set<string>();
for (const tx of cookUses) {
await app.db.insert(schema.inventoryTransactions).values({
householdId: tx.householdId,
inventoryItemId: tx.inventoryItemId,
cookingSessionId: session.id,
type: "correction",
quantityDelta: -tx.quantityDelta,
unit: tx.unit,
refType: "cooking_session_undo",
refId: session.id,
actorUserId: userId,
note: "Ångrat cook_use",
});
affectedItemIds.add(tx.inventoryItemId);
}
// 2. Återställ inventory-saldon från transaktionerna.
for (const itemId of affectedItemIds) {
const txs = await app.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(txs);
await app.db
.update(schema.inventoryItems)
.set({
quantity: balance.balance,
depletedAt: balance.balance <= 1e-9 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, itemId));
}
// 3. Ta bort måltider.
const mealsToRemove = await app.db
.select()
.from(schema.meals)
.where(eq(schema.meals.cookingSessionId, session.id));
for (const meal of mealsToRemove) {
await emitEvent(app.db, {
type: "MEAL_REMOVED",
payload: { mealId: meal.id, recipeId: session.recipeId, source: "cooking_session_undo" },
userId: meal.userId,
householdId: session.householdId,
correlationId,
});
}
await app.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, session.id));
// 4. Markera matlådor som discarded.
const boxesToDiscard = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
for (const box of boxesToDiscard) {
await emitEvent(app.db, {
type: "MEAL_BOX_DISCARDED",
payload: { mealBoxId: box.id, portions: box.portions, source: "cooking_session_undo" },
userId,
householdId: session.householdId,
correlationId,
});
}
await app.db
.update(schema.mealBoxes)
.set({ status: "discarded", portionsRemaining: 0 })
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
// 5. Ta bort recipe_cooks-raden och backa cookCount.
await app.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id));
await app.db
.update(schema.recipes)
.set({ cookCount: sql`GREATEST(${schema.recipes.cookCount} - 1, 0)` })
.where(eq(schema.recipes.id, session.recipeId));
// 6. Rulla tillbaka antagandeprofiler.
const recipe = await loadFullRecipe(app, session.recipeId);
for (const ing of recipe.ingredients) {
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);
if (!existing) continue;
const rolledBack = rollbackCookingAssumptionProfile(session.id, {
averageEatenPortions: existing.averageEatenPortions,
averageLeftoverPortions: existing.averageLeftoverPortions,
observationCount: existing.observationCount,
lastSessionAnswers: existing.lastSessionAnswers,
});
if (rolledBack.observationCount === 0) {
await app.db
.delete(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, session.householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, ing.canonicalIngredientId),
),
);
} else {
await app.db
.insert(schema.cookingAssumptionProfiles)
.values({
householdId: session.householdId,
canonicalIngredientId: ing.canonicalIngredientId,
averageEatenPortions: rolledBack.averageEatenPortions,
averageLeftoverPortions: rolledBack.averageLeftoverPortions,
observationCount: rolledBack.observationCount,
lastSessionAnswers: rolledBack.lastSessionAnswers,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [
schema.cookingAssumptionProfiles.householdId,
schema.cookingAssumptionProfiles.canonicalIngredientId,
],
set: {
averageEatenPortions: rolledBack.averageEatenPortions,
averageLeftoverPortions: rolledBack.averageLeftoverPortions,
observationCount: rolledBack.observationCount,
lastSessionAnswers: rolledBack.lastSessionAnswers,
updatedAt: new Date(),
},
});
}
}
// 7. Sätt sessionstatus till undone.
await app.db
.update(schema.cookingSessions)
.set({
status: "undone",
updatedAt: new Date(),
})
.where(eq(schema.cookingSessions.id, session.id));
await trackProductAnalytics(
app.db,
userId,
cookingSessionUndone({
householdId: session.householdId,
properties: {
cookingSessionId: session.id,
recipeId: session.recipeId,
reversedTransactions: cookUses.length,
},
}),
);
await emitEvent(app.db, {
type: "COOKING_SESSION_UNDONE",
payload: {
cookingSessionId: session.id,
recipeId: session.recipeId,
reversedTransactions: cookUses.length,
},
userId,
householdId: session.householdId,
correlationId,
});
return {
ok: true,
reversedTransactions: cookUses.length,
restoredItemIds: [...affectedItemIds],
removedMealIds: mealsToRemove.map((m) => m.id),
discardedMealBoxIds: boxesToDiscard.map((b) => b.id),
};
}
+19
View File
@@ -34,6 +34,21 @@ const COOKED_PORTIONS_SUM_EXCEEDS_PLANNED: Record<string, string> = {
sv: "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
};
const COOKED_UNDO_WINDOW_EXPIRED: Record<string, string> = {
da: "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet.",
de: "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen.",
en: "This cooking session can no longer be undone. The 24-hour window has expired.",
es: "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas.",
fi: "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut.",
fr: "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré.",
it: "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta.",
nb: "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt.",
nl: "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken.",
pl: "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło.",
pt: "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou.",
sv: "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
};
function resolve(catalog: Record<string, string>, languageTag: string): string {
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
@@ -44,6 +59,10 @@ export function t(key: string, languageTag: string): string {
return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag);
}
if (key === "cooked.undoWindowExpired") {
return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag);
}
if (key !== "onboarding.householdDefaultName") {
// No other server-side keys are supported yet; fall back to a safe default.
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
+14 -2
View File
@@ -10,7 +10,7 @@ import { errors, parse } from "../lib/errors.js";
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
import { trackProductAnalytics } from "../lib/helpers.js";
import { completeCookingSession } from "../lib/cooking.js";
import { completeCookingSession, undoCookingSession } from "../lib/cooking.js";
import { z } from "zod";
/**
@@ -103,7 +103,7 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
const session = await getOwnedSession(app, params.id, req.userId);
if (session.status === "completed" || session.status === "cancelled") {
if (session.status === "completed" || session.status === "cancelled" || session.status === "undone") {
throw errors.conflict("Sessionen är redan avslutad.");
}
@@ -196,6 +196,18 @@ export async function cookingSessionRoutes(app: FastifyInstance) {
};
});
/**
* Ångra en completed session inom 24 h.
* Ledger är append-only: reverseringstransaktioner skrivs, befintliga
* transaktioner rörs inte.
*/
app.post("/v1/cooking-sessions/:id/undo", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const session = await getOwnedSession(app, id, req.userId);
const result = await undoCookingSession(app, session, req.userId, req.correlationId);
return result;
});
/** Lista hushållets aktiva sessioner. */
app.get("/v1/cooking-sessions", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
+446
View File
@@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
import { cancelTimedOutCookingSessions } from "@app/database";
import { computeBalance } from "@app/inventory-engine";
import type { Unit } from "@app/shared-types";
describe("cooking sessions", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
@@ -17,6 +18,36 @@ describe("cooking sessions", () => {
let recipeId: string;
const email = "cooking-session-test@example.invalid";
async function createItemWithPurchase(canonicalIngredientId: string, displayName: string, quantity: number, unit: Unit) {
const [location] = await testDb.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(eq(schema.storageLocations.householdId, householdId))
.limit(1);
const [item] = await testDb.db
.insert(schema.inventoryItems)
.values({
householdId,
canonicalIngredientId,
displayName,
quantity,
unit,
storageLocationId: location!.id,
source: "manual_search",
})
.returning();
await testDb.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item!.id,
type: "purchase",
quantityDelta: quantity,
unit,
refType: "test_setup",
actorUserId: userId,
});
return item!.id;
}
async function cleanup() {
const existing = await testDb.db
.select({ id: schema.users.id })
@@ -582,4 +613,419 @@ describe("cooking sessions", () => {
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
});
it("undo restores inventory, removes meals, discards meal boxes and deletes recipe_cooks", async () => {
const recipe = (await app.inject({
method: "GET",
url: `/v1/recipes/${recipeId}`,
headers: { authorization: `Bearer ${token}` },
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
const firstNonOptional = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
// Sätt upp ett känt lager om receptet har en icke-valfri ingrediens.
let itemId: string | undefined;
if (firstNonOptional) {
itemId = await createItemWithPurchase(firstNonOptional, "Testvara", 1000, "GRAM");
}
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 complete = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 1, actualPortionsEaten: 3, leftoverEstimatePortions: 1 },
});
expect(complete.statusCode).toBe(200);
const txsBefore = await testDb.db
.select({ count: count(schema.inventoryTransactions.id) })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(200);
const undoBody = JSON.parse(undo.body) as { ok: boolean; removedMealIds: string[]; discardedMealBoxIds: string[] };
expect(undoBody.ok).toBe(true);
// Sessionstatus = undone.
const session = await testDb.db
.select()
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, sessionId))
.limit(1);
expect(session[0]!.status).toBe("undone");
// Append-only: fler transaktioner efter undo.
const txsAfter = await testDb.db
.select({ count: count(schema.inventoryTransactions.id) })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.cookingSessionId, sessionId));
expect(Number(txsAfter[0]!.count)).toBeGreaterThan(Number(txsBefore[0]!.count));
// Meals borttagna.
const meals = await testDb.db.select({ count: count(schema.meals.id) }).from(schema.meals).where(eq(schema.meals.cookingSessionId, sessionId));
expect(Number(meals[0]!.count)).toBe(0);
// Matlådor markerade discarded.
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
expect(boxes.length).toBe(undoBody.discardedMealBoxIds.length);
for (const box of boxes) expect(box.status).toBe("discarded");
// recipe_cooks borttagen och cookCount backad.
const cooks = await testDb.db.select().from(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, sessionId));
expect(cooks.length).toBe(0);
// Inventory-transaktionsinvariant.
if (itemId) {
const item = await testDb.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
const itemTxs = await testDb.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(itemTxs);
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
}
// Analytics.
const analytics = await testDb.db
.select()
.from(schema.productAnalyticsEvents)
.where(eq(schema.productAnalyticsEvents.userId, userId))
.orderBy(schema.productAnalyticsEvents.occurredAt);
const props = (e: (typeof analytics)[number]) => (e.properties ?? {}) as { cookingSessionId?: string };
expect(analytics.some((e) => e.eventName === "cooking_session_undone" && props(e).cookingSessionId === sessionId)).toBe(true);
if (itemId) {
await testDb.db
.delete(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId));
}
});
it("undo is rejected after 24h window", async () => {
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0 },
});
// Simulera 25 h gammal completed session.
await testDb.db
.update(schema.cookingSessions)
.set({ completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
.where(eq(schema.cookingSessions.id, sessionId));
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(409);
});
it("partial consumption deducts only actual portions eaten from inventory", async () => {
// Skapa ett enkelt recept med en enda icke-valfri ingrediens.
const [recipe] = await testDb.db
.insert(schema.recipes)
.values({
slug: `partial-${Date.now()}`,
titleSv: "Partial test",
descriptionSv: "",
cuisine: "international",
mealTypes: ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
prepTimeMinutes: 5,
cookTimeMinutes: 10,
totalTimeMinutes: 15,
portions: 4,
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
allergens: [],
spiceLevel: 0,
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
status: "published",
verificationStatus: "unverified",
sourceType: "own_editorial",
creatorDisplayName: "Test",
})
.returning();
const testRecipeId = recipe!.id;
await testDb.db.insert(schema.recipeIngredients).values({
recipeId: testRecipeId,
canonicalIngredientId: "pasta_dry",
displayNameSv: "Pasta",
quantity: 400,
unit: "GRAM",
optional: false,
sortOrder: 0,
});
await testDb.db
.delete(schema.inventoryItems)
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
const itemId = await createItemWithPurchase("pasta_dry", "Pasta", 400, "GRAM");
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${testRecipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true, portions: 4 },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
});
// 4 portioner → 400 g, 2 ätna → 200 g, alltså 200 g kvar.
const afterComplete = await testDb.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
expect(afterComplete[0]!.quantity).toBeCloseTo(200, 1);
// Undo återställer.
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(200);
const afterUndo = await testDb.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
expect(afterUndo[0]!.quantity).toBeCloseTo(400, 1);
// Städa testreceptet och lagerposten.
await testDb.db
.delete(schema.inventoryItems)
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "pasta_dry")));
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
});
it("undo + new complete maintains inventory invariant", async () => {
const [recipe] = await testDb.db
.insert(schema.recipes)
.values({
slug: `invariant-${Date.now()}`,
titleSv: "Invariant test",
descriptionSv: "",
cuisine: "international",
mealTypes: ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
prepTimeMinutes: 5,
cookTimeMinutes: 10,
totalTimeMinutes: 15,
portions: 4,
nutritionPerPortion: { kcal: 100, proteinG: 5, fatG: 3, carbsG: 12, saturatedFatG: 1, fiberG: 1, sugarG: 2, saltG: 0.1 },
allergens: [],
spiceLevel: 0,
dna: { cuisine: "international", vegetables: [], flavorProfile: [], spiceLevel: 0, method: "stovetop", timeMinutes: 15, calories: 100, proteinGrams: 5 },
status: "published",
verificationStatus: "unverified",
sourceType: "own_editorial",
creatorDisplayName: "Test",
})
.returning();
const testRecipeId = recipe!.id;
await testDb.db.insert(schema.recipeIngredients).values({
recipeId: testRecipeId,
canonicalIngredientId: "rice_white",
displayNameSv: "Ris",
quantity: 400,
unit: "GRAM",
optional: false,
sortOrder: 0,
});
await testDb.db
.delete(schema.inventoryItems)
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
const itemId = await createItemWithPurchase("rice_white", "Ris", 400, "GRAM");
async function assertInvariant() {
const item = await testDb.db
.select({ quantity: schema.inventoryItems.quantity })
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
const txs = await testDb.db
.select({ type: schema.inventoryTransactions.type, quantityDelta: schema.inventoryTransactions.quantityDelta, unit: schema.inventoryTransactions.unit })
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, itemId));
const balance = computeBalance(txs);
expect(Math.abs(balance.balance - item[0]!.quantity)).toBeLessThan(1e-6);
}
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${testRecipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true, portions: 4 },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0, actualPortionsEaten: 4, leftoverEstimatePortions: 0 },
});
await assertInvariant();
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
await assertInvariant();
// Ny session + complete efter undo ska bibehålla invarianten.
const start2 = await app.inject({
method: "POST",
url: `/v1/recipes/${testRecipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true, portions: 4 },
});
const sessionId2 = (JSON.parse(start2.body) as { session: { id: string } }).session.id;
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId2}/complete`,
headers: { authorization: `Bearer ${token}` },
payload: { mealBoxPortions: 0, actualPortionsEaten: 2, leftoverEstimatePortions: 0 },
});
await assertInvariant();
await testDb.db
.delete(schema.inventoryItems)
.where(and(eq(schema.inventoryItems.householdId, householdId), eq(schema.inventoryItems.canonicalIngredientId, "rice_white")));
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, testRecipeId));
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, testRecipeId));
});
it("legacy /cook followed by undo works", async () => {
const cook = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
});
expect(cook.statusCode).toBe(200);
const { sessionId } = JSON.parse(cook.body) as { sessionId: string };
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(200);
const session = await testDb.db
.select()
.from(schema.cookingSessions)
.where(eq(schema.cookingSessions.id, sessionId))
.limit(1);
expect(session[0]!.status).toBe("undone");
const boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
expect(boxes.every((b) => b.status === "discarded")).toBe(true);
});
it("undo rolls back cooking assumption profiles", async () => {
await testDb.db.delete(schema.cookingAssumptionProfiles).where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
const recipe = (await app.inject({
method: "GET",
url: `/v1/recipes/${recipeId}`,
headers: { authorization: `Bearer ${token}` },
})).json() as { ingredients: Array<{ canonicalIngredientId: string; optional?: boolean }> };
const firstNonOptionalIngredientId = recipe.ingredients.find((i) => i.canonicalIngredientId && !i.optional)?.canonicalIngredientId;
expect(firstNonOptionalIngredientId).toBeDefined();
const cook = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook`,
headers: { authorization: `Bearer ${token}` },
payload: { portionsCooked: 4, actualPortionsEaten: 3, leftoverEstimatePortions: 1, deductInventory: true },
});
const { sessionId } = JSON.parse(cook.body) as { sessionId: string };
await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
const profile = await testDb.db
.select()
.from(schema.cookingAssumptionProfiles)
.where(
and(
eq(schema.cookingAssumptionProfiles.householdId, householdId),
eq(schema.cookingAssumptionProfiles.canonicalIngredientId, firstNonOptionalIngredientId!),
),
)
.limit(1);
expect(profile.length).toBe(0);
});
it("cannot undo a non-completed session", async () => {
const start = await app.inject({
method: "POST",
url: `/v1/recipes/${recipeId}/cook/start`,
headers: { authorization: `Bearer ${token}` },
payload: { startNow: true },
});
const sessionId = (JSON.parse(start.body) as { session: { id: string } }).session.id;
const undo = await app.inject({
method: "POST",
url: `/v1/cooking-sessions/${sessionId}/undo`,
headers: { authorization: `Bearer ${token}` },
payload: {},
});
expect(undo.statusCode).toBe(409);
});
});
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Ingen forskelle fundet.",
"scan.diff.undo": "Fortryd",
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner."
"cooked.portionsSumExceedsPlanned": "Antal spiste portioner og rester må ikke overstige det samlede antal portioner.",
"cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
"scan.diff.undo": "Rückgängig",
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten."
"cooked.portionsSumExceedsPlanned": "Gegessene Portionen und Reste dürfen die Gesamtanzahl der Portionen nicht überschreiten.",
"cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "No differences found.",
"scan.diff.undo": "Undo",
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions."
"cooked.portionsSumExceedsPlanned": "Eaten portions and leftovers cannot exceed the total number of portions.",
"cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "No se encontraron diferencias.",
"scan.diff.undo": "Deshacer",
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones."
"cooked.portionsSumExceedsPlanned": "Las raciones comidas y las sobras no pueden superar el número total de raciones.",
"cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Eroja ei löytynyt.",
"scan.diff.undo": "Kumoa",
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää."
"cooked.portionsSumExceedsPlanned": "Syödyt annokset ja tähteet eivät voi ylittää annosten kokonaismäärää.",
"cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Aucune différence trouvée.",
"scan.diff.undo": "Annuler",
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions."
"cooked.portionsSumExceedsPlanned": "Les portions mangées et les restes ne peuvent pas dépasser le nombre total de portions.",
"cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Nessuna differenza trovata.",
"scan.diff.undo": "Annulla",
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni."
"cooked.portionsSumExceedsPlanned": "Le porzioni mangiate e gli avanzi non possono superare il numero totale di porzioni.",
"cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Ingen forskjeller funnet.",
"scan.diff.undo": "Angre",
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner."
"cooked.portionsSumExceedsPlanned": "Antall spiste porsjoner og rester kan ikke overstige det totale antallet porsjoner.",
"cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Geen verschillen gevonden.",
"scan.diff.undo": "Ongedaan maken",
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden."
"cooked.portionsSumExceedsPlanned": "Gegeten porties en restjes mogen het totaal aantal porties niet overschrijden.",
"cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken."
}
+2 -1
View File
@@ -397,5 +397,6 @@
"scan.diff.noChanges": "Nie znaleziono różnic.",
"scan.diff.undo": "Cofnij",
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji."
"cooked.portionsSumExceedsPlanned": "Zjedzone porcje i resztki nie mogą przekroczyć całkowitej liczby porcji.",
"cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
"scan.diff.undo": "Desfazer",
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções."
"cooked.portionsSumExceedsPlanned": "As porções comidas e as sobras não podem ultrapassar o número total de porções.",
"cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou."
}
+2 -1
View File
@@ -383,5 +383,6 @@
"scan.diff.noChanges": "Inga skillnader hittades.",
"scan.diff.undo": "Ångra",
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner."
"cooked.portionsSumExceedsPlanned": "Antalet ätna portioner och rester får inte överstiga det totala antalet portioner.",
"cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut."
}
+27 -11
View File
@@ -140,7 +140,7 @@ Att lägga till `prepared_food_batches` skulle skapa:
---
### Steg 3c Partiell förbrukning §6.3 + undo_until
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår.
**Mål:** Om användaren säger att de bara åt 3 av 4 portioner, ska FEFO-avdraget justeras så att motsvarande råvaror återstår. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner.
**Ändringar:**
1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror.
@@ -149,24 +149,40 @@ Att lägga till `prepared_food_batches` skulle skapa:
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
3. `POST /v1/cooking-sessions/:id/undo`:
- Status måste vara `completed`.
- För varje `inventory_transactions` med `cookingSessionId`: skapa en `correction` med motsatt delta.
- För varje `meal` med `cookingSessionId`: radera (eller markera som cancelled).
- För varje `meal_box` med `cookingSessionId`: sätt status `discarded`.
- Sätt `cooking_sessions.status = 'cancelled'`.
4. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också.
- `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.
- För varje `inventory_transactions.type = 'cook_use'` med `cookingSessionId`: skapa en `correction` med motsatt delta (append-only; befintliga rader rörs inte).
- Lagersaldo räknas om från transaktionshistoriken; `depletedAt` nollas där saldot blir > 0 igen.
- För varje `meal` med `cookingSessionId`: radera och emitta `MEAL_REMOVED`.
- För varje `meal_box` med `cookingSessionId`: sätt status `discarded`, `portionsRemaining = 0`, emitta `MEAL_BOX_DISCARDED`.
- Ta bort `recipe_cooks`-raden och backa `recipes.cookCount` med `GREATEST(cookCount - 1, 0)`.
- Sätt `cooking_sessions.status = 'undone'` (TEXT, valideras mot `COOKING_SESSION_STATUSES` i shared-types; ingen pgEnum, inga nya tabeller).
- Emitta `COOKING_SESSION_UNDONE` och analytics-event `cooking_session_undone` (consent-gatat via `trackProductAnalytics`, ingen fritext).
- Aktiveringsmilstolpar backas INTE (once-ever).
4. Antagandeprofiler rullas tillbaka deterministiskt via `lastSessionAnswers.sessionId`:
- `rollbackCookingAssumptionProfile(sessionId, existingProfile)` är en ren funktion i `packages/inventory-engine/src/cooking-profiles.ts`.
- Den tar bort det aktuella sessions-id:t från `lastSessionAnswers`, räknar om EMA från scratch i kronologisk ordning och sätter `observationCount = history.length`.
- Om historiken blir tom raderas profilen (inte nollas); det förhindrar dubbelräkning vid undo + ny complete.
5. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också.
**Berörda filer:**
- `apps/api/src/lib/cooking.ts` (`completeCookingSession`, `undoCookingSession`)
- `apps/api/src/routes/cooking-sessions.ts`
- `apps/api/src/routes/inventory.ts` (visa cookingSessionId i transaktionslistan)
- `packages/inventory-engine/src/fefo.ts` (ev. helper för omräkning)
- `apps/mobile/src/app/cooking/[id].tsx`
- `apps/api/src/lib/i18n.ts`
- `packages/inventory-engine/src/cooking-profiles.ts`
- `packages/shared-types/src/enums.ts`, `packages/shared-types/src/analytics.ts`
- `packages/events/src/index.ts`
- `packages/analytics/src/builders.ts`
- `apps/mobile/src/locales/*/common.json` (+12)
**Migration:** ingår i 0011 (kolumn cookingSessionId)
**Migration:** 0014_expand_event_types.sql (nya domänhändelser i `event_type`-enum)
**Testplan:**
- Laga 4 portioner, ät 3 → lager innehåller 25 % kvar av varje ingrediens
- Undo → allt återställs
- Invariant: `computeBalance(transaktioner) === item.quantity` efter complete och efter undo
- Invariant: `computeBalance(transaktioner) === item.quantity` efter (a) complete, (b) undo, (c) undo + ny complete
- Append-only-bevis: antalet transaktioner ökar vid undo
- Undo efter 24 h → 409 med lokaliserat fel
- Legacy `/cook` → undo fungerar via samma kodväg
---
@@ -0,0 +1,4 @@
-- Expandera event_type-enum med nya domänhändelser för 3c (undo/partiell förbrukning).
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_BOX_DISCARDED';
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'MEAL_REMOVED';
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'COOKING_SESSION_UNDONE';
@@ -92,6 +92,13 @@
"when": 1786049332540,
"tag": "0013_dark_the_anarchist",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1786055900000,
"tag": "0014_expand_event_types",
"breakpoints": true
}
]
}
+1
View File
@@ -57,6 +57,7 @@ export const recipeSaved = builder("recipe_saved");
export const cookingSessionStarted = builder("cooking_session_started");
export const cookingSessionCompleted = builder("cooking_session_completed");
export const cookingSessionCancelled = builder("cooking_session_cancelled");
export const cookingSessionUndone = builder("cooking_session_undone");
export const leftoversCreated = builder("leftovers_created");
export const substitutionConfirmed = builder("substitution_confirmed");
+7
View File
@@ -42,6 +42,13 @@ export interface EventPayloadMap {
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number };
MEAL_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string };
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
COOKING_SESSION_UNDONE: {
cookingSessionId: string;
recipeId: string;
reversedTransactions: number;
};
}
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
@@ -55,6 +55,51 @@ export function updateCookingAssumptionProfile(
return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers };
}
/**
* Återställ en antagandeprofil genom att ta bort ett specifikt sessions-id
* från historiken och räkna om medelvärdena deterministiskt från resterande
* observationer. Ren funktion alla beroenden är explicita argument.
*/
export function rollbackCookingAssumptionProfile(
sessionId: string,
existing: ExistingProfile,
): {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>;
} {
const history = (existing.lastSessionAnswers ?? []).filter((a) => a.sessionId !== sessionId);
if (history.length === 0) {
return {
averageEatenPortions: null,
averageLeftoverPortions: null,
observationCount: 0,
lastSessionAnswers: [],
};
}
// Räkna om EMA från scratch i kronologisk ordning (äldst först).
const chronological = [...history].reverse();
let avgEaten = chronological[0]!.eaten;
let avgLeftovers = chronological[0]!.leftovers;
for (let i = 1; i < chronological.length; i++) {
const alpha = 1 / (i + 1);
const a = chronological[i]!;
avgEaten = round2(avgEaten + alpha * (a.eaten - avgEaten));
avgLeftovers = round2(avgLeftovers + alpha * (a.leftovers - avgLeftovers));
}
return {
averageEatenPortions: avgEaten,
averageLeftoverPortions: avgLeftovers,
observationCount: history.length,
lastSessionAnswers: history,
};
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js";
import {
updateCookingAssumptionProfile,
rollbackCookingAssumptionProfile,
} from "../src/cooking-profiles.js";
describe("updateCookingAssumptionProfile", () => {
it("initializes profile from first observation", () => {
@@ -61,3 +64,104 @@ describe("updateCookingAssumptionProfile", () => {
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
});
});
describe("rollbackCookingAssumptionProfile", () => {
it("returns null averages and zero count when history becomes empty", () => {
const existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 3,
leftoverEstimatePortions: 1,
sessionId: "s1",
date: "2026-08-07",
},
{ averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 },
);
const rolled = rollbackCookingAssumptionProfile("s1", existing);
expect(rolled.averageEatenPortions).toBeNull();
expect(rolled.averageLeftoverPortions).toBeNull();
expect(rolled.observationCount).toBe(0);
expect(rolled.lastSessionAnswers).toHaveLength(0);
});
it("recalculates averages deterministically from remaining history", () => {
let existing: {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
// Three chronological observations.
const observations = [
{ sessionId: "s1", eaten: 4, leftovers: 0 },
{ sessionId: "s2", eaten: 2, leftovers: 1 },
{ sessionId: "s3", eaten: 3, leftovers: 1 },
];
for (const obs of observations) {
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: obs.eaten,
leftoverEstimatePortions: obs.leftovers,
sessionId: obs.sessionId,
date: "2026-08-07",
},
existing,
);
}
// Roll back the middle observation and recompute from scratch.
const rolled = rollbackCookingAssumptionProfile("s2", existing);
expect(rolled.lastSessionAnswers).toHaveLength(2);
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s1");
expect(rolled.lastSessionAnswers!.map((a) => a.sessionId)).toContain("s3");
// Recompute EMA manually from s1 then s3.
// s1: avgEaten=4, avgLeftovers=0
// s3: alpha=1/2 => eaten=4+0.5*(3-4)=3.5, leftovers=0+0.5*(1-0)=0.5
expect(rolled.averageEatenPortions).toBe(3.5);
expect(rolled.averageLeftoverPortions).toBe(0.5);
expect(rolled.observationCount).toBe(2);
});
it("is idempotent: rolling back the same session twice leaves history unchanged", () => {
let existing: {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 3,
leftoverEstimatePortions: 1,
sessionId: "s1",
date: "2026-08-07",
},
existing,
);
existing = updateCookingAssumptionProfile(
{
householdId: "h1",
canonicalIngredientId: "i1",
plannedPortions: 4,
actualPortionsEaten: 2,
leftoverEstimatePortions: 0,
sessionId: "s2",
date: "2026-08-07",
},
existing,
);
const first = rollbackCookingAssumptionProfile("s1", existing);
const second = rollbackCookingAssumptionProfile("s1", first);
expect(second).toEqual(first);
});
});
+1
View File
@@ -53,6 +53,7 @@ export const ANALYTICS_COOKING_EVENT_NAMES = [
"cooking_session_started",
"cooking_session_completed",
"cooking_session_cancelled",
"cooking_session_undone",
"leftovers_created",
"substitution_confirmed",
] as const;
+17
View File
@@ -413,6 +413,9 @@ export const EVENT_TYPES = [
"WEEK_PLAN_UPDATED",
"MEAL_BOX_CREATED",
"MEAL_BOX_CONSUMED",
"MEAL_BOX_DISCARDED",
"MEAL_REMOVED",
"COOKING_SESSION_UNDONE",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -433,6 +436,20 @@ export const MEAL_LOG_SOURCES = [
] as const;
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
/** Cooking session lifecycle (Fas 3 §6). TEXT i databasen, valideras mot denna const. */
export const COOKING_SESSION_STATUSES = [
"planned",
"started",
"completed",
"cancelled",
"undone",
] as const;
export type CookingSessionStatus = (typeof COOKING_SESSION_STATUSES)[number];
/** Matlådestatuser (spec §24). TEXT i databasen, valideras mot denna const. */
export const MEAL_BOX_STATUSES = ["available", "consumed", "discarded"] as const;
export type MealBoxStatus = (typeof MEAL_BOX_STATUSES)[number];
// ---------------------------------------------------------------------------
// Smakprofil & feedback (spec §30)
// ---------------------------------------------------------------------------