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:
Claude
2026-08-20 00:54:32 +00:00
parent 9b3456b080
commit ceceb63e8e
17 changed files with 232 additions and 34 deletions
+105 -16
View File
@@ -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}
+23 -5
View File
@@ -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 …"
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Ja, jeg lavede den",
"cookedLog.confirm": "Træk råvarer for {count} portioner fra dit lager?",
"cookedLog.doneTitle": "Registreret!",
"cookedLog.doneBody": "Råvarerne er trukket fra dit lager, og måltidet er registreret."
"cookedLog.doneBody": "Råvarerne er trukket fra dit lager, og måltidet er registreret.",
"plan.addMeal": "Tilføj et måltid",
"plan.regenerate": "Planlæg ugen igen"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Ja, ich habe es gekocht",
"cookedLog.confirm": "Zutaten für {count} Portionen von deinem Vorrat abziehen?",
"cookedLog.doneTitle": "Erfasst!",
"cookedLog.doneBody": "Die Zutaten wurden von deinem Vorrat abgezogen und die Mahlzeit ist erfasst."
"cookedLog.doneBody": "Die Zutaten wurden von deinem Vorrat abgezogen und die Mahlzeit ist erfasst.",
"plan.addMeal": "Mahlzeit hinzufügen",
"plan.regenerate": "Woche neu planen"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Yes, I cooked it",
"cookedLog.confirm": "Deduct ingredients for {count} servings from your stock?",
"cookedLog.doneTitle": "Logged!",
"cookedLog.doneBody": "The ingredients have been deducted from your stock and the meal is logged."
"cookedLog.doneBody": "The ingredients have been deducted from your stock and the meal is logged.",
"plan.addMeal": "Add a meal",
"plan.regenerate": "Re-plan the week"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Sí, lo cociné",
"cookedLog.confirm": "¿Descontar ingredientes para {count} raciones de tu despensa?",
"cookedLog.doneTitle": "¡Registrado!",
"cookedLog.doneBody": "Los ingredientes se han descontado de tu despensa y la comida queda registrada."
"cookedLog.doneBody": "Los ingredientes se han descontado de tu despensa y la comida queda registrada.",
"plan.addMeal": "Añadir una comida",
"plan.regenerate": "Replanificar la semana"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Kyllä, tein sen",
"cookedLog.confirm": "Vähennetäänkö ainekset {count} annokselle varastostasi?",
"cookedLog.doneTitle": "Merkitty!",
"cookedLog.doneBody": "Ainekset on vähennetty varastostasi ja ateria on kirjattu."
"cookedLog.doneBody": "Ainekset on vähennetty varastostasi ja ateria on kirjattu.",
"plan.addMeal": "Lisää ateria",
"plan.regenerate": "Suunnittele viikko uudelleen"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Oui, je l'ai cuisiné",
"cookedLog.confirm": "Déduire les ingrédients pour {count} portions de votre stock ?",
"cookedLog.doneTitle": "Enregistré !",
"cookedLog.doneBody": "Les ingrédients ont été déduits de votre stock et le repas est enregistré."
"cookedLog.doneBody": "Les ingrédients ont été déduits de votre stock et le repas est enregistré.",
"plan.addMeal": "Ajouter un repas",
"plan.regenerate": "Replanifier la semaine"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Sì, l'ho cucinato",
"cookedLog.confirm": "Scalare gli ingredienti per {count} porzioni dalla tua dispensa?",
"cookedLog.doneTitle": "Registrato!",
"cookedLog.doneBody": "Gli ingredienti sono stati scalati dalla tua dispensa e il pasto è registrato."
"cookedLog.doneBody": "Gli ingredienti sono stati scalati dalla tua dispensa e il pasto è registrato.",
"plan.addMeal": "Aggiungi un pasto",
"plan.regenerate": "Ripianifica la settimana"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Ja, jeg laget den",
"cookedLog.confirm": "Trekke råvarer for {count} porsjoner fra lageret ditt?",
"cookedLog.doneTitle": "Registrert!",
"cookedLog.doneBody": "Råvarene er trukket fra lageret ditt, og måltidet er logget."
"cookedLog.doneBody": "Råvarene er trukket fra lageret ditt, og måltidet er logget.",
"plan.addMeal": "Legg til et måltid",
"plan.regenerate": "Planlegg uken på nytt"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Ja, ik heb het gemaakt",
"cookedLog.confirm": "Ingrediënten voor {count} porties van je voorraad aftrekken?",
"cookedLog.doneTitle": "Vastgelegd!",
"cookedLog.doneBody": "De ingrediënten zijn van je voorraad afgetrokken en de maaltijd is vastgelegd."
"cookedLog.doneBody": "De ingrediënten zijn van je voorraad afgetrokken en de maaltijd is vastgelegd.",
"plan.addMeal": "Maaltijd toevoegen",
"plan.regenerate": "Week opnieuw plannen"
}
+3 -1
View File
@@ -500,5 +500,7 @@
"cookedLog.button": "Tak, ugotowałem",
"cookedLog.confirm": "Odjąć składniki na {count} porcji z Twojej spiżarni?",
"cookedLog.doneTitle": "Zapisano!",
"cookedLog.doneBody": "Składniki zostały odjęte z Twojej spiżarni, a posiłek zapisano."
"cookedLog.doneBody": "Składniki zostały odjęte z Twojej spiżarni, a posiłek zapisano.",
"plan.addMeal": "Dodaj posiłek",
"plan.regenerate": "Zaplanuj tydzień ponownie"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Sim, cozinhei",
"cookedLog.confirm": "Descontar ingredientes para {count} porções do seu stock?",
"cookedLog.doneTitle": "Registado!",
"cookedLog.doneBody": "Os ingredientes foram descontados do seu stock e a refeição fica registada."
"cookedLog.doneBody": "Os ingredientes foram descontados do seu stock e a refeição fica registada.",
"plan.addMeal": "Adicionar uma refeição",
"plan.regenerate": "Replanear a semana"
}
+3 -1
View File
@@ -486,5 +486,7 @@
"cookedLog.button": "Ja, jag lagade den",
"cookedLog.confirm": "Räkna av råvaror för {count} portioner från ditt lager?",
"cookedLog.doneTitle": "Registrerat!",
"cookedLog.doneBody": "Råvarorna är avräknade från ditt lager och måltiden är loggad."
"cookedLog.doneBody": "Råvarorna är avräknade från ditt lager och måltiden är loggad.",
"plan.addMeal": "Lägg till måltid",
"plan.regenerate": "Planera om veckan"
}