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();
+ });
+});