feat(mobile): veckoplan – hämta plan, rendera dagens måltider + generera-knapp med polling
CI / Typecheck, test & build (push) Successful in 1m34s
CI / Typecheck, test & build (push) Successful in 1m34s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -346,6 +346,8 @@
|
||||
"plan.today": "Aujourd’hui",
|
||||
"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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user