diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 1488b2e..ec9196d 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -104,7 +104,7 @@ {meta}

{/if} - {#if !suggestion.hasConflict} + {#if (suggestion.scoreDelta ?? 0) > 0} ↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - {:else} + {:else if suggestion.hasConflict} { expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); }); + it('shows no badge when scoreDelta is zero (neutral, no improvement)', () => { + const neutralSuggestions = [ + { recipe: { id: 'sn', name: 'Neutrales Rezept', effort: 'easy', cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false } + ]; + render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } }); + expect(screen.queryByTestId('badge-sn')).toBeNull(); + }); + it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => { render(RecipePicker, { props: { ...baseProps, isLoading: true } }); expect(screen.getByTestId('suggestions-loading')).toBeTruthy(); diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index d281247..5fa8284 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -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 @@ /> - + { actionSheetOpen = false; swapSheetOpen = true; }} oncancel={() => (actionSheetOpen = false)} + onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined} /> @@ -530,6 +558,15 @@ > Gericht tauschen + {#if detailSlot.id} + + {/if} {/if} {: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; } diff --git a/frontend/src/routes/(app)/planner/page.test.ts b/frontend/src/routes/(app)/planner/page.test.ts index c400a88..fb2d181 100644 --- a/frontend/src/routes/(app)/planner/page.test.ts +++ b/frontend/src/routes/(app)/planner/page.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/svelte'; +import { render, screen, waitFor, within } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import Page from './+page.svelte'; @@ -13,13 +13,23 @@ const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001'; const DATE = '2025-01-06'; // Monday, January 6 2025 const mockData = { - weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] }, + weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] }, varietyScore: null, weekStart: DATE, recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }], benutzer: { rolle: 'planer' } }; +const mockDataWithSlot = { + ...mockData, + weekPlan: { + id: PLAN_ID, + weekStart: DATE, + status: 'draft', + slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }] + } +}; + const mockSuggestions = [ { recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 }, @@ -82,3 +92,35 @@ describe('+page.svelte — $effect suggestion fetch', () => { expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal); }); }); + +describe('+page.svelte — remove meal', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) })); + + render(Page, { props: { data: mockDataWithSlot } }); + + await userEvent.click(screen.getByTestId('day-meal-card')); + await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i })); + + const undoBar = screen.getByTestId('undo-bar'); + expect(undoBar).toBeTruthy(); + expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy(); + }); + + it('clicking Rückgängig after remove hides the undo bar', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) })); + + render(Page, { props: { data: mockDataWithSlot } }); + + await userEvent.click(screen.getByTestId('day-meal-card')); + await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i })); + + await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i })); + + expect(screen.queryByTestId('undo-bar')).toBeNull(); + }); +});