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:
+252
-4
@@ -1,16 +1,29 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
||||||
import { schema, markMilestone } from "@app/database";
|
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 { scaleNutrition } from "@app/nutrition-engine";
|
||||||
import type { Unit } from "@app/shared-types";
|
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 { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js";
|
||||||
import { errors } from "./errors.js";
|
import { errors } from "./errors.js";
|
||||||
import { loadFullRecipe } from "../routes/recipes.js";
|
import { loadFullRecipe } from "../routes/recipes.js";
|
||||||
import { userLanguageTag } from "./contentLanguage.js";
|
import { userLanguageTag } from "./contentLanguage.js";
|
||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import { MEAL_TYPES } from "@app/shared-types";
|
|
||||||
|
|
||||||
export interface CompleteCookingInput {
|
export interface CompleteCookingInput {
|
||||||
portionsCooked?: number;
|
portionsCooked?: number;
|
||||||
@@ -191,7 +204,11 @@ export async function completeCookingSessionCore(
|
|||||||
// 1. FEFO-avdrag
|
// 1. FEFO-avdrag
|
||||||
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
|
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
|
||||||
if (input.deductInventory !== false) {
|
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]) ?? []);
|
const overrides = new Map(input.inventoryOverrides?.map((o) => [o.canonicalIngredientId, o]) ?? []);
|
||||||
|
|
||||||
for (const ing of recipe.ingredients) {
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.",
|
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 {
|
function resolve(catalog: Record<string, string>, languageTag: string): string {
|
||||||
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
const lang = (languageTag.split("-")[0] ?? "sv").toLowerCase();
|
||||||
return catalog[lang] ?? catalog["en"] ?? catalog["sv"]!;
|
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);
|
return resolve(COOKED_PORTIONS_SUM_EXCEEDS_PLANNED, languageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "cooked.undoWindowExpired") {
|
||||||
|
return resolve(COOKED_UNDO_WINDOW_EXPIRED, languageTag);
|
||||||
|
}
|
||||||
|
|
||||||
if (key !== "onboarding.householdDefaultName") {
|
if (key !== "onboarding.householdDefaultName") {
|
||||||
// No other server-side keys are supported yet; fall back to a safe default.
|
// No other server-side keys are supported yet; fall back to a safe default.
|
||||||
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
return HOUSEHOLD_DEFAULT_NAMES["sv"]!;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { errors, parse } from "../lib/errors.js";
|
|||||||
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
import { requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
||||||
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
|
import { cookingSessionStarted, cookingSessionCancelled } from "@app/analytics";
|
||||||
import { trackProductAnalytics } from "../lib/helpers.js";
|
import { trackProductAnalytics } from "../lib/helpers.js";
|
||||||
import { completeCookingSession } from "../lib/cooking.js";
|
import { completeCookingSession, undoCookingSession } from "../lib/cooking.js";
|
||||||
import { z } from "zod";
|
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 params = z.object({ id: z.uuid() }).parse(req.params);
|
||||||
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
|
const body = z.object({ reason: z.string().max(200).optional() }).parse(req.body ?? {});
|
||||||
const session = await getOwnedSession(app, params.id, req.userId);
|
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.");
|
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. */
|
/** Lista hushållets aktiva sessioner. */
|
||||||
app.get("/v1/cooking-sessions", auth, async (req) => {
|
app.get("/v1/cooking-sessions", auth, async (req) => {
|
||||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.js";
|
|||||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
import { cancelTimedOutCookingSessions } from "@app/database";
|
import { cancelTimedOutCookingSessions } from "@app/database";
|
||||||
import { computeBalance } from "@app/inventory-engine";
|
import { computeBalance } from "@app/inventory-engine";
|
||||||
|
import type { Unit } from "@app/shared-types";
|
||||||
|
|
||||||
describe("cooking sessions", () => {
|
describe("cooking sessions", () => {
|
||||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||||
@@ -17,6 +18,36 @@ describe("cooking sessions", () => {
|
|||||||
let recipeId: string;
|
let recipeId: string;
|
||||||
const email = "cooking-session-test@example.invalid";
|
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() {
|
async function cleanup() {
|
||||||
const existing = await testDb.db
|
const existing = await testDb.db
|
||||||
.select({ id: schema.users.id })
|
.select({ id: schema.users.id })
|
||||||
@@ -582,4 +613,419 @@ describe("cooking sessions", () => {
|
|||||||
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
|
const timedOut = await cancelTimedOutCookingSessions(testDb.db);
|
||||||
expect(timedOut.some((s: { id: string }) => s.id === sessionId)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Ingen forskelle fundet.",
|
"scan.diff.noChanges": "Ingen forskelle fundet.",
|
||||||
"scan.diff.undo": "Fortryd",
|
"scan.diff.undo": "Fortryd",
|
||||||
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
|
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
|
||||||
"scan.diff.undo": "Rückgängig",
|
"scan.diff.undo": "Rückgängig",
|
||||||
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "No differences found.",
|
"scan.diff.noChanges": "No differences found.",
|
||||||
"scan.diff.undo": "Undo",
|
"scan.diff.undo": "Undo",
|
||||||
"scan.diff.undoHint": "Each change can be undone from the item detail view.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "No se encontraron diferencias.",
|
"scan.diff.noChanges": "No se encontraron diferencias.",
|
||||||
"scan.diff.undo": "Deshacer",
|
"scan.diff.undo": "Deshacer",
|
||||||
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Eroja ei löytynyt.",
|
"scan.diff.noChanges": "Eroja ei löytynyt.",
|
||||||
"scan.diff.undo": "Kumoa",
|
"scan.diff.undo": "Kumoa",
|
||||||
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Aucune différence trouvée.",
|
"scan.diff.noChanges": "Aucune différence trouvée.",
|
||||||
"scan.diff.undo": "Annuler",
|
"scan.diff.undo": "Annuler",
|
||||||
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article.",
|
"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é."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Nessuna differenza trovata.",
|
"scan.diff.noChanges": "Nessuna differenza trovata.",
|
||||||
"scan.diff.undo": "Annulla",
|
"scan.diff.undo": "Annulla",
|
||||||
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Ingen forskjeller funnet.",
|
"scan.diff.noChanges": "Ingen forskjeller funnet.",
|
||||||
"scan.diff.undo": "Angre",
|
"scan.diff.undo": "Angre",
|
||||||
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Geen verschillen gevonden.",
|
"scan.diff.noChanges": "Geen verschillen gevonden.",
|
||||||
"scan.diff.undo": "Ongedaan maken",
|
"scan.diff.undo": "Ongedaan maken",
|
||||||
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,5 +397,6 @@
|
|||||||
"scan.diff.noChanges": "Nie znaleziono różnic.",
|
"scan.diff.noChanges": "Nie znaleziono różnic.",
|
||||||
"scan.diff.undo": "Cofnij",
|
"scan.diff.undo": "Cofnij",
|
||||||
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
|
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
|
||||||
"scan.diff.undo": "Desfazer",
|
"scan.diff.undo": "Desfazer",
|
||||||
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,5 +383,6 @@
|
|||||||
"scan.diff.noChanges": "Inga skillnader hittades.",
|
"scan.diff.noChanges": "Inga skillnader hittades.",
|
||||||
"scan.diff.undo": "Ångra",
|
"scan.diff.undo": "Ångra",
|
||||||
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Att lägga till `prepared_food_batches` skulle skapa:
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Steg 3c – Partiell förbrukning §6.3 + undo_until
|
### 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:**
|
**Ändringar:**
|
||||||
1. Vid complete: använd `actualPortionsEaten` för att räkna om råvaror.
|
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).
|
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
|
||||||
3. `POST /v1/cooking-sessions/:id/undo`:
|
3. `POST /v1/cooking-sessions/:id/undo`:
|
||||||
- Status måste vara `completed`.
|
- Status måste vara `completed`.
|
||||||
- För varje `inventory_transactions` med `cookingSessionId`: skapa en `correction` med motsatt delta.
|
- `undo_until = completedAt + 24 h` (server-side, UTC). Efter fönstret: 409 med i18n-nyckeln `cooked.undoWindowExpired`.
|
||||||
- För varje `meal` med `cookingSessionId`: radera (eller markera som cancelled).
|
- För varje `inventory_transactions.type = 'cook_use'` med `cookingSessionId`: skapa en `correction` med motsatt delta (append-only; befintliga rader rörs inte).
|
||||||
- För varje `meal_box` med `cookingSessionId`: sätt status `discarded`.
|
- Lagersaldo räknas om från transaktionshistoriken; `depletedAt` nollas där saldot blir > 0 igen.
|
||||||
- Sätt `cooking_sessions.status = 'cancelled'`.
|
- För varje `meal` med `cookingSessionId`: radera och emitta `MEAL_REMOVED`.
|
||||||
4. Lägg `cookingSessionId` i inventory item detail så användaren kan ångra enskilda transaktioner därifrån också.
|
- 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:**
|
**Berörda filer:**
|
||||||
|
- `apps/api/src/lib/cooking.ts` (`completeCookingSession`, `undoCookingSession`)
|
||||||
- `apps/api/src/routes/cooking-sessions.ts`
|
- `apps/api/src/routes/cooking-sessions.ts`
|
||||||
- `apps/api/src/routes/inventory.ts` (visa cookingSessionId i transaktionslistan)
|
- `apps/api/src/lib/i18n.ts`
|
||||||
- `packages/inventory-engine/src/fefo.ts` (ev. helper för omräkning)
|
- `packages/inventory-engine/src/cooking-profiles.ts`
|
||||||
- `apps/mobile/src/app/cooking/[id].tsx`
|
- `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:**
|
**Testplan:**
|
||||||
- Laga 4 portioner, ät 3 → lager innehåller 25 % kvar av varje ingrediens
|
- Laga 4 portioner, ät 3 → lager innehåller 25 % kvar av varje ingrediens
|
||||||
- Undo → allt återställs
|
- 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,
|
"when": 1786049332540,
|
||||||
"tag": "0013_dark_the_anarchist",
|
"tag": "0013_dark_the_anarchist",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786055900000,
|
||||||
|
"tag": "0014_expand_event_types",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,7 @@ export const recipeSaved = builder("recipe_saved");
|
|||||||
export const cookingSessionStarted = builder("cooking_session_started");
|
export const cookingSessionStarted = builder("cooking_session_started");
|
||||||
export const cookingSessionCompleted = builder("cooking_session_completed");
|
export const cookingSessionCompleted = builder("cooking_session_completed");
|
||||||
export const cookingSessionCancelled = builder("cooking_session_cancelled");
|
export const cookingSessionCancelled = builder("cooking_session_cancelled");
|
||||||
|
export const cookingSessionUndone = builder("cooking_session_undone");
|
||||||
export const leftoversCreated = builder("leftovers_created");
|
export const leftoversCreated = builder("leftovers_created");
|
||||||
export const substitutionConfirmed = builder("substitution_confirmed");
|
export const substitutionConfirmed = builder("substitution_confirmed");
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export interface EventPayloadMap {
|
|||||||
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
|
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
|
||||||
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
|
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
|
||||||
MEAL_BOX_CONSUMED: { 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. */
|
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
|
||||||
|
|||||||
@@ -55,6 +55,51 @@ export function updateCookingAssumptionProfile(
|
|||||||
return { averageEatenPortions, averageLeftoverPortions, observationCount, lastSessionAnswers };
|
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 {
|
function clamp(n: number, min: number, max: number): number {
|
||||||
return Math.max(min, Math.min(max, n));
|
return Math.max(min, Math.min(max, n));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { updateCookingAssumptionProfile } from "../src/cooking-profiles.js";
|
import {
|
||||||
|
updateCookingAssumptionProfile,
|
||||||
|
rollbackCookingAssumptionProfile,
|
||||||
|
} from "../src/cooking-profiles.js";
|
||||||
|
|
||||||
describe("updateCookingAssumptionProfile", () => {
|
describe("updateCookingAssumptionProfile", () => {
|
||||||
it("initializes profile from first observation", () => {
|
it("initializes profile from first observation", () => {
|
||||||
@@ -61,3 +64,104 @@ describe("updateCookingAssumptionProfile", () => {
|
|||||||
expect(existing.lastSessionAnswers![0]!.sessionId).toBe("s11");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const ANALYTICS_COOKING_EVENT_NAMES = [
|
|||||||
"cooking_session_started",
|
"cooking_session_started",
|
||||||
"cooking_session_completed",
|
"cooking_session_completed",
|
||||||
"cooking_session_cancelled",
|
"cooking_session_cancelled",
|
||||||
|
"cooking_session_undone",
|
||||||
"leftovers_created",
|
"leftovers_created",
|
||||||
"substitution_confirmed",
|
"substitution_confirmed",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -413,6 +413,9 @@ export const EVENT_TYPES = [
|
|||||||
"WEEK_PLAN_UPDATED",
|
"WEEK_PLAN_UPDATED",
|
||||||
"MEAL_BOX_CREATED",
|
"MEAL_BOX_CREATED",
|
||||||
"MEAL_BOX_CONSUMED",
|
"MEAL_BOX_CONSUMED",
|
||||||
|
"MEAL_BOX_DISCARDED",
|
||||||
|
"MEAL_REMOVED",
|
||||||
|
"COOKING_SESSION_UNDONE",
|
||||||
] as const;
|
] as const;
|
||||||
export type EventType = (typeof EVENT_TYPES)[number];
|
export type EventType = (typeof EVENT_TYPES)[number];
|
||||||
|
|
||||||
@@ -433,6 +436,20 @@ export const MEAL_LOG_SOURCES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type MealLogSource = (typeof MEAL_LOG_SOURCES)[number];
|
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)
|
// Smakprofil & feedback (spec §30)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user