Files
Cibello-app/apps/mobile/src/app/(tabs)/plan.tsx
T

334 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { router } from "expo-router";
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";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
/** 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;
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;
}
function cap(s: string): string {
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
}
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());
const selected = useMemo(() => new Date(`${selectedKey}T00:00:00`), [selectedKey]);
// Veckoplanen startar alltid idag och går 7 dagar framåt (aldrig bakåt i tiden).
const monday = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
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 planId = plan?.id ?? null;
const draftPlanId = plan && plan.status === "draft" ? plan.id : null;
const hasEntries = (plan?.entries?.length ?? 0) > 0;
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 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", {
method: "POST",
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"] },
}),
onSuccess: () => setGenerating(true),
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 createList = useMutation({
mutationFn: (weekPlanId: string) =>
api("/v1/shopping-lists", {
method: "POST",
body: { weekPlanId, generateFromPlan: true },
}),
onSuccess: () => router.push("/shopping"),
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),
);
const dayHeader = cap(
new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long" }).format(
selected,
),
);
const weekdayShort = new Intl.DateTimeFormat(locale, { weekday: "short" });
return (
<Screen>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
<Pressable onPress={() => setSelectedKey(toKey(addDays(selected, -7)))} hitSlop={8}>
<Text style={{ fontSize: 22, color: colors.textMuted, paddingHorizontal: spacing.sm }}>
</Text>
</Pressable>
<Title>{monthLabel}</Title>
<Pressable onPress={() => setSelectedKey(toKey(addDays(selected, 7)))} hitSlop={8}>
<Text style={{ fontSize: 22, color: colors.textMuted, paddingHorizontal: spacing.sm }}>
</Text>
</Pressable>
</View>
<Pressable
onPress={() => setSelectedKey(todayKey)}
style={{ alignSelf: "flex-start", paddingVertical: spacing.xs }}
>
<Text style={{ color: colors.primary, fontSize: 13, fontWeight: "600" }}>
{t("plan.today")}
</Text>
</Pressable>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginVertical: spacing.sm,
}}
>
{weekDays.map((d) => {
const key = toKey(d);
const isSelected = key === selectedKey;
const isToday = key === todayKey;
const count = entriesByDate[key]?.length ?? 0;
return (
<Pressable
key={key}
onPress={() => setSelectedKey(key)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
style={{
flex: 1,
alignItems: "center",
paddingVertical: spacing.xs,
marginHorizontal: 2,
borderRadius: radius.md,
backgroundColor: isSelected
? colors.primary
: isToday
? colors.primarySoft
: "transparent",
}}
>
<Text style={{ fontSize: 12, color: isSelected ? colors.surface : colors.textMuted }}>
{cap(weekdayShort.format(d))}
</Text>
<Text
style={{
fontSize: 16,
fontWeight: isSelected || isToday ? "700" : "500",
color: isSelected ? colors.surface : colors.text,
}}
>
{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>
{draftPlanId ? (
<Button
label={t("plan.activate")}
variant="secondary"
onPress={() => draftPlanId && activate.mutate(draftPlanId)}
loading={activate.isPending}
/>
) : null}
<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) => {
const cooked = entry.status === "cooked";
const skipped = entry.status === "skipped";
return (
<Card key={entry.id} onPress={() => openActions(entry)}>
<Row style={{ justifyContent: "space-between" }}>
<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")} />
{(plan === null || !hasEntries) && (
<Button
label={t("plan.generate")}
onPress={() => generate.mutate()}
loading={generate.isPending}
/>
)}
</>
)}
{hasEntries ? (
<>
<Spacer size={spacing.sm} />
<Button
label={t("plan.createShopping")}
variant="secondary"
onPress={() => planId && createList.mutate(planId)}
loading={createList.isPending}
/>
</>
) : null}
</Screen>
);
}