Files
Cibello-app/apps/mobile/src/app/cooking/[id].tsx
T
Claude 52bd6e74bd feat: retry-failed-translations + kok-läget på rätt språk (titel, ingredienser, valfritt)
- retry-failed-translations.ts: kör om de receptöversättningar som föll på
  verifieringen (skriver ut vilka + varför, köar TRANSLATE_RECIPE på nytt).
  Idempotent; kör tills listan är tom, publicera sedan.
- Kok-läget (cooking/[id].tsx) visade svensk titel + svenska ingrediensnamn fast
  detalj-API:t redan levererar översatta fält. Nu title/displayName med svensk
  fallback (stegen var redan översatta).
- "(valfritt)" var hårdkodat i kok-läget + recept-detaljen -> ny nyckel
  recipe.optional i alla 12 språk. Vakt + typecheck gröna.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 18:49:34 +00:00

322 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useKeepAwake } from "expo-keep-awake";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/**
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
* timers per steg, portionsskalning. Avslutas med "jag lagade detta"
* som drar lager, loggar måltid och kan skapa matlådor (spec §2324).
*/
interface RecipeForCooking {
id: string;
titleSv: string;
title?: string;
portions: number;
steps: Array<{
id: string;
stepNumber: number;
instructionSv: string;
instruction?: string;
timerSeconds: number | null;
temperatureC: number | null;
tip: string | null;
}>;
ingredients: Array<{
id: string;
displayNameSv: string;
displayName?: string;
quantity: number;
unit: string;
optional: boolean;
}>;
}
export default function CookingScreen() {
useKeepAwake();
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
const queryClient = useQueryClient();
const [stepIndex, setStepIndex] = useState(0);
const [showIngredients, setShowIngredients] = useState(false);
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);
const query = useQuery({
queryKey: ["recipe", id],
queryFn: () => api<RecipeForCooking>(`/v1/recipes/${id}`),
});
const cook = useMutation({
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) =>
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 recipe = query.data;
const portionsCooked = Number(portionsParam ?? recipe.portions) || recipe.portions;
const step = recipe.steps[stepIndex];
const isLast = stepIndex >= recipe.steps.length - 1;
const finish = () => {
setFinishing(true);
};
if (completedSessionId) {
return (
<Screen>
<Card>
<Heading> {t("cooked.title")}</Heading>
<Body>{recipe.title ?? 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;
return (
<Screen>
<Heading>{t("cooked.title")}</Heading>
<Card>
<Body>{recipe.title ?? recipe.titleSv}</Body>
<Small>
{t("cooked.portionsCooked")}: {portionsCooked}
</Small>
</Card>
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {t("cooked.mealBoxes")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setMealBoxPortions(Math.max(0, mealBoxPortions - 1))}
/>
<Body>{mealBoxPortions}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setMealBoxPortions(Math.min(portionsCooked, mealBoxPortions + 1))}
/>
</Row>
</Row>
</Card>
<Card>
<Body>{t("cooked.actualPortionsEaten")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setActualPortionsEaten(Math.max(0, defaultEaten - 1))}
/>
<Body>{defaultEaten}</Body>
<Button
label="+"
variant="ghost"
onPress={() => setActualPortionsEaten(Math.min(portionsCooked, defaultEaten + 1))}
/>
</Row>
<Small>{t("cooked.actualPortionsEatenHint")}</Small>
</Card>
<Card>
<Body>{t("cooked.leftoverEstimatePortions")}</Body>
<Row>
<Button
label=""
variant="ghost"
onPress={() => setLeftoverEstimatePortions(Math.max(0, defaultLeftovers - 1))}
/>
<Body>{defaultLeftovers}</Body>
<Button
label="+"
variant="ghost"
onPress={() =>
setLeftoverEstimatePortions(
Math.min(portionsCooked - defaultEaten, defaultLeftovers + 1),
)
}
/>
</Row>
<Small>{t("cooked.leftoverEstimateHint")}</Small>
</Card>
<Small>{t("cooked.deductPantryNote")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("cooking.finish")}
loading={cook.isPending}
onPress={() =>
cook.mutate({
portionsCooked,
mealBoxPortions,
actualPortionsEaten: actualPortionsEaten ?? defaultEaten,
leftoverEstimatePortions: leftoverEstimatePortions ?? defaultLeftovers,
mealBoxFrozen: false,
deductInventory: true,
mealType: "dinner",
})
}
/>
<Button
label={t("common.skip")}
variant="ghost"
onPress={() =>
cook.mutate({
portionsCooked,
mealBoxPortions,
actualPortionsEaten: defaultEaten,
leftoverEstimatePortions: defaultLeftovers,
mealBoxFrozen: false,
deductInventory: true,
mealType: "dinner",
})
}
/>
<Button label={t("common.back")} variant="ghost" onPress={() => setFinishing(false)} />
</Screen>
);
}
return (
<Screen scroll={false} style={{ justifyContent: "space-between" }}>
<View style={{ gap: spacing.md }}>
<Button label={t("common.cancel")} variant="ghost" onPress={() => router.dismissAll()} />
<Small>{recipe.title ?? recipe.titleSv}</Small>
<Heading>
{t("cooking.step", { current: stepIndex + 1, total: recipe.steps.length })}
</Heading>
<Text style={{ fontSize: 24, lineHeight: 34, color: colors.text }}>
{step?.instruction ?? step?.instructionSv}
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text>
{step?.tip && <Small>💡 {step.tip}</Small>}
<Pressable
onPress={() => setShowIngredients((v) => !v)}
hitSlop={8}
style={{ alignSelf: "flex-start" }}
>
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
</Small>
</Pressable>
{showIngredients && (
<View
style={{
maxHeight: 200,
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: spacing.md,
}}
>
<ScrollView>
{recipe.ingredients.map((ing) => {
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
return (
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
<Body>
{ing.displayName ?? ing.displayNameSv}
{ing.optional ? ` ${t("recipe.optional")}` : ""}
</Body>
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
</Row>
);
})}
</ScrollView>
</View>
)}
{step?.timerSeconds != null && (
<Small> {t("cooking.approxTime", { time: formatTime(step.timerSeconds) })}</Small>
)}
<Small>{t("cooking.keepAwake")}</Small>
</View>
<Row style={{ paddingBottom: spacing.lg }}>
<View style={{ flex: 1 }}>
<Button
label={`${t("common.back")}`}
variant="ghost"
disabled={stepIndex === 0}
onPress={() => setStepIndex(Math.max(0, stepIndex - 1))}
/>
</View>
<View style={{ flex: 2 }}>
<Button
label={isLast ? t("cooking.finish") : `${t("common.next")}`}
onPress={() => (isLast ? finish() : setStepIndex(stepIndex + 1))}
/>
</View>
</Row>
</Screen>
);
}
function formatTime(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}