From c5f8d6396491f8623a305ef8b1e651a78ac0ba44 Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 14 Aug 2026 02:38:59 +0700 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20veckoplan=20=E2=80=93=20h=C3=A4?= =?UTF-8?q?mta=20plan,=20rendera=20dagens=20m=C3=A5ltider=20+=20generera-k?= =?UTF-8?q?napp=20med=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/app/(tabs)/plan.tsx | 154 ++++++++++++++++++++++--- apps/mobile/src/locales/da/common.json | 2 + apps/mobile/src/locales/de/common.json | 2 + apps/mobile/src/locales/en/common.json | 2 + apps/mobile/src/locales/es/common.json | 2 + apps/mobile/src/locales/fi/common.json | 2 + apps/mobile/src/locales/fr/common.json | 2 + apps/mobile/src/locales/it/common.json | 2 + apps/mobile/src/locales/nb/common.json | 2 + apps/mobile/src/locales/nl/common.json | 2 + apps/mobile/src/locales/pl/common.json | 2 + apps/mobile/src/locales/pt/common.json | 2 + apps/mobile/src/locales/sv/common.json | 2 + 13 files changed, 163 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx index 4d73829..941af5c 100644 --- a/apps/mobile/src/app/(tabs)/plan.tsx +++ b/apps/mobile/src/app/(tabs)/plan.tsx @@ -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(() => 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(`/v1/week-plans?weekStartDate=${mondayKey}`), + refetchInterval: generating ? 3000 : false, + }); + const plan = query.data?.plans?.[0] ?? null; + + const entriesByDate = useMemo(() => { + const map: Record = {}; + 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 ( {d.getDate()} + + {count > 0 ? "•".repeat(Math.min(count, 3)) : " "} + ); })} {dayHeader} - + + + {query.isLoading ? ( + + ) : query.isError ? ( + void query.refetch()} /> + ) : generating ? ( + <> + {t("plan.generating")} + + + ) : selectedEntries.length > 0 ? ( + selectedEntries.map((entry) => ( + + + {entry.titleSv} + + + {t("plan.portions", { count: entry.portions })} + + )) + ) : ( + <> + + {plan === null && ( +