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
+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];