fix(veckoplan): byt-ratt-fel, navigera veckor, lagg till maltid manuellt
- Byt rätt gav 'något gick fel': limit=30 översteg schemats tak (20). Höjt taket till 50, byt rätt hämtar 40 + sök vilken rätt som helst. - Veckonavigering: pilarna byter nu VECKA (weekOffset) i stället för att bara flytta markerad dag -> går att planera nästa vecka. Titel visar veckans datumintervall; bakåt spärras före denna vecka. - Lägg till måltid: ny POST /v1/week-plans/:id/entries + knapp 'Lägg till måltid' (väljer måltidstyp -> receptväljaren i lägg-till-läge) + knapp 'Planera om veckan'. Går nu att fylla en tom dag igen. Verifierat: swap 200 (40 varierade), add-entry 201, delete 200, api 104/104.
This commit is contained in:
@@ -69,21 +69,38 @@ export default function PlanScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedKey, setSelectedKey] = useState<string>(() => toKey(new Date()));
|
||||
const [actionEntry, setActionEntry] = useState<WeekPlanEntry | null>(null);
|
||||
const [addMealOpen, setAddMealOpen] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [weekOffset, setWeekOffset] = useState(0); // 0 = denna vecka, 1 = nästa vecka …
|
||||
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(() => {
|
||||
// Planeringsfönster: 7 dagar från veckostart (idag + veckoförskjutning).
|
||||
// Aldrig bakåt i tiden – weekOffset klampas till >= 0.
|
||||
const todayMidnight = 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 weekStart = useMemo(
|
||||
() => addDays(todayMidnight, weekOffset * 7),
|
||||
[todayMidnight, weekOffset],
|
||||
);
|
||||
const weekStartKey = toKey(weekStart);
|
||||
const weekDays = useMemo(
|
||||
() => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)),
|
||||
[weekStart],
|
||||
);
|
||||
// Byt vecka (framåt/bakåt). Sätter markerad dag till första dagen i veckan.
|
||||
const goWeek = (delta: number) =>
|
||||
setWeekOffset((o) => {
|
||||
const next = Math.max(0, o + delta);
|
||||
setSelectedKey(toKey(addDays(todayMidnight, next * 7)));
|
||||
return next;
|
||||
});
|
||||
// Lunch + middag varje dag som default (fler fyllda måltider), går att toggla.
|
||||
const [mealSlots, setMealSlots] = useState<string[][]>(() =>
|
||||
weekDays.map(() => ["lunch", "dinner"]),
|
||||
Array.from({ length: 7 }, () => ["lunch", "dinner"]),
|
||||
);
|
||||
const toggleSlot = (dayIdx: number, meal: string) =>
|
||||
setMealSlots((prev) =>
|
||||
@@ -93,8 +110,8 @@ export default function PlanScreen() {
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["week-plan", mondayKey],
|
||||
queryFn: () => api<WeekPlansResponse>(`/v1/week-plans?weekStartDate=${mondayKey}`),
|
||||
queryKey: ["week-plan", weekStartKey],
|
||||
queryFn: () => api<WeekPlansResponse>(`/v1/week-plans?weekStartDate=${weekStartKey}`),
|
||||
refetchInterval: generating ? 3000 : false,
|
||||
});
|
||||
const plan = query.data?.plans?.[0] ?? null;
|
||||
@@ -129,7 +146,7 @@ export default function PlanScreen() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [generating]);
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["week-plan", mondayKey] });
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["week-plan", weekStartKey] });
|
||||
const onMutationError = (err: unknown) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
|
||||
@@ -137,7 +154,7 @@ export default function PlanScreen() {
|
||||
mutationFn: () =>
|
||||
api("/v1/week-plans/generate", {
|
||||
method: "POST",
|
||||
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"], mealSlots },
|
||||
body: { weekStartDate: weekStartKey, mealTypes: ["lunch", "dinner"], mealSlots },
|
||||
}),
|
||||
onSuccess: () => setGenerating(true),
|
||||
onError: onMutationError,
|
||||
@@ -183,9 +200,11 @@ export default function PlanScreen() {
|
||||
if (planId) setActionEntry(entry);
|
||||
};
|
||||
|
||||
const monthLabel = cap(
|
||||
new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(selected),
|
||||
);
|
||||
const weekRangeLabel = `${new Intl.DateTimeFormat(locale, { day: "numeric" }).format(
|
||||
weekStart,
|
||||
)}–${new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(
|
||||
addDays(weekStart, 6),
|
||||
)}`;
|
||||
const dayHeader = cap(
|
||||
new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long" }).format(
|
||||
selected,
|
||||
@@ -196,13 +215,18 @@ export default function PlanScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Pressable onPress={() => setSelectedKey(toKey(addDays(selected, -7)))} hitSlop={8}>
|
||||
<Pressable
|
||||
onPress={() => goWeek(-1)}
|
||||
hitSlop={8}
|
||||
disabled={weekOffset === 0}
|
||||
style={{ opacity: weekOffset === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
<Text style={{ fontSize: 22, color: colors.textMuted, paddingHorizontal: spacing.sm }}>
|
||||
‹
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Title>{monthLabel}</Title>
|
||||
<Pressable onPress={() => setSelectedKey(toKey(addDays(selected, 7)))} hitSlop={8}>
|
||||
<Title>{weekRangeLabel}</Title>
|
||||
<Pressable onPress={() => goWeek(1)} hitSlop={8}>
|
||||
<Text style={{ fontSize: 22, color: colors.textMuted, paddingHorizontal: spacing.sm }}>
|
||||
›
|
||||
</Text>
|
||||
@@ -210,7 +234,10 @@ export default function PlanScreen() {
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setSelectedKey(todayKey)}
|
||||
onPress={() => {
|
||||
setWeekOffset(0);
|
||||
setSelectedKey(todayKey);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", paddingVertical: spacing.xs }}
|
||||
>
|
||||
<Text style={{ color: colors.primary, fontSize: 13, fontWeight: "600" }}>
|
||||
@@ -356,6 +383,18 @@ export default function PlanScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lägg till en måltid på vald dag (även tom dag), så länge en plan finns. */}
|
||||
{planId && !generating ? (
|
||||
<>
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button
|
||||
label={t("plan.addMeal")}
|
||||
variant="secondary"
|
||||
onPress={() => setAddMealOpen(true)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{hasEntries ? (
|
||||
<>
|
||||
<Spacer size={spacing.sm} />
|
||||
@@ -365,9 +404,59 @@ export default function PlanScreen() {
|
||||
onPress={() => planId && createList.mutate(planId)}
|
||||
loading={createList.isPending}
|
||||
/>
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button
|
||||
label={t("plan.regenerate")}
|
||||
variant="ghost"
|
||||
onPress={() => generate.mutate()}
|
||||
loading={generate.isPending}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Välj måltidstyp att lägga till – navigerar till receptväljaren i "lägg till"-läge. */}
|
||||
<Modal
|
||||
visible={addMealOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setAddMealOpen(false)}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => setAddMealOpen(false)}
|
||||
style={{ flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.4)" }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {}}
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
}}
|
||||
>
|
||||
<Heading>{t("plan.addMeal")}</Heading>
|
||||
<Small>{dayHeader}</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
{(["breakfast", "lunch", "dinner", "snack", "dessert"] as const).map((meal) => (
|
||||
<View key={meal}>
|
||||
<Button
|
||||
label={mealTypeLabel(meal)}
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setAddMealOpen(false);
|
||||
router.push(
|
||||
`/swap-meal/new?planId=${planId}&mealType=${meal}&date=${selectedKey}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Spacer size={spacing.xs} />
|
||||
</View>
|
||||
))}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{/* Åtgärdspanel för en måltid – tryck utanför för att stänga, ingen Avbryt. */}
|
||||
<Modal
|
||||
visible={actionEntry !== null}
|
||||
|
||||
@@ -30,10 +30,17 @@ interface WhatToEatResponse {
|
||||
}
|
||||
|
||||
export default function SwapMealScreen() {
|
||||
const params = useLocalSearchParams<{ entryId: string; planId: string; mealType: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
entryId: string;
|
||||
planId: string;
|
||||
mealType: string;
|
||||
date?: string;
|
||||
}>();
|
||||
const entryId = String(params.entryId);
|
||||
const planId = String(params.planId);
|
||||
const mealType = String(params.mealType || "dinner");
|
||||
const date = typeof params.date === "string" ? params.date : "";
|
||||
const addMode = date.length > 0; // "lägg till" i stället för "byt ut"
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -44,7 +51,7 @@ export default function SwapMealScreen() {
|
||||
// Med sökning: byt till VILKEN rätt som helst (mealType filtreras då bort på
|
||||
// servern). Utan sökning: breda förslag för slotten (view=swap, låg täckningsvikt).
|
||||
queryFn: () => {
|
||||
const base = "/v1/recommendations/what-to-eat?limit=30&view=swap";
|
||||
const base = "/v1/recommendations/what-to-eat?limit=40&view=swap";
|
||||
const path = submitted
|
||||
? `${base}&search=${encodeURIComponent(submitted)}`
|
||||
: `${base}&mealType=${mealType}`;
|
||||
@@ -54,7 +61,15 @@ export default function SwapMealScreen() {
|
||||
|
||||
const swap = useMutation({
|
||||
mutationFn: (recipeId: string) =>
|
||||
api(`/v1/week-plans/${planId}/entries/${entryId}`, { method: "PATCH", body: { recipeId } }),
|
||||
addMode
|
||||
? api(`/v1/week-plans/${planId}/entries`, {
|
||||
method: "POST",
|
||||
body: { date, mealType, recipeId },
|
||||
})
|
||||
: api(`/v1/week-plans/${planId}/entries/${entryId}`, {
|
||||
method: "PATCH",
|
||||
body: { recipeId },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["week-plan"] });
|
||||
router.back();
|
||||
@@ -69,8 +84,11 @@ export default function SwapMealScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>Byt ut måltiden</Title>
|
||||
<Small>Välj en annan rätt för {mealLabel} – eller sök efter vilken rätt som helst.</Small>
|
||||
<Title>{addMode ? "Lägg till måltid" : "Byt ut måltiden"}</Title>
|
||||
<Small>
|
||||
{addMode ? `Välj en rätt för ${mealLabel}` : `Välj en annan rätt för ${mealLabel}`} – eller
|
||||
sök efter vilken rätt som helst.
|
||||
</Small>
|
||||
<Spacer size={spacing.sm} />
|
||||
<Input
|
||||
placeholder="Sök bland alla recept …"
|
||||
|
||||
Reference in New Issue
Block a user