diff --git a/apps/api/src/routes/planning.ts b/apps/api/src/routes/planning.ts index 8330148..5d1df60 100644 --- a/apps/api/src/routes/planning.ts +++ b/apps/api/src/routes/planning.ts @@ -146,6 +146,38 @@ export async function planningRoutes(app: FastifyInstance) { return row; }); + // Ta bort en planpost helt (t.ex. "Ta bort" på en måltid man inte lagat). + app.delete("/v1/week-plans/:id/entries/:entryId", auth, async (req) => { + const params = req.params as { id: string; entryId: string }; + const [plan] = await app.db + .select() + .from(schema.weekPlans) + .where(eq(schema.weekPlans.id, params.id)) + .limit(1); + if (!plan) throw errors.notFound("Planen finns inte."); + await requireMembership(app.db, plan.householdId, req.userId); + + const [row] = await app.db + .delete(schema.weekPlanEntries) + .where( + and( + eq(schema.weekPlanEntries.id, params.entryId), + eq(schema.weekPlanEntries.weekPlanId, params.id), + ), + ) + .returning(); + if (!row) throw errors.notFound("Planposten finns inte."); + + await emitEvent(app.db, { + type: "WEEK_PLAN_UPDATED", + payload: { weekPlanId: params.id, reason: "removed" }, + userId: req.userId, + householdId: plan.householdId, + correlationId: req.correlationId, + }); + return { ok: true }; + }); + app.post("/v1/week-plans/:id/activate", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [plan] = await app.db diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx index 5445e4a..3d102a2 100644 --- a/apps/mobile/src/app/(tabs)/plan.tsx +++ b/apps/mobile/src/app/(tabs)/plan.tsx @@ -79,9 +79,9 @@ export default function PlanScreen() { }, []); const mondayKey = toKey(monday); const weekDays = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(monday, i)), [monday]); - // Vardag: middag. Helg (lör/sön): lunch + middag. Realistisk förifylld default. + // Lunch + middag varje dag som default (fler fyllda måltider), går att toggla. const [mealSlots, setMealSlots] = useState(() => - weekDays.map((d) => (d.getDay() === 0 || d.getDay() === 6 ? ["lunch", "dinner"] : ["dinner"])), + weekDays.map(() => ["lunch", "dinner"]), ); const toggleSlot = (dayIdx: number, meal: string) => setMealSlots((prev) => @@ -151,6 +151,13 @@ export default function PlanScreen() { onError: onMutationError, }); + const removeEntry = useMutation({ + mutationFn: (vars: { planId: string; entryId: string }) => + api(`/v1/week-plans/${vars.planId}/entries/${vars.entryId}`, { method: "DELETE" }), + onSuccess: () => invalidate(), + onError: onMutationError, + }); + const activate = useMutation({ mutationFn: (id: string) => api(`/v1/week-plans/${id}/activate`, { method: "POST" }), onSuccess: () => invalidate(), @@ -159,31 +166,39 @@ export default function PlanScreen() { const createList = useMutation({ mutationFn: (weekPlanId: string) => - api("/v1/shopping-lists", { + api<{ list: { id: string } }>("/v1/shopping-lists", { method: "POST", body: { weekPlanId, generateFromPlan: true }, }), - onSuccess: () => router.push("/shopping"), + // Navigera med listId så rätt (nygenererade) lista visas, inte en äldre tom. + onSuccess: (res) => router.push(`/shopping?listId=${res.list.id}`), 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" }), - }, + // "Jag lagade detta" bara på rätt dag – inte för framtida måltider. + ...(entry.date === todayKey + ? [ + { + text: t("recipe.iCookedThis"), + onPress: () => patchEntry.mutate({ planId, entryId: entry.id, status: "cooked" }), + }, + ] + : []), { text: "Byt rätt", onPress: () => router.push(`/swap-meal/${entry.id}?planId=${planId}&mealType=${entry.mealType}`), }, - { text: t("common.cancel"), style: "cancel" }, + // Ersätter "Hoppa över": ta bort måltiden man inte lagat. + { + text: t("common.remove"), + style: "destructive" as const, + onPress: () => removeEntry.mutate({ planId, entryId: entry.id }), + }, + { text: t("common.cancel"), style: "cancel" as const }, ]); }; diff --git a/apps/worker/src/processors/weekplan.ts b/apps/worker/src/processors/weekplan.ts index 4bdde97..53a271a 100644 --- a/apps/worker/src/processors/weekplan.ts +++ b/apps/worker/src/processors/weekplan.ts @@ -225,6 +225,13 @@ export async function processGenerateWeekPlan( let candidateIndex = 0; const entries: Array = []; + // Variationshjälpare: undvik två soppor / samma kök i rad, och styr soppor + // mot lunch snarare än middag. + const isSoup = (r: { titleSv: string }) => /sopp|soup/i.test(r.titleSv); + const signatureOf = (r: { cuisine: string; titleSv: string }) => + `${r.cuisine}|${isSoup(r) ? "soup" : "dish"}`; + let lastSignature: string | null = null; + for (let day = 0; day < days; day++) { const date = new Date(Date.parse(data.input.weekStartDate) + day * 86_400_000) .toISOString() @@ -246,21 +253,38 @@ export async function processGenerateWeekPlan( sortOrder: entries.length, }); boxIndex++; + lastSignature = null; continue; } - // Nästa bästa recept som passar måltidstyp och variationsregeln - let chosen = null; - for (let i = 0; i < scored.length; i++) { - const idx = (candidateIndex + i) % scored.length; - const candidate = scored[idx]!; - if (!candidate.recipe.mealTypes.includes(mealType)) continue; - const used = recipeUseCount.get(candidate.recipe.id) ?? 0; - if (used >= repeatLimit) continue; - chosen = candidate; - candidateIndex = idx + 1; - break; - } - if (!chosen) continue; + // Nästa bästa recept: passa måltidstyp, undvik upprepning i rad och + // soppa på middag. Lätta reglerna stegvis så en slot ALLTID fylls om + // det finns någon rätt av rätt måltidstyp (inga tomma dagar). + const pick = (o: { + avoidSig: string | null; + noSoupAtDinner: boolean; + respectRepeat: boolean; + }): { c: (typeof scored)[number]; idx: number } | null => { + for (let i = 0; i < scored.length; i++) { + const idx = (candidateIndex + i) % scored.length; + const c = scored[idx]!; + if (!c.recipe.mealTypes.includes(mealType)) continue; + if (o.respectRepeat && (recipeUseCount.get(c.recipe.id) ?? 0) >= repeatLimit) continue; + if (o.avoidSig && signatureOf(c.recipe) === o.avoidSig) continue; + if (o.noSoupAtDinner && mealType === "dinner" && isSoup(c.recipe)) continue; + return { c, idx }; + } + return null; + }; + const hit = + pick({ avoidSig: lastSignature, noSoupAtDinner: true, respectRepeat: true }) ?? + pick({ avoidSig: lastSignature, noSoupAtDinner: false, respectRepeat: true }) ?? + pick({ avoidSig: null, noSoupAtDinner: false, respectRepeat: true }) ?? + pick({ avoidSig: lastSignature, noSoupAtDinner: false, respectRepeat: false }) ?? + pick({ avoidSig: null, noSoupAtDinner: false, respectRepeat: false }); + if (!hit) continue; + const chosen = hit.c; + candidateIndex = hit.idx + 1; + lastSignature = signatureOf(chosen.recipe); recipeUseCount.set(chosen.recipe.id, (recipeUseCount.get(chosen.recipe.id) ?? 0) + 1); usedRecipeIds.add(chosen.recipe.id); const expiring = chosen.coverage.expiringUsed[0];