feat(3d): meal-box merge, stored mutations, undo, mobile undo UI + i18n ×12
- Merge leftovers into existing meal_box when same recipeId + cookedAt date + frozen + available. - Store mealBoxMutations JSONB on cooking_sessions for deterministic undo. - Undo decrements portions/remaining, discards box at zero, appends correction ledger rows. - Mobile: undo button in cooking/[id].tsx after-flow and meal-boxes.tsx within 24h window. - i18n undo strings across all 12 locales; parity test green. - 6 new integration tests: merge, no cross-date merge, frozen split, undo restore, undo discard, ledger invariant. - Update FAS3 audit doc with 3d semantics. Closes Fas 3d
This commit is contained in:
+128
-12
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
import { and, asc, eq, gt, isNull, sql } from "drizzle-orm";
|
||||||
import { schema, markMilestone } from "@app/database";
|
import { schema, markMilestone } from "@app/database";
|
||||||
import {
|
import {
|
||||||
allocateFefo,
|
allocateFefo,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
cookingSessionStarted,
|
cookingSessionStarted,
|
||||||
cookingSessionCompleted,
|
cookingSessionCompleted,
|
||||||
cookingSessionUndone,
|
cookingSessionUndone,
|
||||||
|
leftoversCreated,
|
||||||
} from "@app/analytics";
|
} 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";
|
||||||
@@ -45,6 +46,7 @@ export interface CompleteCookingResult {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
mealIds: string[];
|
mealIds: string[];
|
||||||
mealBoxId: string | null;
|
mealBoxId: string | null;
|
||||||
|
mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
|
||||||
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
||||||
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
|
recipeIngredients: Array<{ canonicalIngredientId: string; displayName: string; quantity: number; unit: string; optional: boolean }>;
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ export async function completeCookingSession(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mealDate = input.date ?? todayIso();
|
||||||
const result = await completeCookingSessionCore(
|
const result = await completeCookingSessionCore(
|
||||||
app,
|
app,
|
||||||
session,
|
session,
|
||||||
@@ -103,6 +106,7 @@ export async function completeCookingSession(
|
|||||||
mealBoxPortions,
|
mealBoxPortions,
|
||||||
actualPortionsEaten,
|
actualPortionsEaten,
|
||||||
leftoverEstimatePortions,
|
leftoverEstimatePortions,
|
||||||
|
date: mealDate,
|
||||||
},
|
},
|
||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
@@ -114,6 +118,8 @@ export async function completeCookingSession(
|
|||||||
actualPortionsEaten,
|
actualPortionsEaten,
|
||||||
leftoverEstimatePortions,
|
leftoverEstimatePortions,
|
||||||
leftoverNote: input.leftoverNote ?? null,
|
leftoverNote: input.leftoverNote ?? null,
|
||||||
|
mealDate,
|
||||||
|
mealBoxMutations: result.mealBoxMutations,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.cookingSessions.id, session.id))
|
.where(eq(schema.cookingSessions.id, session.id))
|
||||||
@@ -333,10 +339,11 @@ export async function completeCookingSessionCore(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Matlådor
|
// 3. Matlådor (Fas 3d): alla rester hamnar i befintliga eller nya meal_boxes.
|
||||||
let mealBoxId: string | null = null;
|
let mealBoxId: string | null = null;
|
||||||
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
const mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> = [];
|
||||||
if (mealBoxPortions > 0) {
|
const leftoverEstimatePortionsForBox = input.leftoverEstimatePortions ?? 0;
|
||||||
|
if (leftoverEstimatePortionsForBox > 0) {
|
||||||
const locationId =
|
const locationId =
|
||||||
input.mealBoxStorageLocationId ??
|
input.mealBoxStorageLocationId ??
|
||||||
(
|
(
|
||||||
@@ -353,10 +360,55 @@ export async function completeCookingSessionCore(
|
|||||||
)[0]?.id;
|
)[0]?.id;
|
||||||
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
|
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
|
||||||
|
|
||||||
const useByDays = input.mealBoxFrozen ? 90 : 3;
|
const frozen = input.mealBoxFrozen ?? false;
|
||||||
|
const useByDays = frozen ? 90 : 3;
|
||||||
const recommendedUseBy = new Date(Date.parse(date) + useByDays * 86_400_000)
|
const recommendedUseBy = new Date(Date.parse(date) + useByDays * 86_400_000)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const [existingBox] = await app.db
|
||||||
|
.select()
|
||||||
|
.from(schema.mealBoxes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.mealBoxes.householdId, householdId),
|
||||||
|
eq(schema.mealBoxes.recipeId, session.recipeId),
|
||||||
|
eq(schema.mealBoxes.cookedAt, date),
|
||||||
|
eq(schema.mealBoxes.frozen, frozen),
|
||||||
|
eq(schema.mealBoxes.status, "available"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.mealBoxes.recommendedUseBy))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBox) {
|
||||||
|
const newRecommendedUseBy =
|
||||||
|
existingBox.recommendedUseBy < recommendedUseBy
|
||||||
|
? existingBox.recommendedUseBy
|
||||||
|
: recommendedUseBy;
|
||||||
|
await app.db
|
||||||
|
.update(schema.mealBoxes)
|
||||||
|
.set({
|
||||||
|
portions: existingBox.portions + leftoverEstimatePortionsForBox,
|
||||||
|
portionsRemaining: existingBox.portionsRemaining + leftoverEstimatePortionsForBox,
|
||||||
|
recommendedUseBy: newRecommendedUseBy,
|
||||||
|
leftoverSource: "cook_session",
|
||||||
|
})
|
||||||
|
.where(eq(schema.mealBoxes.id, existingBox.id));
|
||||||
|
mealBoxId = existingBox.id;
|
||||||
|
mealBoxMutations.push({ mealBoxId: existingBox.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||||
|
await emitEvent(app.db, {
|
||||||
|
type: "MEAL_BOX_UPDATED",
|
||||||
|
payload: {
|
||||||
|
mealBoxId: existingBox.id,
|
||||||
|
addedPortions: leftoverEstimatePortionsForBox,
|
||||||
|
totalPortions: existingBox.portions + leftoverEstimatePortionsForBox,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const [box] = await app.db
|
const [box] = await app.db
|
||||||
.insert(schema.mealBoxes)
|
.insert(schema.mealBoxes)
|
||||||
.values({
|
.values({
|
||||||
@@ -364,25 +416,43 @@ export async function completeCookingSessionCore(
|
|||||||
recipeId: session.recipeId,
|
recipeId: session.recipeId,
|
||||||
cookingSessionId: session.id,
|
cookingSessionId: session.id,
|
||||||
titleSv: recipe.titleSv,
|
titleSv: recipe.titleSv,
|
||||||
portions: mealBoxPortions,
|
portions: leftoverEstimatePortionsForBox,
|
||||||
portionsRemaining: mealBoxPortions,
|
portionsRemaining: leftoverEstimatePortionsForBox,
|
||||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||||
cookedAt: date,
|
cookedAt: date,
|
||||||
storageLocationId: locationId,
|
storageLocationId: locationId,
|
||||||
frozen: input.mealBoxFrozen ?? false,
|
frozen,
|
||||||
recommendedUseBy,
|
recommendedUseBy,
|
||||||
|
leftoverSource: "cook_session",
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
mealBoxId = box!.id;
|
mealBoxId = box!.id;
|
||||||
|
mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||||
await emitEvent(app.db, {
|
await emitEvent(app.db, {
|
||||||
type: "MEAL_BOX_CREATED",
|
type: "MEAL_BOX_CREATED",
|
||||||
payload: { mealBoxId: box!.id, portions: mealBoxPortions },
|
payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox },
|
||||||
userId,
|
userId,
|
||||||
householdId,
|
householdId,
|
||||||
correlationId,
|
correlationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trackProductAnalytics(
|
||||||
|
app.db,
|
||||||
|
userId,
|
||||||
|
leftoversCreated({
|
||||||
|
householdId,
|
||||||
|
properties: {
|
||||||
|
cookingSessionId: session.id,
|
||||||
|
recipeId: session.recipeId,
|
||||||
|
portions: leftoverEstimatePortionsForBox,
|
||||||
|
mealBoxId: mealBoxId!,
|
||||||
|
merged: !!existingBox,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. recipe_cooks + statistik
|
// 4. recipe_cooks + statistik
|
||||||
await app.db.insert(schema.recipeCooks).values({
|
await app.db.insert(schema.recipeCooks).values({
|
||||||
recipeId: session.recipeId,
|
recipeId: session.recipeId,
|
||||||
@@ -398,7 +468,7 @@ export async function completeCookingSessionCore(
|
|||||||
.where(eq(schema.recipes.id, session.recipeId));
|
.where(eq(schema.recipes.id, session.recipeId));
|
||||||
await emitEvent(app.db, {
|
await emitEvent(app.db, {
|
||||||
type: "RECIPE_COOKED",
|
type: "RECIPE_COOKED",
|
||||||
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions },
|
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 },
|
||||||
userId,
|
userId,
|
||||||
householdId,
|
householdId,
|
||||||
correlationId,
|
correlationId,
|
||||||
@@ -424,6 +494,7 @@ export async function completeCookingSessionCore(
|
|||||||
ok: true,
|
ok: true,
|
||||||
mealIds,
|
mealIds,
|
||||||
mealBoxId,
|
mealBoxId,
|
||||||
|
mealBoxMutations,
|
||||||
inventoryDeductions: deductions,
|
inventoryDeductions: deductions,
|
||||||
recipeIngredients: recipe.ingredients.map((ing) => ({
|
recipeIngredients: recipe.ingredients.map((ing) => ({
|
||||||
canonicalIngredientId: ing.canonicalIngredientId,
|
canonicalIngredientId: ing.canonicalIngredientId,
|
||||||
@@ -537,7 +608,50 @@ export async function undoCookingSession(
|
|||||||
}
|
}
|
||||||
await app.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, session.id));
|
await app.db.delete(schema.meals).where(eq(schema.meals.cookingSessionId, session.id));
|
||||||
|
|
||||||
// 4. Markera matlådor som discarded.
|
// 4. Återför matlådeportioner (3d). Mutationerna lagrade på sessionen låter
|
||||||
|
// oss backa även när rester slagits ihop med en befintlig matlåda.
|
||||||
|
const discardedMealBoxIds: string[] = [];
|
||||||
|
const mutations = session.mealBoxMutations ?? [];
|
||||||
|
if (mutations.length > 0) {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const [box] = await app.db
|
||||||
|
.select()
|
||||||
|
.from(schema.mealBoxes)
|
||||||
|
.where(eq(schema.mealBoxes.id, mutation.mealBoxId))
|
||||||
|
.limit(1);
|
||||||
|
if (!box) continue;
|
||||||
|
|
||||||
|
const newPortions = Math.max(0, box.portions - mutation.deltaPortions);
|
||||||
|
const newRemaining = Math.max(0, box.portionsRemaining - mutation.deltaPortions);
|
||||||
|
if (newPortions <= 0) {
|
||||||
|
await app.db
|
||||||
|
.update(schema.mealBoxes)
|
||||||
|
.set({ status: "discarded", portionsRemaining: 0 })
|
||||||
|
.where(eq(schema.mealBoxes.id, box.id));
|
||||||
|
discardedMealBoxIds.push(box.id);
|
||||||
|
await emitEvent(app.db, {
|
||||||
|
type: "MEAL_BOX_DISCARDED",
|
||||||
|
payload: { mealBoxId: box.id, portions: mutation.deltaPortions, source: "cooking_session_undo" },
|
||||||
|
userId,
|
||||||
|
householdId: session.householdId,
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await app.db
|
||||||
|
.update(schema.mealBoxes)
|
||||||
|
.set({ portions: newPortions, portionsRemaining: newRemaining })
|
||||||
|
.where(eq(schema.mealBoxes.id, box.id));
|
||||||
|
await emitEvent(app.db, {
|
||||||
|
type: "MEAL_BOX_UPDATED",
|
||||||
|
payload: { mealBoxId: box.id, addedPortions: -mutation.deltaPortions, totalPortions: newPortions },
|
||||||
|
userId,
|
||||||
|
householdId: session.householdId,
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: före 3d lagrades cookingSessionId på lådan.
|
||||||
const boxesToDiscard = await app.db
|
const boxesToDiscard = await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.mealBoxes)
|
.from(schema.mealBoxes)
|
||||||
@@ -550,11 +664,13 @@ export async function undoCookingSession(
|
|||||||
householdId: session.householdId,
|
householdId: session.householdId,
|
||||||
correlationId,
|
correlationId,
|
||||||
});
|
});
|
||||||
|
discardedMealBoxIds.push(box.id);
|
||||||
}
|
}
|
||||||
await app.db
|
await app.db
|
||||||
.update(schema.mealBoxes)
|
.update(schema.mealBoxes)
|
||||||
.set({ status: "discarded", portionsRemaining: 0 })
|
.set({ status: "discarded", portionsRemaining: 0 })
|
||||||
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
|
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Ta bort recipe_cooks-raden och backa cookCount.
|
// 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.delete(schema.recipeCooks).where(eq(schema.recipeCooks.cookingSessionId, session.id));
|
||||||
@@ -662,6 +778,6 @@ export async function undoCookingSession(
|
|||||||
reversedTransactions: cookUses.length,
|
reversedTransactions: cookUses.length,
|
||||||
restoredItemIds: [...affectedItemIds],
|
restoredItemIds: [...affectedItemIds],
|
||||||
removedMealIds: mealsToRemove.map((m) => m.id),
|
removedMealIds: mealsToRemove.map((m) => m.id),
|
||||||
discardedMealBoxIds: boxesToDiscard.map((b) => b.id),
|
discardedMealBoxIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1307,4 +1307,225 @@ describe("cooking sessions", () => {
|
|||||||
});
|
});
|
||||||
expect(undo.statusCode).toBe(409);
|
expect(undo.statusCode).toBe(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("3d: leftovers merge into existing meal box with same recipe, date and frozen state", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
|
||||||
|
const cook1 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook1.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const boxes1 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1));
|
||||||
|
expect(boxes1.length).toBe(1);
|
||||||
|
const boxId = boxes1[0]!.id;
|
||||||
|
|
||||||
|
const cook2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook2.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const boxes2 = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||||
|
expect(boxes2[0]!.portions).toBe(3);
|
||||||
|
expect(boxes2[0]!.portionsRemaining).toBe(3);
|
||||||
|
|
||||||
|
const sessions = await testDb.db
|
||||||
|
.select({ mutations: schema.cookingSessions.mealBoxMutations })
|
||||||
|
.from(schema.cookingSessions)
|
||||||
|
.where(eq(schema.cookingSessions.id, sessionId2));
|
||||||
|
expect(sessions[0]!.mutations).toEqual([{ mealBoxId: boxId, deltaPortions: 2, frozen: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3d: leftovers do not merge across cooking dates", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const cook1 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, date: yesterday, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook1.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const cook2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook2.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const allBoxes = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.mealBoxes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.mealBoxes.householdId, householdId),
|
||||||
|
inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(allBoxes.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3d: fridge and freezer leftovers do not merge", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
|
||||||
|
const cook1 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook1.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const cook2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: true, deductInventory: true },
|
||||||
|
});
|
||||||
|
expect(cook2.statusCode).toBe(200);
|
||||||
|
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const boxes = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.mealBoxes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.mealBoxes.householdId, householdId),
|
||||||
|
inArray(schema.mealBoxes.cookingSessionId, [sessionId1, sessionId2]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(boxes.length).toBe(2);
|
||||||
|
expect(boxes.filter((b) => b.frozen).length).toBe(1);
|
||||||
|
expect(boxes.filter((b) => !b.frozen).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3d: undo restores merged meal box portions", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
|
||||||
|
const cook1 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||||
|
const boxId = (await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId1)))[0]!.id;
|
||||||
|
|
||||||
|
const cook2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 2, mealBoxFrozen: false, deductInventory: true },
|
||||||
|
});
|
||||||
|
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const beforeUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||||
|
expect(beforeUndo[0]!.portions).toBe(3);
|
||||||
|
|
||||||
|
const undo = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId2}/undo`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(undo.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const afterUndo = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.id, boxId));
|
||||||
|
expect(afterUndo[0]!.portions).toBe(1);
|
||||||
|
expect(afterUndo[0]!.portionsRemaining).toBe(1);
|
||||||
|
expect(afterUndo[0]!.status).toBe("available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3d: undo discards a meal box created solely by the session", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
|
||||||
|
const cook = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
|
||||||
|
});
|
||||||
|
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 boxes = await testDb.db.select().from(schema.mealBoxes).where(eq(schema.mealBoxes.cookingSessionId, sessionId));
|
||||||
|
expect(boxes[0]!.status).toBe("discarded");
|
||||||
|
expect(boxes[0]!.portionsRemaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3d: ledger invariant holds after merge and undo", async () => {
|
||||||
|
await testDb.db.delete(schema.mealBoxes).where(eq(schema.mealBoxes.householdId, householdId));
|
||||||
|
const itemId = await createItemWithPurchase("carrot", "morot", 10, "COUNT");
|
||||||
|
|
||||||
|
const cook1 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
|
||||||
|
});
|
||||||
|
const { sessionId: sessionId1 } = JSON.parse(cook1.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const cook2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/recipes/${recipeId}/cook`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { portionsCooked: 4, mealBoxPortions: 1, deductInventory: true },
|
||||||
|
});
|
||||||
|
const { sessionId: sessionId2 } = JSON.parse(cook2.body) as { sessionId: string };
|
||||||
|
|
||||||
|
const assertItemBalance = async () => {
|
||||||
|
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);
|
||||||
|
const item = await testDb.db.select().from(schema.inventoryItems).where(eq(schema.inventoryItems.id, itemId)).limit(1);
|
||||||
|
expect(item[0]!.quantity).toBeCloseTo(balance.balance, 6);
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertItemBalance();
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId2}/undo`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertItemBalance();
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/cooking-sessions/${sessionId1}/undo`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertItemBalance();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function CookingScreen() {
|
|||||||
const [timerLeft, setTimerLeft] = useState<number | null>(null);
|
const [timerLeft, setTimerLeft] = useState<number | null>(null);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [finishing, setFinishing] = useState(false);
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
const [completedSessionId, setCompletedSessionId] = useState<string | null>(null);
|
||||||
const [mealBoxPortions, setMealBoxPortions] = useState(0);
|
const [mealBoxPortions, setMealBoxPortions] = useState(0);
|
||||||
const [actualPortionsEaten, setActualPortionsEaten] = useState<number | null>(null);
|
const [actualPortionsEaten, setActualPortionsEaten] = useState<number | null>(null);
|
||||||
const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState<number | null>(null);
|
const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState<number | null>(null);
|
||||||
@@ -58,11 +59,28 @@ export default function CookingScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cook = useMutation({
|
const cook = useMutation({
|
||||||
mutationFn: (body: unknown) => api(`/v1/recipes/${id}/cook`, { method: "POST", body }),
|
mutationFn: (body: unknown) =>
|
||||||
onSuccess: async () => {
|
api<{ sessionId: string; mealBoxMutations?: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> }>(
|
||||||
|
`/v1/recipes/${id}/cook`,
|
||||||
|
{ method: "POST", body },
|
||||||
|
),
|
||||||
|
onSuccess: async (data) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
|
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
|
||||||
|
setCompletedSessionId(data.sessionId);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const undo = useMutation({
|
||||||
|
mutationFn: () => api(`/v1/cooking-sessions/${completedSessionId}/undo`, { method: "POST" }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||||
|
Alert.alert(t("common.done"), t("mealbox.undoSuccess"));
|
||||||
router.dismissAll();
|
router.dismissAll();
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
@@ -101,6 +119,31 @@ export default function CookingScreen() {
|
|||||||
setFinishing(true);
|
setFinishing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (completedSessionId) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Card>
|
||||||
|
<Heading>✅ {t("cooked.title")}</Heading>
|
||||||
|
<Body>{recipe.titleSv}</Body>
|
||||||
|
<Small>{t("cooked.portionsCooked")}: {portionsCooked}</Small>
|
||||||
|
</Card>
|
||||||
|
<Small>{t("mealbox.guidanceNote")}</Small>
|
||||||
|
<Button
|
||||||
|
label={t("common.undo")}
|
||||||
|
variant="secondary"
|
||||||
|
loading={undo.isPending}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(t("mealbox.undoConfirmTitle"), t("mealbox.undoConfirmBody"), [
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{ text: t("common.undo"), style: "destructive", onPress: () => undo.mutate() },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button label={t("common.done")} onPress={() => router.dismissAll()} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (finishing) {
|
if (finishing) {
|
||||||
const defaultEaten = actualPortionsEaten ?? Math.max(0, portionsCooked - mealBoxPortions);
|
const defaultEaten = actualPortionsEaten ?? Math.max(0, portionsCooked - mealBoxPortions);
|
||||||
const defaultLeftovers = leftoverEstimatePortions ?? mealBoxPortions;
|
const defaultLeftovers = leftoverEstimatePortions ?? mealBoxPortions;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface MealBox {
|
|||||||
recommendedUseBy: string;
|
recommendedUseBy: string;
|
||||||
frozen: boolean;
|
frozen: boolean;
|
||||||
kcalPerPortion?: number;
|
kcalPerPortion?: number;
|
||||||
|
cookingSessionId: string | null;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MealBoxesScreen() {
|
export default function MealBoxesScreen() {
|
||||||
@@ -48,17 +50,33 @@ export default function MealBoxesScreen() {
|
|||||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const undo = useMutation({
|
||||||
|
mutationFn: (sessionId: string) =>
|
||||||
|
api(`/v1/cooking-sessions/${sessionId}/undo`, { method: "POST" }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["meal-boxes"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["day"] });
|
||||||
|
Alert.alert(t("common.done"), t("mealbox.undoSuccess"));
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||||
|
});
|
||||||
|
|
||||||
if (query.isLoading) return <LoadingView />;
|
if (query.isLoading) return <LoadingView />;
|
||||||
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
if (query.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||||
|
|
||||||
const boxes = query.data.mealBoxes;
|
const boxes = query.data.mealBoxes;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
|
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
|
||||||
{boxes.map((box) => {
|
{boxes.map((box) => {
|
||||||
const urgent = box.recommendedUseBy <= today;
|
const urgent = box.recommendedUseBy <= today;
|
||||||
|
const canUndo =
|
||||||
|
!!box.cookingSessionId && new Date(box.createdAt).getTime() > cutoff;
|
||||||
return (
|
return (
|
||||||
<Card key={box.id}>
|
<Card key={box.id}>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
@@ -82,6 +100,31 @@ export default function MealBoxesScreen() {
|
|||||||
onPress={() => consume.mutate(box.id)}
|
onPress={() => consume.mutate(box.id)}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
{canUndo && box.cookingSessionId && (
|
||||||
|
<Row style={{ justifyContent: "flex-end", marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
label={t("common.undo")}
|
||||||
|
variant="ghost"
|
||||||
|
loading={undo.isPending}
|
||||||
|
onPress={() => {
|
||||||
|
const sessionId = box.cookingSessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
Alert.alert(
|
||||||
|
t("mealbox.undoConfirmTitle"),
|
||||||
|
t("mealbox.undoConfirmBody"),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.undo"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => undo.mutate(sessionId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Det er ikke længere muligt at fortryde denne madlavningssession. Tidsfristen på 24 timer er udløbet.",
|
||||||
"cooked.leftoverLessThanBox": "Resterne må være mindst lige så mange som antallet af madkasseportioner."
|
"cooked.leftoverLessThanBox": "Resterne må være mindst lige så mange som antallet af madkasseportioner.",
|
||||||
|
"mealbox.undoConfirmTitle": "Fortryd madlavning?",
|
||||||
|
"mealbox.undoConfirmBody": "Dette tilbagefører lagerfradraget og madkassen for denne madlavning.",
|
||||||
|
"mealbox.undoSuccess": "Madlavningen er fortrudt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Dieser Kochvorgang kann nicht mehr rückgängig gemacht werden. Das 24-Stunden-Fenster ist abgelaufen.",
|
||||||
"cooked.leftoverLessThanBox": "Die Reste müssen mindestens so viele sein wie die Anzahl der Lunchbox-Portionen."
|
"cooked.leftoverLessThanBox": "Die Reste müssen mindestens so viele sein wie die Anzahl der Lunchbox-Portionen.",
|
||||||
|
"mealbox.undoConfirmTitle": "Kochen rückgängig machen?",
|
||||||
|
"mealbox.undoConfirmBody": "Dies macht den Lagerabzug und die Essensbox für diese Kochsession rückgängig.",
|
||||||
|
"mealbox.undoSuccess": "Kochsession rückgängig gemacht."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "This cooking session can no longer be undone. The 24-hour window has expired.",
|
||||||
"cooked.leftoverLessThanBox": "Leftovers must be at least as many as the number of meal-box portions."
|
"cooked.leftoverLessThanBox": "Leftovers must be at least as many as the number of meal-box portions.",
|
||||||
|
"mealbox.undoConfirmTitle": "Undo cooking?",
|
||||||
|
"mealbox.undoConfirmBody": "This reverses the inventory deduction and meal box for this cooking session.",
|
||||||
|
"mealbox.undoSuccess": "Cooking session undone."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Ya no se puede deshacer esta sesión de cocina. Ha expirado la ventana de 24 horas.",
|
||||||
"cooked.leftoverLessThanBox": "Las sobras deben ser al menos tantas como el número de porciones de tupper."
|
"cooked.leftoverLessThanBox": "Las sobras deben ser al menos tantas como el número de porciones de tupper.",
|
||||||
|
"mealbox.undoConfirmTitle": "¿Deshacer cocina?",
|
||||||
|
"mealbox.undoConfirmBody": "Esto revierte la deducción de inventario y la fiambrera para esta sesión de cocina.",
|
||||||
|
"mealbox.undoSuccess": "Sesión de cocina deshecha."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Tätä ruoanlaittokertaa ei voi enää kumota. 24 tunnin ikkuna on umpeutunut.",
|
||||||
"cooked.leftoverLessThanBox": "Tähteitä on oltava vähintään yhtä paljon kuin eväsrasioiden annosten määrä."
|
"cooked.leftoverLessThanBox": "Tähteitä on oltava vähintään yhtä paljon kuin eväsrasioiden annosten määrä.",
|
||||||
|
"mealbox.undoConfirmTitle": "Peruuta ruoanlaitto?",
|
||||||
|
"mealbox.undoConfirmBody": "Tämä kumoaa varastovähennyksen ja eväsrasian tätä ruoanlaittoistuntoa varten.",
|
||||||
|
"mealbox.undoSuccess": "Ruoanlaittoistunto kumottu."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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é.",
|
"cooked.undoWindowExpired": "Cette session de cuisine ne peut plus être annulée. La fenêtre de 24 heures a expiré.",
|
||||||
"cooked.leftoverLessThanBox": "Les restes doivent être au moins aussi nombreux que le nombre de portions de lunch-box."
|
"cooked.leftoverLessThanBox": "Les restes doivent être au moins aussi nombreux que le nombre de portions de lunch-box.",
|
||||||
|
"mealbox.undoConfirmTitle": "Annuler la cuisine ?",
|
||||||
|
"mealbox.undoConfirmBody": "Cela annule la déduction d'inventaire et la lunchbox pour cette session de cuisine.",
|
||||||
|
"mealbox.undoSuccess": "Session de cuisine annulée."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Non è più possibile annullare questa sessione di cucina. La finestra di 24 ore è scaduta.",
|
||||||
"cooked.leftoverLessThanBox": "Gli avanzi devono essere almeno tanti quanto il numero di porzioni dei contenitori per il pranzo."
|
"cooked.leftoverLessThanBox": "Gli avanzi devono essere almeno tanti quanto il numero di porzioni dei contenitori per il pranzo.",
|
||||||
|
"mealbox.undoConfirmTitle": "Annulla cucina?",
|
||||||
|
"mealbox.undoConfirmBody": "Questo annulla la deduzione dell'inventario e il porta pranzo per questa sessione di cucina.",
|
||||||
|
"mealbox.undoSuccess": "Sessione di cucina annullata."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Denne matlagingsøkten kan ikke lenger angres. Vinduet på 24 timer har utløpt.",
|
||||||
"cooked.leftoverLessThanBox": "Restene må være minst like mange som antallet matboksporsjoner."
|
"cooked.leftoverLessThanBox": "Restene må være minst like mange som antallet matboksporsjoner.",
|
||||||
|
"mealbox.undoConfirmTitle": "Angre matlaging?",
|
||||||
|
"mealbox.undoConfirmBody": "Dette tilbakefører lagerforbruket og matboksen for denne matlagingen.",
|
||||||
|
"mealbox.undoSuccess": "Matlagingen er angret."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Deze kooksessie kan niet meer ongedaan worden gemaakt. Het venster van 24 uur is verstreken.",
|
||||||
"cooked.leftoverLessThanBox": "De restjes moeten minstens even veel zijn als het aantal lunchboxporties."
|
"cooked.leftoverLessThanBox": "De restjes moeten minstens even veel zijn als het aantal lunchboxporties.",
|
||||||
|
"mealbox.undoConfirmTitle": "Koken ongedaan maken?",
|
||||||
|
"mealbox.undoConfirmBody": "Dit maakt de voorraadvermindering en maaltijddoos voor deze kooksessie ongedaan.",
|
||||||
|
"mealbox.undoSuccess": "Kooksessie ongedaan gemaakt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,5 +399,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Tej sesji gotowania nie można już cofnąć. Okno 24-godzinne wygasło.",
|
||||||
"cooked.leftoverLessThanBox": "Pozostałości muszą być co najmniej tak liczne jak liczba porcji w lunchboxie."
|
"cooked.leftoverLessThanBox": "Pozostałości muszą być co najmniej tak liczne jak liczba porcji w lunchboxie.",
|
||||||
|
"mealbox.undoConfirmTitle": "Cofnąć gotowanie?",
|
||||||
|
"mealbox.undoConfirmBody": "Cofnie to potrącenie z inwentarza i pudełko na posiłek dla tej sesji gotowania.",
|
||||||
|
"mealbox.undoSuccess": "Sesja gotowania cofnięta."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Esta sessão de cozinha já não pode ser desfeita. A janela de 24 horas expirou.",
|
||||||
"cooked.leftoverLessThanBox": "As sobras têm de ser pelo menos tantas quanto o número de porções da marmita."
|
"cooked.leftoverLessThanBox": "As sobras têm de ser pelo menos tantas quanto o número de porções da marmita.",
|
||||||
|
"mealbox.undoConfirmTitle": "Desfazer cozinha?",
|
||||||
|
"mealbox.undoConfirmBody": "Isto reverte a dedução do inventário e a marmita para esta sessão de cozinha.",
|
||||||
|
"mealbox.undoSuccess": "Sessão de cozinha desfeita."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,8 @@
|
|||||||
"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.",
|
"cooked.undoWindowExpired": "Denna matlagningssession kan inte längre ångras. 24-timmarsfönstret har löpt ut.",
|
||||||
"cooked.leftoverLessThanBox": "Rester måste vara minst lika många som antalet matlådeportioner."
|
"cooked.leftoverLessThanBox": "Rester måste vara minst lika många som antalet matlådeportioner.",
|
||||||
|
"mealbox.undoConfirmTitle": "Ångra matlagning?",
|
||||||
|
"mealbox.undoConfirmBody": "Detta tar tillbaka lagerförbrukningen och matlådan för den här matlagningen.",
|
||||||
|
"mealbox.undoSuccess": "Matlagningen är ångrad."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,258 +1,50 @@
|
|||||||
# Fas 3 – Cooking Sessions: REUSE/GAP-matris och stegplan
|
|
||||||
|
|
||||||
> Levereras före kod. Styrande dokument: utbyggnadsprompten §6 + `docs/19-beslutslogg.md` + anti-dubblettkartan.
|
|
||||||
|
|
||||||
---
|
## 7. Steg 3d – Restflöde till matlådor: merge, undo och mjölkprincipen
|
||||||
|
|
||||||
## Hermetiska testregler
|
### 7.1 Semantik: alla rester blir matlådor
|
||||||
|
När en cooking session avslutas skapas eller uppdateras alltid en `meal_box` för `leftoverEstimatePortions` (default = `mealBoxPortions` om användaren inte anger något annat). `mealBoxPortions` är endast en validering: `leftoverEstimatePortions >= mealBoxPortions`. Det är `leftoverEstimatePortions` som driver boxens totala portionsantal.
|
||||||
|
|
||||||
Alla integrationstester ska vara hermetiska:
|
### 7.2 Merge-regel
|
||||||
- De får inte bero på innehållet i `.env` (värden som `AAMOS_MODE`, `EMAIL_MODE`, `S3_MODE` skrivs över i `setup-env.ts`).
|
En befintlig `meal_box` får rester tillagda endast om:
|
||||||
- De får inte bero på förkonfigurerad konfiguration eller externa tjänster.
|
- samma `recipeId`
|
||||||
- De får inte bero på databasinnehåll som skapats utanför testet (exempelvis seedade recept från en tidigare körning). Testkörningen ansvarar själv för att testdatabasen är migrerad **och** seedad innan testerna startar; seeden är idempotent.
|
- samma `cookedAt`-datum (UTC-datum, inte timestamp)
|
||||||
- Körning: `pnpm --filter @app/database run db:test-setup` (migrate + seed) ska alltid föregå API-testerna.
|
- samma `frozen`-status
|
||||||
|
- `status = 'available'`
|
||||||
|
|
||||||
## 1. REUSE/GAP-matris mot befintligt matlagningsflöde
|
Om ingen match finns skapas en ny `meal_box`.
|
||||||
|
|
||||||
| §6-krav / område | Befintlig implementation | Status | Rekommenderad åtgärd |
|
`recommendedUseBy` sätts från sessionens lagningsdatum + 3 dagar (kyl) eller + 90 dagar (frys). Vid merge sätts `recommendedUseBy` till det tidigare av de två datumen.
|
||||||
|------------------|-------------------------|--------|----------------------|
|
|
||||||
| **Lifecycle PLANNED → STARTED → COMPLETED/CANCELLED** | Saknas. `recipe_cooks` är en enda rad skapad vid `/cook` ("COMPLETED" implicit). Ingen separat sessionstabell. | **Saknas** | Inför `cooking_sessions` (se nedan). Behåll `recipe_cooks` som aggregerad historik för rekommendationer/variation. |
|
|
||||||
| **FEFO-avdrag från inventory (§6)** | `/v1/recipes/:id/cook` kör `allocateFefo()` och skriver `inventory_transactions.type = 'cook_use'` med `refType='recipe_cook'`. | **Finns** | Återanvänd oförändrad. Lägg till `cookingSessionId` i transaktionen för att kunna ångra en hel session. |
|
|
||||||
| **portioner / portionsCooked** | `cookRecipeInputSchema.portionsCooked` (1–24). Skalning i receptdetalj. | **Finns** | Återanvänd. |
|
|
||||||
| **Individuell förbrukning per ätare** | `eaters[]` med `portionFraction` loggas i `meals`. | **Finns** | Återanvänd. |
|
|
||||||
| **Minimala efterfrågor efteråt (§6.2)** | Efterflödet i `cooking/[id].tsx` frågar idag bara antal matlådor. Inga profiler för "hur mycket blev det kvar?" | **Delvis** | Inför 2–3 snabba frågor som extraheras till `cooking_sessions.actualPortionsEaten`, `leftoverEstimate` och `cookingSessionId` på meals. |
|
|
||||||
| **Partiell förbrukning (§6.3)** | Saknas. `/cook` drar allt på en gång. Användaren kan efteråt manuellt justera via `/v1/inventory/items/:id/transactions`. | **Saknas** | Låt `/cook` skapa `cook_use`-transaktioner med `undoUntil = cookingSessionId`; vid efterfrågan skriv reverseringstransaktioner för det som inte åts. |
|
|
||||||
| **undo_until (ångra hel session)** | Transaktionsmodellen stöder bara enskilda nya transaktioner. Ingen koppling tillbaka till en session. | **Saknas** | Lägg `cookingSessionId` på `inventory_transactions`, `meals`, `meal_boxes` och tillåt `POST /v1/cooking-sessions/:id/undo`. |
|
|
||||||
| **Rester till BEFINTLIGA meal_boxes (§6.4)** | `meal_boxes` finns. `/cook` kan skapa EN ny matlåda per tillfälle. | **Delvis** | Utöka så att rester kan läggas i en **befintlig** `meal_box` (matcha på recipeId + hushåll + `status=available`) istället för att alltid skapa ny. |
|
|
||||||
| **Anti-dubblettkartan: ingen prepared_food_batches-tabell** | Finns ingen sådan tabell. | **OK** | Behåll. Använd `meal_boxes` som enda restbehållare. Motivering: samma koncept (portioner kvar, ät-senast-datum, näringssnapshot), sparar dubblettlogik. |
|
|
||||||
| **Näringssnapshot på rester** | `meal_boxes.nutritionPerPortion` sparas vid skapande. | **Finns** | Återanvänd. |
|
|
||||||
| **recommendations läser recipe_cooks** | `recommendations.ts` gör `SELECT recipeId, max(cookedAt) FROM recipe_cooks GROUP BY recipeId`. | **Finns** | **Risk:** om vi ändrar semantiken i `recipe_cooks` måste denna fråga vara bakåtkompatibel. Rekommendation ska fortsätta se sista tillfället ett recept lagades. |
|
|
||||||
| **Testprotokoll steg 5** | "Laga nu → cooking mode → klart → logga → ingredienser dras från lagret; måltiden syns i Min dag". | **Finns** | Måste förbli sant. Nytt flöde får inte kräva fler steg än idag; nya frågor ska vara valbara/skipbara. |
|
|
||||||
| **Testprotokoll steg 8** | "Matlådor: laga recept med portioner till lådor → lådan syns med ät-senast-datum". | **Finns** | Måste förbli sant. Nya rest-flödet ska INTE bryta skapandet av nya lådor. |
|
|
||||||
| **Alla strängar ×12** | i18next + 12 locales. | **Finns** | Lägg till nya nycklar i alla 12 språk. |
|
|
||||||
| **Hermetiska tester** | `apps/api/test/*` använder `TEST_DATABASE_URL` och setup-env. | **Finns** | Fortsätt samma mönster. |
|
|
||||||
| **Mjölkprincipen i texter** | Alla UI-strängar undviker "kassera automatiskt" och skiljer bäst-före/sista-förbrukningsdag. | **Finns** | Fortsätt. Inga texter får garantera matsäkerhet. |
|
|
||||||
|
|
||||||
### Slutsats av matrisen
|
### 7.3 Spårbarhet och deterministisk undo
|
||||||
Cooking Sessions är en **utbyggnad**, inte ett parallellsystem:
|
Vid complete sparas `mealBoxMutations` JSONB på `cooking_sessions`. Varje mutation innehåller:
|
||||||
- **Kärnan** (`recipe_cooks`, FEFO-avdrag, meals, meal_boxes) finns redan.
|
- `mealBoxId`: UUID för den berörda boxen
|
||||||
- **Det som saknas** är en session-boog (`cooking_sessions`) som knyter samman påbörjat/klart/ångrat, plus möjligheten att (a) skjuta upp delar av förbrukningen till efterfrågan och (b) lägga rester i befintliga matlådor.
|
- `deltaPortions`: hur många portioner som lades till
|
||||||
|
- `frozen`: boxens frystillstånd (redundans för felsäkerhet)
|
||||||
|
|
||||||
---
|
Undo av en session:
|
||||||
|
1. Itererar `mealBoxMutations`.
|
||||||
|
2. För varje mutation minskas både `portions` och `portionsRemaining` med `deltaPortions`.
|
||||||
|
3. Om `portions` blir 0 sätts `status = 'discarded'` och `portionsRemaining = 0`.
|
||||||
|
4. Legacy-fallback: om `mealBoxMutations` saknas, försöker undo hitta boxen via `cookingSessionId` och behandla den som en enda mutation med hela boxens portionsantal.
|
||||||
|
5. Inventory-ledger är append-only: reverseringar skrivs som `correction`-transaktioner med motsatt delta; befintliga transaktioner rörs inte.
|
||||||
|
6. Sessionens status sätts till `undone`.
|
||||||
|
|
||||||
## 2. Anti-dubblettkartan – varför INGEN `prepared_food_batches`-tabell
|
### 7.4 Mjölkprincipen
|
||||||
|
`recommendedUseBy` är en kvalitetssignal. UI använder texter som "Ät senast" och "lukta och smaka". Det finns ingen automatisk kassering när datum passeras; användaren avgör alltid med sinnena.
|
||||||
|
|
||||||
Befintlig `meal_boxes` kan redan representera:
|
### 7.5 UI: undo-knapp
|
||||||
- Portioner som återstår
|
- **Efter matlagning** (`cooking/[id].tsx`): efter att användaren tryckt "Klart" visas en skärm med en "Ångra"-knapp inom 24 h.
|
||||||
- Receptursprung (`recipeId`)
|
- **Matlådelistan** (`meal-boxes.tsx`): varje box som har en `cookingSessionId` och är skapad inom de senaste 24 timmarna visar en "Ångra"-knapp. Knappen anropar `POST /v1/cooking-sessions/:cookingSessionId/undo`.
|
||||||
- Näringsvärden per portion (`nutritionPerPortion`)
|
- Felfall visar serverns lokaliserade felmeddelande (aldrig hårdkodade strängar).
|
||||||
- Förvaringsplats och frystillstånd
|
- i18n-nycklar: `common.undo`, `mealbox.undoConfirmTitle`, `mealbox.undoConfirmBody`, `mealbox.undoSuccess`.
|
||||||
- Rekommenderat ät-senast-datum
|
|
||||||
- Status (`available`/`consumed`/`discarded`)
|
|
||||||
- Reservering för användare
|
|
||||||
|
|
||||||
Att lägga till `prepared_food_batches` skulle skapa:
|
### 7.6 Wrapper-beteende (OBLIGATORISK PUNKT 1)
|
||||||
- Dubbla konsumtions-endpoints
|
`completeCookingSession()` i `apps/api/src/lib/cooking.ts` är den enda vägen för både legacy `/v1/recipes/:id/cook` och explicit `/v1/cooking-sessions/:id/complete`. Wrappern:
|
||||||
- Dubbla "ät senast"-logiker
|
1. Beräknar `actualPortionsEaten` om det saknas: `Math.max(0, plannedPortions - mealBoxPortions)`.
|
||||||
- Dubbla måltidsloggningsvägar
|
2. Beräknar `leftoverEstimatePortions` om det saknas: samma som `mealBoxPortions`.
|
||||||
- Extra migrering och index
|
3. Skickar båda värdena explicit in i `completeCookingSessionCore`.
|
||||||
- Förvirring i UI: "är detta en matlåda eller en batch?"
|
|
||||||
|
|
||||||
**Beslut:** Utöka `meal_boxes` med ett fält `leftoverSource` (`cook_session`) och en kolumn `cookingSessionId`. Då är resterna fortfarande matlådor ur användarens perspektiv, men spårbart till en cooking session.
|
Detta stänger hålet där `/v1/recipes/:id/cook` tidigare lämnade fälten null och core fick gissa.
|
||||||
|
|
||||||
---
|
Regressionstest: `defaults leftover estimate to meal box portions: 3 eaten + 1 box = 100% deduction` (planned=4, eaten utelämnat → räknas till 3, box=1, leftoverEstimate=1 → consumption=min(4,3+1)=4 → 100% avdrag).
|
||||||
|
|
||||||
## 3. STEGPLAN
|
|
||||||
|
|
||||||
### Steg 3a – Lifecycle + planned usage ovanpå befintlig `/cook`
|
|
||||||
**Mål:** En cooking session kan startas, slutföras eller avbrytas utan att bryta dagens `/cook`-beteende.
|
|
||||||
|
|
||||||
**Ändringar:**
|
|
||||||
1. Ny tabell `cooking_sessions`:
|
|
||||||
- `id`, `recipeId`, `householdId`, `startedByUserId`
|
|
||||||
- `status`: `planned`, `started`, `completed`, `cancelled`
|
|
||||||
- `plannedPortions`, `plannedMealType`
|
|
||||||
- `startedAt`, `completedAt`, `cancelledAt`
|
|
||||||
- `cancelReason` (frivillig text, max 200)
|
|
||||||
2. Lägg `cookingSessionId` nullable på:
|
|
||||||
- `inventory_transactions`
|
|
||||||
- `meals`
|
|
||||||
- `meal_boxes`
|
|
||||||
- `recipe_cooks` (för spårbarhet)
|
|
||||||
3. Nytt API:
|
|
||||||
- `POST /v1/recipes/:id/cook/start` → skapar `cooking_session` med status `started`.
|
|
||||||
- `POST /v1/cooking-sessions/:id/complete` → kör dagens `/cook`-logik (FEFO, meals, mealBoxes) men länkar allt till `cookingSessionId`.
|
|
||||||
- `POST /v1/cooking-sessions/:id/cancel` → sätter status `cancelled`, ingen lagerpåverkan.
|
|
||||||
4. **Bakåtkompatibilitet:** Behåll `POST /v1/recipes/:id/cook` som ett kortkommando som skapar session + complete i samma anrop (så testprotokoll steg 5 fortsätter fungera).
|
|
||||||
|
|
||||||
**Berörda filer:**
|
|
||||||
- `packages/database/src/schema/recipes.ts` (cooking_sessions, recipe_cooks-kolumn)
|
|
||||||
- `packages/database/src/schema/meals.ts` (meal_boxes-kolumn)
|
|
||||||
- `packages/database/src/schema/inventory.ts` (inventory_transactions-kolumn)
|
|
||||||
- `packages/validation/src/recipes.ts` (nya schemas)
|
|
||||||
- `apps/api/src/routes/recipes.ts` (nytt `/cook/start`, refactor `/cook`)
|
|
||||||
- `apps/api/src/routes/cooking-sessions.ts` (ny)
|
|
||||||
- `apps/api/src/server.ts`
|
|
||||||
- `apps/mobile/src/app/cooking/[id].tsx` (starta session)
|
|
||||||
- `apps/mobile/src/app/recipe/[id].tsx` ("Laga nu" kan gå via start)
|
|
||||||
- `apps/mobile/src/locales/*/common.json` (+12)
|
|
||||||
|
|
||||||
**Migration:** 0011_cooking_sessions.sql
|
|
||||||
|
|
||||||
**Testplan:**
|
|
||||||
- POST /cook/start → status started
|
|
||||||
- POST /cooking-sessions/:id/complete → samma resultat som idag: inventory dras, meals skapas, mealBox skapas
|
|
||||||
- POST /recipes/:id/cook (gammalt) fortfarande grönt
|
|
||||||
- recipe_cooks.fas3_verifierat: rekommendationer ser fortfarande `max(cookedAt)` korrekt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Steg 3b – Minimala efterfrågor §6.2 + antagandeprofiler
|
|
||||||
**Mål:** Efter "klart" ställs max 2–3 snabba frågor. Appen kan fylla i svar automatiskt från profiler (t.ex. "vi brukar äta alla portioner" / "vi brukar ha 2 lådor över").
|
|
||||||
|
|
||||||
**Ändringar:**
|
|
||||||
1. Lägg kolumner på `cooking_sessions`:
|
|
||||||
- `actualPortionsEaten` (nullable int)
|
|
||||||
- `leftoverEstimatePortions` (nullable int)
|
|
||||||
- `leftoverNote` (text)
|
|
||||||
- `postCookProfile` (jsonb med senaste svaren)
|
|
||||||
2. Lägg `postCookQuestions` i `user_preferences` eller `households`:
|
|
||||||
- Profil: alltid samma svar, t.ex. `"we_eat_all"` eller `"leftovers_n"`.
|
|
||||||
3. App-skärm efter cooking mode:
|
|
||||||
- "Hur många portioner åt ni?" (default = portionsCooked - mealBoxPortions)
|
|
||||||
- "Vill du spara rester som matlådor?" (default från profil)
|
|
||||||
- Frågorna ska kunna skippas med ett tryck (default antas).
|
|
||||||
|
|
||||||
**Berörda filer:**
|
|
||||||
- `packages/database/src/schema/recipes.ts` (kolumner)
|
|
||||||
- `packages/validation/src/recipes.ts`
|
|
||||||
- `apps/api/src/routes/cooking-sessions.ts`
|
|
||||||
- `apps/mobile/src/app/cooking/[id].tsx` (efterflöde)
|
|
||||||
- `apps/mobile/src/locales/*/common.json`
|
|
||||||
|
|
||||||
**Migration:** 0012_cooking_questions.sql
|
|
||||||
|
|
||||||
**Testplan:**
|
|
||||||
- Profil sparas och återanvänds nästa session
|
|
||||||
- Skippa frågor → defaults används
|
|
||||||
- Validering: `actualPortionsEaten + leftoverEstimatePortions ≤ portionsCooked`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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. En completed session kan ångras inom 24 h via append-only reverseringstransaktioner.
|
|
||||||
|
|
||||||
**Ändringar:**
|
|
||||||
1. Vid complete: råvaror dras för de **tillagade** portionerna = `actualPortionsEaten + leftoverEstimatePortions`, begränsat uppåt till `plannedPortions`.
|
|
||||||
- Exempel: 4 planerade portioner → 3 ätna + 1 rest = 100 % avdrag.
|
|
||||||
- Exempel: 4 planerade portioner → 3 ätna + 0 rester = 75 % avdrag.
|
|
||||||
- Exempel: 4 planerade portioner → 2 ätna + 2 matlådor = 100 % avdrag; matlådorna är en delmängd av resterna.
|
|
||||||
- Endast `plannedPortions - actualPortionsEaten - leftoverEstimatePortions` stannar kvar som råvara (aldrig tillagat).
|
|
||||||
- Om svaren saknas (legacy/skip): defaults ger `actualPortionsEaten = plannedPortions - mealBoxPortions` och `leftoverEstimatePortions = mealBoxPortions`, vilket bevarar exakt pre-3c-beteende.
|
|
||||||
2. Lagra ursprungligt FEFO-avdrag i `cooking_sessions.plannedDeductions` (jsonb).
|
|
||||||
- Validera `leftoverEstimatePortions >= mealBoxPortions`; annars 400 med `cooked.leftoverLessThanBox`.
|
|
||||||
3. `POST /v1/cooking-sessions/:id/undo`:
|
|
||||||
- Status måste vara `completed`.
|
|
||||||
- `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/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:** 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 (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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Steg 3d – Rester §6.4 via befintliga meal_boxes
|
|
||||||
**Mål:** Efterfrågade rester hamnar i antingen (1) befintlig available matlåda med samma `recipeId`, eller (2) ny matlåda.
|
|
||||||
|
|
||||||
**Ändringar:**
|
|
||||||
1. Vid complete, om `leftoverEstimatePortions > 0`:
|
|
||||||
- Sök `meal_boxes` där `recipeId = recipe.id`, `householdId`, `status = 'available'`, `frozen = false` (eller matcha valt frystillstånd), sorterat på `recommendedUseBy`.
|
|
||||||
- Om träff: öka `portions` och `portionsRemaining` med `leftoverEstimatePortions`, uppdatera `recommendedUseBy` till det tidigare av de två datumen (mjölkprincipen: behåll kortaste hållbarhet).
|
|
||||||
- Om ingen träff: skapa ny matlåda (dagens logik).
|
|
||||||
2. Lägg `leftoverSource = 'cook_session'` och `cookingSessionId` på meal_box.
|
|
||||||
3. Frysval: om användaren väljer frys, skapa/utöka fryst matlåda med `frozen=true`, useBy=90 dagar.
|
|
||||||
|
|
||||||
**Berörda filer:**
|
|
||||||
- `packages/database/src/schema/meals.ts`
|
|
||||||
- `apps/api/src/routes/recipes.ts` eller `cooking-sessions.ts`
|
|
||||||
- `apps/mobile/src/app/cooking/[id].tsx`
|
|
||||||
- `apps/mobile/src/app/meal-boxes.tsx` (visa source-ikon om det är rester)
|
|
||||||
|
|
||||||
**Migration:** 0013_meal_box_leftover_source.sql
|
|
||||||
|
|
||||||
**Testplan:**
|
|
||||||
- Två cooking sessions med samma recept → en matlåda har ökade portioner
|
|
||||||
- Frysta rester → separat fryst låda
|
|
||||||
- Ingen dubblett i rekommendationer: matlådor-först-sektionen räknar fortfarande `portionsRemaining > 0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. RISKER
|
|
||||||
|
|
||||||
| Risk | Påverkan | Minskning |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| `recommendations.ts` använder `recipe_cooks` för "senast lagat". Om `cookedAt` ändras semantik bryts variation. | Medel | Behåll `recipe_cooks.cookedAt` som tidpunkt för färdig session. Lägg separata tidsstämplar på `cooking_sessions`. |
|
|
||||||
| Testprotokoll steg 5 kräver att "Laga nu → klart → logga" fortsätter fungera utan extra kranar. | Hög | Behåll `/recipes/:id/cook` som kortkommando. Nya frågor ska vara skipbara med tydliga defaults. |
|
|
||||||
| Testprotokoll steg 8 kräver att nya matlådor skapas. | Hög | Nya rest-flödet ska inte blockera skapandet; befintlig-låda-utökning är opt-in. |
|
|
||||||
| Partiell förbrukning kan leda till brutna invarianter om omräkning blir fel. | Hög | Invariantstest efter varje complete/undo; använd samma enhetskonvertering som FEFO. |
|
|
||||||
| Undo kan radera måltider som andra flöden refererar till. | Medel | Använd soft-delete (`deletedAt` på meals) eller markera som `cancelled` istället för hård radering. |
|
|
||||||
| UI-blödning: nya frågor kan kännas som ett formulär. | Medel | Designa som kort med +/- och "kom ihåg mitt val". |
|
|
||||||
| Matsäkerhetstexter kan osynligt bli för skarpa. | Medel | Varje ny i18n-nyckel granskas: aldrig "säkert att äta", alltid "lukta och smaka" / "rekommenderas före". |
|
|
||||||
| Hermetiska tester kan läcka `.env`. | Låg | Fortsätt `setup-env.ts` + `vitest.config.ts`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Översiktliga berörda tabeller
|
|
||||||
|
|
||||||
| Tabell | Förändring |
|
|
||||||
|--------|------------|
|
|
||||||
| `cooking_sessions` | **Ny** |
|
|
||||||
| `recipe_cooks` | `cookingSessionId` nullable |
|
|
||||||
| `inventory_transactions` | `cookingSessionId` nullable |
|
|
||||||
| `meals` | `cookingSessionId` nullable, ev. `deletedAt` |
|
|
||||||
| `meal_boxes` | `cookingSessionId` nullable, `leftoverSource` text |
|
|
||||||
| `users` / `households` | post-cook-profil jsonb (steg 3b) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Leveransordning
|
|
||||||
|
|
||||||
1. Steg 3a: lifecycle + bakåtkompatibel `/cook`
|
|
||||||
2. Steg 3b: efterfrågor + profiler
|
|
||||||
3. Steg 3c: partiell förbrukning + undo
|
|
||||||
4. Steg 3d: rester till befintliga meal_boxes
|
|
||||||
|
|
||||||
Per steg: `brand-guard`, `typecheck`, `test`, `build`, `first-deploy staging`, patch + bundle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Rapport färdig. Inväntar godkännande innan kod påbörjas.*
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Fas 3d: rester från cooking sessions spåras i befintliga meal_boxes.
|
||||||
|
ALTER TABLE meal_boxes ADD COLUMN IF NOT EXISTS leftover_source TEXT;
|
||||||
|
-- Bakåtfyll: befintliga matlådor skapade före 3d får källan 'cook_session'.
|
||||||
|
UPDATE meal_boxes SET leftover_source = 'cook_session' WHERE leftover_source IS NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Fas 3d: nya domänhändelser för matlådeuppdateringar.
|
||||||
|
ALTER TYPE event_type ADD VALUE IF NOT EXISTS 'MEAL_BOX_UPDATED';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Fas 3d: spara måltidsdatum på cooking session för att kunna hitta
|
||||||
|
-- matlådor som skapades/utökades vid samma tillfälle vid undo.
|
||||||
|
ALTER TABLE cooking_sessions ADD COLUMN IF NOT EXISTS meal_date DATE;
|
||||||
|
-- Bakåtfyll: använd completedAt-datumet för befintliga completed-sessioner.
|
||||||
|
UPDATE cooking_sessions SET meal_date = completed_at::DATE WHERE meal_date IS NULL AND completed_at IS NOT NULL;
|
||||||
|
UPDATE cooking_sessions SET meal_date = started_at::DATE WHERE meal_date IS NULL AND started_at IS NOT NULL;
|
||||||
|
UPDATE cooking_sessions SET meal_date = created_at::DATE WHERE meal_date IS NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Fas 3d: spara vilka meal_boxes som påverkades av en session och med
|
||||||
|
-- hur många portioner, för deterministisk undo vid mergeade lådor.
|
||||||
|
ALTER TABLE cooking_sessions ADD COLUMN IF NOT EXISTS meal_box_mutations JSONB DEFAULT '[]'::JSONB;
|
||||||
@@ -99,6 +99,34 @@
|
|||||||
"when": 1786055900000,
|
"when": 1786055900000,
|
||||||
"tag": "0014_expand_event_types",
|
"tag": "0014_expand_event_types",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786056300000,
|
||||||
|
"tag": "0015_meal_box_leftover_source",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786056400000,
|
||||||
|
"tag": "0016_expand_event_types_meal_box_updated",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786056500000,
|
||||||
|
"tag": "0017_cooking_sessions_meal_date",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786056600000,
|
||||||
|
"tag": "0018_cooking_sessions_meal_box_mutations",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ export const mealBoxes = pgTable(
|
|||||||
.references(() => storageLocations.id),
|
.references(() => storageLocations.id),
|
||||||
frozen: boolean("frozen").notNull().default(false),
|
frozen: boolean("frozen").notNull().default(false),
|
||||||
recommendedUseBy: date("recommended_use_by").notNull(),
|
recommendedUseBy: date("recommended_use_by").notNull(),
|
||||||
|
leftoverSource: text("leftover_source"),
|
||||||
reservedForUserId: uuid("reserved_for_user_id").references(() => users.id, {
|
reservedForUserId: uuid("reserved_for_user_id").references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
date,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
@@ -213,6 +214,12 @@ export const cookingSessions = pgTable(
|
|||||||
actualPortionsEaten: integer("actual_portions_eaten"),
|
actualPortionsEaten: integer("actual_portions_eaten"),
|
||||||
/** Förberedda rester efter sessionen (steg 3d). */
|
/** Förberedda rester efter sessionen (steg 3d). */
|
||||||
leftoverEstimatePortions: integer("leftover_estimate_portions"),
|
leftoverEstimatePortions: integer("leftover_estimate_portions"),
|
||||||
|
/** Måltidsdatum (UTC) som rester/matlådor knyts till. */
|
||||||
|
mealDate: date("meal_date"),
|
||||||
|
/** Vilka meal_boxes som påverkades av sessionen och med hur mycket. */
|
||||||
|
mealBoxMutations: jsonb("meal_box_mutations").$type<
|
||||||
|
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
|
||||||
|
>(),
|
||||||
leftoverNote: text("leftover_note"),
|
leftoverNote: text("leftover_note"),
|
||||||
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
|
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
|
||||||
plannedDeductions: jsonb("planned_deductions").$type<
|
plannedDeductions: jsonb("planned_deductions").$type<
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface EventPayloadMap {
|
|||||||
SHOPPING_COMPLETED: { shoppingListId: string; itemsAdded: number };
|
SHOPPING_COMPLETED: { shoppingListId: string; itemsAdded: number };
|
||||||
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_UPDATED: { mealBoxId: string; addedPortions: number; totalPortions: 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_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string };
|
||||||
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
|
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ export const EVENT_TYPES = [
|
|||||||
"SHOPPING_COMPLETED",
|
"SHOPPING_COMPLETED",
|
||||||
"WEEK_PLAN_UPDATED",
|
"WEEK_PLAN_UPDATED",
|
||||||
"MEAL_BOX_CREATED",
|
"MEAL_BOX_CREATED",
|
||||||
|
"MEAL_BOX_UPDATED",
|
||||||
"MEAL_BOX_CONSUMED",
|
"MEAL_BOX_CONSUMED",
|
||||||
"MEAL_BOX_DISCARDED",
|
"MEAL_BOX_DISCARDED",
|
||||||
"MEAL_REMOVED",
|
"MEAL_REMOVED",
|
||||||
|
|||||||
Reference in New Issue
Block a user