feat(veckoplan): fyll alla maltider, variation, ratt maltidstyp + plan-atgarder

Veckoplanen fyller nu ALLA slots (stegvis fallback, inga tomma dagar),
undviker tva soppor/samma kok i rad och styr soppor mot lunch (inte
middag). Default lunch+middag varje dag. Plan-atgarder: 'Jag lagade
detta' bara ratt dag; 'Hoppa over' ersatt med 'Ta bort' (ny DELETE
/v1/week-plans/:id/entries/:entryId). Verifierat mot 393 recept:
14/14 slots fyllda, 0 upprepningar, 0 soppor pa middag.
This commit is contained in:
Claude
2026-08-19 23:33:32 +00:00
parent 5867a95d64
commit 393bc35ac0
3 changed files with 97 additions and 26 deletions
+32
View File
@@ -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
+28 -13
View File
@@ -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<string[][]>(() =>
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 },
]);
};
+37 -13
View File
@@ -225,6 +225,13 @@ export async function processGenerateWeekPlan(
let candidateIndex = 0;
const entries: Array<typeof schema.weekPlanEntries.$inferInsert> = [];
// 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];