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;