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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user