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>
);
}