feat(mobile): veckoplan – hämta plan, rendera dagens måltider + generera-knapp med polling
CI / Typecheck, test & build (push) Successful in 1m34s

This commit is contained in:
Sven (AAMOS AI)
2026-08-14 02:38:59 +07:00
parent ebc3b75cea
commit c5f8d63964
13 changed files with 163 additions and 15 deletions
+139 -15
View File
@@ -1,34 +1,70 @@
import { useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { useEffect, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { getLanguageTag, t } from "@/lib/i18n";
import { colors, radius, spacing } from "@/lib/theme";
import { EmptyState, Screen, Small, Title } from "@/components/ui";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
/** Veckoplanerare (spec §25): dagsvy + veckostrip. Data kopplas in i nästa steg. */
/** Veckoplanerare (spec §25): dagsvy + veckostrip, kopplad till /v1/week-plans. */
interface WeekPlanEntry {
id: string;
date: string;
mealType: string;
recipeId: string | null;
mealBoxId: string | null;
titleSv: string;
portions: number;
status: string;
rescheduleReasonSv: string | null;
sortOrder: number;
}
interface WeekPlan {
id: string;
weekStartDate: string;
status: string;
entries: WeekPlanEntry[];
}
interface WeekPlansResponse {
plans: WeekPlan[];
}
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack", "dessert"];
const mealTypeLabel = (mealType: string): string =>
MEAL_ORDER.includes(mealType) ? t(`myday.mealType.${mealType}`) : mealType;
/** Lokalt datum YYYY-MM-DD (undviker UTC-skift från toISOString). */
function toKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function addDays(d: Date, n: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + n);
return copy;
}
/** Måndag som veckostart. */
function startOfWeekMonday(d: Date): Date {
const copy = new Date(d);
const dow = (copy.getDay() + 6) % 7; // 0 = måndag
const dow = (copy.getDay() + 6) % 7;
copy.setDate(copy.getDate() - dow);
copy.setHours(0, 0, 0, 0);
return copy;
}
function cap(s: string): string {
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
}
@@ -36,13 +72,58 @@ function cap(s: string): string {
export default function PlanScreen() {
const locale = getLanguageTag();
const [selectedKey, setSelectedKey] = useState<string>(() => toKey(new Date()));
const [generating, setGenerating] = useState(false);
const todayKey = toKey(new Date());
const selected = useMemo(() => new Date(`${selectedKey}T00:00:00`), [selectedKey]);
const weekDays = useMemo(() => {
const monday = startOfWeekMonday(selected);
return Array.from({ length: 7 }, (_, i) => addDays(monday, i));
}, [selected]);
const monday = useMemo(() => startOfWeekMonday(selected), [selected]);
const mondayKey = toKey(monday);
const weekDays = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(monday, i)), [monday]);
const query = useQuery({
queryKey: ["week-plan", mondayKey],
queryFn: () => api<WeekPlansResponse>(`/v1/week-plans?weekStartDate=${mondayKey}`),
refetchInterval: generating ? 3000 : false,
});
const plan = query.data?.plans?.[0] ?? null;
const entriesByDate = useMemo(() => {
const map: Record<string, WeekPlanEntry[]> = {};
for (const e of plan?.entries ?? []) {
(map[e.date] ??= []).push(e);
}
for (const list of Object.values(map)) {
list.sort(
(a, b) =>
MEAL_ORDER.indexOf(a.mealType) - MEAL_ORDER.indexOf(b.mealType) ||
a.sortOrder - b.sortOrder,
);
}
return map;
}, [plan]);
const selectedEntries = entriesByDate[selectedKey] ?? [];
useEffect(() => {
if (generating && (plan?.entries?.length ?? 0) > 0) setGenerating(false);
}, [generating, plan]);
useEffect(() => {
if (!generating) return;
const timer = setTimeout(() => setGenerating(false), 45000);
return () => clearTimeout(timer);
}, [generating]);
const generate = useMutation({
mutationFn: () =>
api("/v1/week-plans/generate", {
method: "POST",
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"] },
}),
onSuccess: () => setGenerating(true),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const monthLabel = cap(
new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(selected),
@@ -90,6 +171,7 @@ export default function PlanScreen() {
const key = toKey(d);
const isSelected = key === selectedKey;
const isToday = key === todayKey;
const count = entriesByDate[key]?.length ?? 0;
return (
<Pressable
key={key}
@@ -121,13 +203,55 @@ export default function PlanScreen() {
>
{d.getDate()}
</Text>
<Text
style={{
fontSize: 10,
lineHeight: 12,
minHeight: 12,
color: isSelected ? colors.surface : colors.primary,
}}
>
{count > 0 ? "•".repeat(Math.min(count, 3)) : " "}
</Text>
</Pressable>
);
})}
</View>
<Small>{dayHeader}</Small>
<EmptyState text={t("plan.emptyDay")} />
<Spacer size={spacing.xs} />
{query.isLoading ? (
<LoadingView />
) : query.isError ? (
<ErrorView onRetry={() => void query.refetch()} />
) : generating ? (
<>
<Small>{t("plan.generating")}</Small>
<LoadingView />
</>
) : selectedEntries.length > 0 ? (
selectedEntries.map((entry) => (
<Card key={entry.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{entry.titleSv}</Body>
<Tag label={mealTypeLabel(entry.mealType)} />
</Row>
<Small>{t("plan.portions", { count: entry.portions })}</Small>
</Card>
))
) : (
<>
<EmptyState text={t("plan.emptyDay")} />
{plan === null && (
<Button
label={t("plan.generate")}
onPress={() => generate.mutate()}
loading={generate.isPending}
/>
)}
</>
)}
</Screen>
);
}
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "I dag",
"plan.emptyDay": "Intet planlagt",
"plan.portions": "{count} portioner",
"plan.generate": "Generer ugeplan",
"plan.generating": "Genererer ugeplan…",
"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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Heute",
"plan.emptyDay": "Nichts geplant",
"plan.portions": "{count} Portionen",
"plan.generate": "Wochenplan erstellen",
"plan.generating": "Wochenplan wird erstellt…",
"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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Today",
"plan.emptyDay": "Nothing planned",
"plan.portions": "{count} servings",
"plan.generate": "Generate week plan",
"plan.generating": "Generating week 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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Hoy",
"plan.emptyDay": "Nada planeado",
"plan.portions": "{count} porciones",
"plan.generate": "Generar plan semanal",
"plan.generating": "Generando 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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Tänään",
"plan.emptyDay": "Ei suunnitelmia",
"plan.portions": "{count} annosta",
"plan.generate": "Luo viikkosuunnitelma",
"plan.generating": "Luodaan suunnitelmaa…",
"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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Aujourdhui",
"plan.emptyDay": "Rien de prévu",
"plan.portions": "{count} portions",
"plan.generate": "Générer le plan",
"plan.generating": "Génération du 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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Oggi",
"plan.emptyDay": "Niente in programma",
"plan.portions": "{count} porzioni",
"plan.generate": "Genera piano settimanale",
"plan.generating": "Generazione 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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "I dag",
"plan.emptyDay": "Ingenting planlagt",
"plan.portions": "{count} porsjoner",
"plan.generate": "Generer ukeplan",
"plan.generating": "Genererer ukeplan…",
"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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Vandaag",
"plan.emptyDay": "Niets gepland",
"plan.portions": "{count} porties",
"plan.generate": "Weekplan genereren",
"plan.generating": "Weekplan genereren…",
"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.",
+2
View File
@@ -360,6 +360,8 @@
"plan.today": "Dziś",
"plan.emptyDay": "Nic nie zaplanowano",
"plan.portions": "{count} porcji",
"plan.generate": "Generuj plan tygodnia",
"plan.generating": "Generowanie planu…",
"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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Hoje",
"plan.emptyDay": "Nada planeado",
"plan.portions": "{count} porções",
"plan.generate": "Gerar plano semanal",
"plan.generating": "A gerar 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.",
+2
View File
@@ -346,6 +346,8 @@
"plan.today": "Idag",
"plan.emptyDay": "Inget planerat",
"plan.portions": "{count} portioner",
"plan.generate": "Generera veckoplan",
"plan.generating": "Genererar veckoplan…",
"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.",