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:
+162
-46
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
allocateFefo,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
cookingSessionStarted,
|
||||
cookingSessionCompleted,
|
||||
cookingSessionUndone,
|
||||
leftoversCreated,
|
||||
} from "@app/analytics";
|
||||
import { todayIso, emitEvent, trackProductAnalytics } from "./helpers.js";
|
||||
import { errors } from "./errors.js";
|
||||
@@ -45,6 +46,7 @@ export interface CompleteCookingResult {
|
||||
ok: boolean;
|
||||
mealIds: string[];
|
||||
mealBoxId: string | null;
|
||||
mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>;
|
||||
inventoryDeductions: Array<{ itemId: string; quantity: number; unit: string; name: string }>;
|
||||
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(
|
||||
app,
|
||||
session,
|
||||
@@ -103,6 +106,7 @@ export async function completeCookingSession(
|
||||
mealBoxPortions,
|
||||
actualPortionsEaten,
|
||||
leftoverEstimatePortions,
|
||||
date: mealDate,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -114,6 +118,8 @@ export async function completeCookingSession(
|
||||
actualPortionsEaten,
|
||||
leftoverEstimatePortions,
|
||||
leftoverNote: input.leftoverNote ?? null,
|
||||
mealDate,
|
||||
mealBoxMutations: result.mealBoxMutations,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.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;
|
||||
const mealBoxPortions = input.mealBoxPortions ?? 0;
|
||||
if (mealBoxPortions > 0) {
|
||||
const mealBoxMutations: Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }> = [];
|
||||
const leftoverEstimatePortionsForBox = input.leftoverEstimatePortions ?? 0;
|
||||
if (leftoverEstimatePortionsForBox > 0) {
|
||||
const locationId =
|
||||
input.mealBoxStorageLocationId ??
|
||||
(
|
||||
@@ -353,34 +360,97 @@ export async function completeCookingSessionCore(
|
||||
)[0]?.id;
|
||||
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)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const [box] = await app.db
|
||||
.insert(schema.mealBoxes)
|
||||
.values({
|
||||
|
||||
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,
|
||||
recipeId: session.recipeId,
|
||||
cookingSessionId: session.id,
|
||||
titleSv: recipe.titleSv,
|
||||
portions: mealBoxPortions,
|
||||
portionsRemaining: mealBoxPortions,
|
||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||
cookedAt: date,
|
||||
storageLocationId: locationId,
|
||||
frozen: input.mealBoxFrozen ?? false,
|
||||
recommendedUseBy,
|
||||
})
|
||||
.returning();
|
||||
mealBoxId = box!.id;
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_CREATED",
|
||||
payload: { mealBoxId: box!.id, portions: mealBoxPortions },
|
||||
correlationId,
|
||||
});
|
||||
} else {
|
||||
const [box] = await app.db
|
||||
.insert(schema.mealBoxes)
|
||||
.values({
|
||||
householdId,
|
||||
recipeId: session.recipeId,
|
||||
cookingSessionId: session.id,
|
||||
titleSv: recipe.titleSv,
|
||||
portions: leftoverEstimatePortionsForBox,
|
||||
portionsRemaining: leftoverEstimatePortionsForBox,
|
||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||
cookedAt: date,
|
||||
storageLocationId: locationId,
|
||||
frozen,
|
||||
recommendedUseBy,
|
||||
leftoverSource: "cook_session",
|
||||
})
|
||||
.returning();
|
||||
mealBoxId = box!.id;
|
||||
mealBoxMutations.push({ mealBoxId: box!.id, deltaPortions: leftoverEstimatePortionsForBox, frozen });
|
||||
await emitEvent(app.db, {
|
||||
type: "MEAL_BOX_CREATED",
|
||||
payload: { mealBoxId: box!.id, portions: leftoverEstimatePortionsForBox },
|
||||
userId,
|
||||
householdId,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
await trackProductAnalytics(
|
||||
app.db,
|
||||
userId,
|
||||
householdId,
|
||||
correlationId,
|
||||
});
|
||||
leftoversCreated({
|
||||
householdId,
|
||||
properties: {
|
||||
cookingSessionId: session.id,
|
||||
recipeId: session.recipeId,
|
||||
portions: leftoverEstimatePortionsForBox,
|
||||
mealBoxId: mealBoxId!,
|
||||
merged: !!existingBox,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. recipe_cooks + statistik
|
||||
@@ -398,7 +468,7 @@ export async function completeCookingSessionCore(
|
||||
.where(eq(schema.recipes.id, session.recipeId));
|
||||
await emitEvent(app.db, {
|
||||
type: "RECIPE_COOKED",
|
||||
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions },
|
||||
payload: { recipeId: session.recipeId, portions: portionsCooked, mealBoxPortions: input.mealBoxPortions ?? 0 },
|
||||
userId,
|
||||
householdId,
|
||||
correlationId,
|
||||
@@ -424,6 +494,7 @@ export async function completeCookingSessionCore(
|
||||
ok: true,
|
||||
mealIds,
|
||||
mealBoxId,
|
||||
mealBoxMutations,
|
||||
inventoryDeductions: deductions,
|
||||
recipeIngredients: recipe.ingredients.map((ing) => ({
|
||||
canonicalIngredientId: ing.canonicalIngredientId,
|
||||
@@ -537,24 +608,69 @@ export async function undoCookingSession(
|
||||
}
|
||||
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,
|
||||
});
|
||||
// 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
|
||||
.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,
|
||||
});
|
||||
discardedMealBoxIds.push(box.id);
|
||||
}
|
||||
await app.db
|
||||
.update(schema.mealBoxes)
|
||||
.set({ status: "discarded", portionsRemaining: 0 })
|
||||
.where(eq(schema.mealBoxes.cookingSessionId, session.id));
|
||||
}
|
||||
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));
|
||||
@@ -662,6 +778,6 @@ export async function undoCookingSession(
|
||||
reversedTransactions: cookUses.length,
|
||||
restoredItemIds: [...affectedItemIds],
|
||||
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);
|
||||
});
|
||||
|
||||
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 timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const [completedSessionId, setCompletedSessionId] = useState<string | null>(null);
|
||||
const [mealBoxPortions, setMealBoxPortions] = useState(0);
|
||||
const [actualPortionsEaten, setActualPortionsEaten] = useState<number | null>(null);
|
||||
const [leftoverEstimatePortions, setLeftoverEstimatePortions] = useState<number | null>(null);
|
||||
@@ -58,11 +59,28 @@ export default function CookingScreen() {
|
||||
});
|
||||
|
||||
const cook = useMutation({
|
||||
mutationFn: (body: unknown) => api(`/v1/recipes/${id}/cook`, { method: "POST", body }),
|
||||
onSuccess: async () => {
|
||||
mutationFn: (body: unknown) =>
|
||||
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: ["day"] });
|
||||
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();
|
||||
},
|
||||
onError: (err) =>
|
||||
@@ -101,6 +119,31 @@ export default function CookingScreen() {
|
||||
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) {
|
||||
const defaultEaten = actualPortionsEaten ?? Math.max(0, portionsCooked - mealBoxPortions);
|
||||
const defaultLeftovers = leftoverEstimatePortions ?? mealBoxPortions;
|
||||
|
||||
@@ -24,6 +24,8 @@ interface MealBox {
|
||||
recommendedUseBy: string;
|
||||
frozen: boolean;
|
||||
kcalPerPortion?: number;
|
||||
cookingSessionId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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")),
|
||||
});
|
||||
|
||||
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.isError || !query.data) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
|
||||
const boxes = query.data.mealBoxes;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{boxes.length === 0 && <EmptyState text={t("mealbox.empty")} />}
|
||||
{boxes.map((box) => {
|
||||
const urgent = box.recommendedUseBy <= today;
|
||||
const canUndo =
|
||||
!!box.cookingSessionId && new Date(box.createdAt).getTime() > cutoff;
|
||||
return (
|
||||
<Card key={box.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
@@ -82,6 +100,31 @@ export default function MealBoxesScreen() {
|
||||
onPress={() => consume.mutate(box.id)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -385,5 +385,8 @@
|
||||
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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ä.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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.",
|
||||
"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.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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user