feat(planner): add remove meal with undo; fix RecipePicker badge for neutral delta

- MealActionSheet: new onremove prop + Entfernen button (guarded by #if)
- +page.svelte: handleRemoveMeal submits delete form, shows undo bar;
  undo re-adds via addSlot form; refactored handleUndo to undoCallback
  pattern; desktop day-detail panel also gets Entfernen button
- RecipePicker: only show green +delta badge when scoreDelta > 0;
  neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt
- Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 12:47:53 +02:00
parent 77cdccb26c
commit 1de9dfc314
4 changed files with 96 additions and 6 deletions

View File

@@ -100,6 +100,7 @@
// UndoBar
let undoVisible = $state(false);
let undoMessage = $state('');
let undoCallback = $state<(() => void) | null>(null);
$effect(() => {
if (!activePickerDate || !weekPlan?.id) {
@@ -162,7 +163,33 @@
function handleUndo() {
undoVisible = false;
undoCallback?.();
}
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
// Capture primitive values immediately — slot may be a reactive proxy that
// becomes stale after the first await (tick flushes state + re-render).
const slotId = slot.id;
const slotDate = slot.slotDate;
const recipeName = slot.recipe?.name ?? '';
const recipeId = slot.recipe?.id ?? '';
if (!slotId || !recipeId) return;
actionSheetOpen = false;
undoCallback = async () => {
addPlanId = weekPlan!.id;
addSlotDate = slotDate;
addRecipeId = recipeId;
addRecipeName = recipeName;
await tick();
addSlotFormEl.requestSubmit();
};
delPlanId = weekPlan!.id;
delSlotId = slotId;
await tick();
deleteSlotFormEl.requestSubmit();
undoMessage = `${recipeName} entfernt`;
undoVisible = true;
}
async function handleSwapPick(recipeId: string, recipeName: string) {
@@ -315,12 +342,13 @@
/>
</BottomSheet>
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
<MealActionSheet
open={actionSheetOpen}
slot={selectedSlot}
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
oncancel={() => (actionSheetOpen = false)}
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
/>
<!-- Mobile: swap suggestions sheet -->
@@ -530,6 +558,15 @@
>
Gericht tauschen
</button>
{#if detailSlot.id}
<button
type="button"
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
>
Entfernen
</button>
{/if}
{/if}
</div>
{:else}
@@ -611,8 +648,10 @@
formData.set('recipeId', addRecipeId);
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.success) {
const slotId = (result.data as any)?.slot?.id ?? '';
delPlanId = addPlanId;
delSlotId = (result.data as any)?.slot?.id ?? '';
delSlotId = slotId;
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${addRecipeName} hinzugefügt`;
undoVisible = true;
}
@@ -639,6 +678,7 @@
if (result.type === 'success' && result.data?.success) {
delPlanId = updPlanId;
delSlotId = (result.data as any)?.slot?.id ?? '';
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${updRecipeName} eingetragen`;
undoVisible = true;
}