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:
Sven (AAMOS AI)
2026-08-07 17:04:42 +07:00
parent c1ab2c8f37
commit 74f95daab2
26 changed files with 610 additions and 305 deletions
+45 -2
View File
@@ -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;
+43
View File
@@ -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>
);
})}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}
+4 -1
View File
@@ -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."
}