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:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user