feat(mobile): veckoplan – markera lagad/hoppa över (smart omplanering) + aktivera plan
CI / Typecheck, test & build (push) Successful in 1m48s

This commit is contained in:
Sven (AAMOS AI)
2026-08-14 02:48:18 +07:00
parent c5f8d63964
commit 4d9c69d0ce
13 changed files with 102 additions and 12 deletions
+61 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { getLanguageTag, t } from "@/lib/i18n";
import { colors, radius, spacing } from "@/lib/theme";
@@ -71,6 +71,7 @@ function cap(s: string): string {
export default function PlanScreen() {
const locale = getLanguageTag();
const queryClient = useQueryClient();
const [selectedKey, setSelectedKey] = useState<string>(() => toKey(new Date()));
const [generating, setGenerating] = useState(false);
const todayKey = toKey(new Date());
@@ -86,6 +87,8 @@ export default function PlanScreen() {
refetchInterval: generating ? 3000 : false,
});
const plan = query.data?.plans?.[0] ?? null;
const planId = plan?.id ?? null;
const draftPlanId = plan && plan.status === "draft" ? plan.id : null;
const entriesByDate = useMemo(() => {
const map: Record<string, WeekPlanEntry[]> = {};
@@ -114,6 +117,10 @@ export default function PlanScreen() {
return () => clearTimeout(timer);
}, [generating]);
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["week-plan", mondayKey] });
const onMutationError = (err: unknown) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
const generate = useMutation({
mutationFn: () =>
api("/v1/week-plans/generate", {
@@ -121,10 +128,40 @@ export default function PlanScreen() {
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"] },
}),
onSuccess: () => setGenerating(true),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onError: onMutationError,
});
const patchEntry = useMutation({
mutationFn: (vars: { planId: string; entryId: string; status: string }) =>
api(`/v1/week-plans/${vars.planId}/entries/${vars.entryId}`, {
method: "PATCH",
body: { status: vars.status },
}),
onSuccess: () => invalidate(),
onError: onMutationError,
});
const activate = useMutation({
mutationFn: (id: string) => api(`/v1/week-plans/${id}/activate`, { method: "POST" }),
onSuccess: () => invalidate(),
onError: onMutationError,
});
const openActions = (entry: WeekPlanEntry) => {
if (!planId) return;
Alert.alert(entry.titleSv, undefined, [
{
text: t("recipe.iCookedThis"),
onPress: () => patchEntry.mutate({ planId, entryId: entry.id, status: "cooked" }),
},
{
text: t("common.skip"),
onPress: () => patchEntry.mutate({ planId, entryId: entry.id, status: "skipped" }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
};
const monthLabel = cap(
new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(selected),
);
@@ -219,6 +256,14 @@ export default function PlanScreen() {
</View>
<Small>{dayHeader}</Small>
{draftPlanId ? (
<Button
label={t("plan.activate")}
variant="secondary"
onPress={() => draftPlanId && activate.mutate(draftPlanId)}
loading={activate.isPending}
/>
) : null}
<Spacer size={spacing.xs} />
{query.isLoading ? (
@@ -231,15 +276,24 @@ export default function PlanScreen() {
<LoadingView />
</>
) : selectedEntries.length > 0 ? (
selectedEntries.map((entry) => (
<Card key={entry.id}>
selectedEntries.map((entry) => {
const cooked = entry.status === "cooked";
const skipped = entry.status === "skipped";
return (
<Card key={entry.id} onPress={() => openActions(entry)}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{entry.titleSv}</Body>
<Body muted={cooked || skipped}>{entry.titleSv}</Body>
<Row>
{cooked ? <Tag label={t("plan.cooked")} tone="success" /> : null}
{skipped ? <Tag label={t("plan.skipped")} tone="warning" /> : null}
<Tag label={mealTypeLabel(entry.mealType)} />
</Row>
</Row>
<Small>{t("plan.portions", { count: entry.portions })}</Small>
{entry.rescheduleReasonSv ? <Small>{entry.rescheduleReasonSv}</Small> : null}
</Card>
))
);
})
) : (
<>
<EmptyState text={t("plan.emptyDay")} />
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} portioner",
"plan.generate": "Generer ugeplan",
"plan.generating": "Genererer ugeplan…",
"plan.cooked": "Tilberedt",
"plan.skipped": "Sprunget over",
"plan.activate": "Aktivér plan",
"wte.coverage": "{pct} % derhjemme",
"wte.cravingPlaceholder": "Jeg har lyst til … (fx cremet, asiatisk, under 500 kcal)",
"wte.empty": "Ingen forslag endnu læg lidt mad på lager eller løsn filtrene.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} Portionen",
"plan.generate": "Wochenplan erstellen",
"plan.generating": "Wochenplan wird erstellt…",
"plan.cooked": "Gekocht",
"plan.skipped": "Übersprungen",
"plan.activate": "Plan aktivieren",
"wte.coverage": "{pct} % zuhause",
"wte.cravingPlaceholder": "Ich habe Lust auf … (z. B. cremig, asiatisch, unter 500 kcal)",
"wte.empty": "Noch keine Vorschläge fülle den Vorrat oder lockere die Filter.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} servings",
"plan.generate": "Generate week plan",
"plan.generating": "Generating week plan…",
"plan.cooked": "Cooked",
"plan.skipped": "Skipped",
"plan.activate": "Activate plan",
"wte.coverage": "{pct}% at home",
"wte.cravingPlaceholder": "I'm craving … (e.g. creamy, Asian, under 500 kcal)",
"wte.empty": "No suggestions yet add some food to your inventory or loosen the filters.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} porciones",
"plan.generate": "Generar plan semanal",
"plan.generating": "Generando plan…",
"plan.cooked": "Cocinado",
"plan.skipped": "Omitido",
"plan.activate": "Activar plan",
"wte.coverage": "{pct}% en casa",
"wte.cravingPlaceholder": "Me apetece … (p. ej. cremoso, asiático, menos de 500 kcal)",
"wte.empty": "Aún no hay sugerencias: añade comida al inventario o relaja los filtros.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} annosta",
"plan.generate": "Luo viikkosuunnitelma",
"plan.generating": "Luodaan suunnitelmaa…",
"plan.cooked": "Valmistettu",
"plan.skipped": "Ohitettu",
"plan.activate": "Aktivoi suunnitelma",
"wte.coverage": "{pct} % kotona",
"wte.cravingPlaceholder": "Tekisi mieli … (esim. kermaista, aasialaista, alle 500 kcal)",
"wte.empty": "Ei vielä ehdotuksia lisää ruokaa varastoon tai löysää suodattimia.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} portions",
"plan.generate": "Générer le plan",
"plan.generating": "Génération du plan…",
"plan.cooked": "Préparé",
"plan.skipped": "Ignoré",
"plan.activate": "Activer le plan",
"wte.coverage": "{pct} % à la maison",
"wte.cravingPlaceholder": "J'ai envie de … (ex. crémeux, asiatique, moins de 500 kcal)",
"wte.empty": "Pas encore de suggestions : ajoutez des aliments ou assouplissez les filtres.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} porzioni",
"plan.generate": "Genera piano settimanale",
"plan.generating": "Generazione piano…",
"plan.cooked": "Cucinato",
"plan.skipped": "Saltato",
"plan.activate": "Attiva piano",
"wte.coverage": "{pct}% in casa",
"wte.cravingPlaceholder": "Ho voglia di … (es. cremoso, asiatico, meno di 500 kcal)",
"wte.empty": "Ancora nessun suggerimento: aggiungi cibo alla dispensa o allenta i filtri.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} porsjoner",
"plan.generate": "Generer ukeplan",
"plan.generating": "Genererer ukeplan…",
"plan.cooked": "Laget",
"plan.skipped": "Hoppet over",
"plan.activate": "Aktiver plan",
"wte.coverage": "{pct} % hjemme",
"wte.cravingPlaceholder": "Jeg har lyst på … (f.eks. kremet, asiatisk, under 500 kcal)",
"wte.empty": "Ingen forslag ennå legg inn litt mat på lageret eller løsne filtrene.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} porties",
"plan.generate": "Weekplan genereren",
"plan.generating": "Weekplan genereren…",
"plan.cooked": "Gekookt",
"plan.skipped": "Overgeslagen",
"plan.activate": "Plan activeren",
"wte.coverage": "{pct}% in huis",
"wte.cravingPlaceholder": "Ik heb zin in … (bijv. romig, Aziatisch, onder 500 kcal)",
"wte.empty": "Nog geen suggesties vul de voorraad aan of versoepel de filters.",
+3
View File
@@ -362,6 +362,9 @@
"plan.portions": "{count} porcji",
"plan.generate": "Generuj plan tygodnia",
"plan.generating": "Generowanie planu…",
"plan.cooked": "Ugotowane",
"plan.skipped": "Pominięte",
"plan.activate": "Aktywuj plan",
"wte.coverage": "{pct}% w domu",
"wte.cravingPlaceholder": "Mam ochotę na … (np. kremowe, azjatyckie, poniżej 500 kcal)",
"wte.empty": "Brak propozycji dodaj jedzenie do spiżarni albo poluzuj filtry.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} porções",
"plan.generate": "Gerar plano semanal",
"plan.generating": "A gerar plano…",
"plan.cooked": "Cozinhado",
"plan.skipped": "Ignorado",
"plan.activate": "Ativar plano",
"wte.coverage": "{pct}% em casa",
"wte.cravingPlaceholder": "Apetece-me … (ex.: cremoso, asiático, menos de 500 kcal)",
"wte.empty": "Ainda sem sugestões adicione comida à despensa ou alivie os filtros.",
+3
View File
@@ -348,6 +348,9 @@
"plan.portions": "{count} portioner",
"plan.generate": "Generera veckoplan",
"plan.generating": "Genererar veckoplan…",
"plan.cooked": "Lagad",
"plan.skipped": "Överhoppad",
"plan.activate": "Aktivera plan",
"wte.coverage": "{pct} % hemma",
"wte.cravingPlaceholder": "Jag är sugen på … (t.ex. krämigt, asiatiskt, under 500 kcal)",
"wte.empty": "Inga förslag ännu lägg in lite mat i lagret eller lätta på filtren.",