312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Alert, Pressable, Text, View } from "react-native";
|
||
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 startOfWeekMonday(d: Date): Date {
|
||
const copy = new Date(d);
|
||
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;
|
||
}
|
||
|
||
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]);
|
||
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 planId = plan?.id ?? null;
|
||
const draftPlanId = plan && plan.status === "draft" ? plan.id : 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 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 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 && (
|
||
<Button
|
||
label={t("plan.generate")}
|
||
onPress={() => generate.mutate()}
|
||
loading={generate.isPending}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</Screen>
|
||
);
|
||
}
|