diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte
index fb03bdf..df39f4b 100644
--- a/frontend/src/lib/planner/DayMealCard.svelte
+++ b/frontend/src/lib/planner/DayMealCard.svelte
@@ -17,15 +17,19 @@
isToday = false,
isSelected = false,
readonly = false,
- onaddrecipe
+ onaddrecipe,
+ onactionsheet
}: {
slot: Slot;
isToday?: boolean;
isSelected?: boolean;
readonly?: boolean;
onaddrecipe?: () => void;
+ onactionsheet?: () => void;
} = $props();
+ let actionSheetMode = $derived(!!onactionsheet && !!slot.recipe);
+
let metadata = $derived(
[
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
@@ -44,49 +48,66 @@
);
-
- {#if slot.recipe}
-
- {slot.recipe.name}
-
- {#if metadata}
-
{metadata}
- {/if}
-
- {#if !readonly}
-
- {/if}
- {:else}
-
Kein Gericht geplant
- {#if !readonly && onaddrecipe}
-
- {/if}
+{#snippet recipeInfo()}
+
+ {slot.recipe?.name ?? ''}
+
+ {#if metadata}
+
{metadata}
{/if}
-
+{/snippet}
+
+{#if actionSheetMode}
+
+{:else}
+
+ {#if slot.recipe}
+ {@render recipeInfo()}
+
+ {#if !readonly}
+
+ {/if}
+ {:else}
+
Kein Gericht geplant
+ {#if !readonly && onaddrecipe}
+
+ {/if}
+ {/if}
+
+{/if}
diff --git a/frontend/src/lib/planner/DayMealCard.test.ts b/frontend/src/lib/planner/DayMealCard.test.ts
index cafa53a..c8f2ac0 100644
--- a/frontend/src/lib/planner/DayMealCard.test.ts
+++ b/frontend/src/lib/planner/DayMealCard.test.ts
@@ -81,4 +81,38 @@ describe('DayMealCard', () => {
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
});
+
+ describe('onactionsheet prop (mobile full-card tap target)', () => {
+ it('card renders as a button when onactionsheet provided and recipe exists', () => {
+ render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
+ const card = screen.getByRole('button', { name: /Pasta Bolognese/i });
+ expect(card).toBeTruthy();
+ });
+
+ it('clicking card calls onactionsheet', async () => {
+ const onactionsheet = vi.fn();
+ const user = userEvent.setup();
+ render(DayMealCard, { props: { slot, onactionsheet } });
+ await user.click(screen.getByRole('button', { name: /Pasta Bolognese/i }));
+ expect(onactionsheet).toHaveBeenCalledOnce();
+ });
+
+ it('inline Jetzt kochen and Tauschen buttons are hidden when onactionsheet provided', () => {
+ render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
+ expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
+ });
+
+ it('falls back to normal rendering when onactionsheet not provided', () => {
+ render(DayMealCard, { props: { slot, readonly: false, onaddrecipe: vi.fn() } });
+ expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
+ expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
+ });
+
+ it('empty slot does not render card as button even when onactionsheet provided', () => {
+ const emptySlot = { id: 's2', slotDate: '2026-03-31', recipe: null };
+ render(DayMealCard, { props: { slot: emptySlot, onactionsheet: vi.fn(), onaddrecipe: vi.fn() } });
+ expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
+ });
+ });
});
diff --git a/frontend/src/lib/planner/MealActionSheet.svelte b/frontend/src/lib/planner/MealActionSheet.svelte
new file mode 100644
index 0000000..d0b6fde
--- /dev/null
+++ b/frontend/src/lib/planner/MealActionSheet.svelte
@@ -0,0 +1,111 @@
+
+
+{#if open}
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+ {slot.recipe?.name ?? ''}
+
+
+
+ {#if meta}
+
+ {meta}
+
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/frontend/src/lib/planner/MealActionSheet.test.ts b/frontend/src/lib/planner/MealActionSheet.test.ts
new file mode 100644
index 0000000..6307790
--- /dev/null
+++ b/frontend/src/lib/planner/MealActionSheet.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { userEvent } from '@testing-library/user-event';
+import MealActionSheet from './MealActionSheet.svelte';
+
+const slot = {
+ id: 's1',
+ slotDate: '2026-04-08',
+ recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 }
+};
+
+const baseProps = {
+ open: true,
+ slot,
+ onswap: vi.fn(),
+ oncancel: vi.fn()
+};
+
+describe('MealActionSheet', () => {
+ it('renders meal title', () => {
+ render(MealActionSheet, { props: baseProps });
+ expect(screen.getByText('Tomato pasta')).toBeTruthy();
+ });
+
+ it('renders meal metadata', () => {
+ render(MealActionSheet, { props: baseProps });
+ expect(screen.getByText(/45 min/i)).toBeTruthy();
+ expect(screen.getByText(/easy/i)).toBeTruthy();
+ });
+
+ it('renders all 4 action buttons', () => {
+ render(MealActionSheet, { props: baseProps });
+ expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
+ expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
+ expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
+ });
+
+ it('Jetzt kochen links to the cook route', () => {
+ render(MealActionSheet, { props: baseProps });
+ const link = screen.getByRole('link', { name: /Jetzt kochen/i });
+ expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
+ });
+
+ it('Rezept ansehen links to the recipe detail route', () => {
+ render(MealActionSheet, { props: baseProps });
+ const link = screen.getByRole('link', { name: /Rezept ansehen/i });
+ expect(link.getAttribute('href')).toBe('/recipes/r1');
+ });
+
+ it('clicking Gericht tauschen calls onswap', async () => {
+ const onswap = vi.fn();
+ const user = userEvent.setup();
+ render(MealActionSheet, { props: { ...baseProps, onswap } });
+ await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
+ expect(onswap).toHaveBeenCalledOnce();
+ });
+
+ it('clicking Abbrechen calls oncancel', async () => {
+ const oncancel = vi.fn();
+ const user = userEvent.setup();
+ render(MealActionSheet, { props: { ...baseProps, oncancel } });
+ await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
+ expect(oncancel).toHaveBeenCalledOnce();
+ });
+
+ it('clicking backdrop calls oncancel', async () => {
+ const oncancel = vi.fn();
+ const user = userEvent.setup();
+ render(MealActionSheet, { props: { ...baseProps, oncancel } });
+ await user.click(screen.getByTestId('sheet-backdrop'));
+ expect(oncancel).toHaveBeenCalledOnce();
+ });
+
+ it('does not render when open is false', () => {
+ render(MealActionSheet, { props: { ...baseProps, open: false } });
+ expect(screen.queryByText('Tomato pasta')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
new file mode 100644
index 0000000..98ee619
--- /dev/null
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -0,0 +1,126 @@
+
+
+
+
+
+ Wird ersetzt
+
+
+ {replacingName}{#if replacingMeta} · {replacingMeta}{/if}
+
+
+
+
+
+ Ersetzen durch (einfachste zuerst)
+
+
+
+{#if visibleRecipes.length === 0}
+
+ Keine Rezepte verfügbar.
+
+{:else}
+ {#each visibleRecipes as recipe (recipe.id)}
+ {@const meta = recipeMeta(recipe)}
+ {@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
+
+
+
+ {recipe.name}
+
+ {#if meta}
+
+ {meta}
+
+ {/if}
+ {#if alreadyPlanned}
+
+ ⚠ Bereits diese Woche
+
+ {/if}
+
+
+
+ {/each}
+{/if}
+
+
+{#if oncancel}
+
+{/if}
diff --git a/frontend/src/lib/planner/SwapSuggestionList.test.ts b/frontend/src/lib/planner/SwapSuggestionList.test.ts
new file mode 100644
index 0000000..755f2cd
--- /dev/null
+++ b/frontend/src/lib/planner/SwapSuggestionList.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { userEvent } from '@testing-library/user-event';
+import SwapSuggestionList from './SwapSuggestionList.svelte';
+
+const recipes = [
+ { id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 },
+ { id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 },
+ { id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 }
+];
+
+const baseProps = {
+ replacingName: 'Tomato pasta',
+ replacingMeta: '45 min · Easy',
+ recipes,
+ currentWeekRecipeIds: new Set(),
+ onpick: vi.fn()
+};
+
+describe('SwapSuggestionList', () => {
+ it('renders the Replacing banner', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
+ });
+
+ it('renders old meal name with strikethrough', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ const struck = screen.getByTestId('replacing-name');
+ expect(struck.textContent).toContain('Tomato pasta');
+ expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through');
+ });
+
+ it('replacing-name span has title attribute for full name', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ const struck = screen.getByTestId('replacing-name');
+ expect(struck.getAttribute('title')).toBe('Tomato pasta');
+ });
+
+ it('renders the easiest-first eyebrow label', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
+ });
+
+ it('renders all recipe names', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.getByText('Quick carbonara')).toBeTruthy();
+ expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
+ expect(screen.getByText('Mushroom risotto')).toBeTruthy();
+ });
+
+ it('clicking Wählen calls onpick with recipeId and name', async () => {
+ const onpick = vi.fn();
+ const user = userEvent.setup();
+ render(SwapSuggestionList, { props: { ...baseProps, onpick } });
+ const buttons = screen.getAllByRole('button', { name: /Wählen/i });
+ await user.click(buttons[0]);
+ expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
+ });
+
+ it('shows already-planned warning for recipes in currentWeekRecipeIds', () => {
+ render(SwapSuggestionList, {
+ props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) }
+ });
+ expect(screen.getByTestId('already-planned-r2')).toBeTruthy();
+ });
+
+ it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.queryByTestId('already-planned-r1')).toBeNull();
+ });
+
+ it('shows empty state when no recipes', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } });
+ expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
+ });
+
+ it('excludes the recipe being replaced when excludeRecipeId is provided', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } });
+ expect(screen.queryByText('Chicken stir-fry')).toBeNull();
+ expect(screen.getByText('Quick carbonara')).toBeTruthy();
+ expect(screen.getByText('Mushroom risotto')).toBeTruthy();
+ });
+
+ it('shows all recipes when excludeRecipeId is not provided', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.getByText('Quick carbonara')).toBeTruthy();
+ expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
+ expect(screen.getByText('Mushroom risotto')).toBeTruthy();
+ });
+
+ it('disables all Wählen buttons when isLoading is true', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } });
+ const buttons = screen.getAllByRole('button', { name: /Wählen/i });
+ buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
+ });
+
+ it('Wählen buttons are enabled when isLoading is false', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } });
+ const buttons = screen.getAllByRole('button', { name: /Wählen/i });
+ buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
+ });
+
+ it('renders optional Abbrechen button when oncancel provided', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
+ expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
+ });
+
+ it('does not render Abbrechen button when oncancel not provided', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
+ });
+
+ it('clicking Abbrechen calls oncancel', async () => {
+ const oncancel = vi.fn();
+ const user = userEvent.setup();
+ render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
+ await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
+ expect(oncancel).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/lib/planner/week.test.ts b/frontend/src/lib/planner/week.test.ts
index 0b919e6..8c433cf 100644
--- a/frontend/src/lib/planner/week.test.ts
+++ b/frontend/src/lib/planner/week.test.ts
@@ -6,7 +6,8 @@ import {
weekDays,
isToday,
formatWeekRange,
- formatDayLabel
+ formatDayLabel,
+ sortEasiestFirst
} from './week';
describe('getWeekStart', () => {
@@ -144,3 +145,52 @@ describe('formatDayLabel', () => {
expect(formatDayLabel('2026-03-30')).toContain(',');
});
});
+
+describe('sortEasiestFirst', () => {
+ it('sorts easy before medium before hard', () => {
+ const recipes = [
+ { id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
+ { id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 },
+ { id: '3', name: 'Medium', effort: 'medium', cookTimeMin: 10 }
+ ];
+ const sorted = sortEasiestFirst(recipes);
+ expect(sorted.map((r) => r.effort)).toEqual(['easy', 'medium', 'hard']);
+ });
+
+ it('sorts by cookTimeMin ascending within same effort', () => {
+ const recipes = [
+ { id: '1', name: 'Slow Easy', effort: 'easy', cookTimeMin: 60 },
+ { id: '2', name: 'Fast Easy', effort: 'easy', cookTimeMin: 15 }
+ ];
+ const sorted = sortEasiestFirst(recipes);
+ expect(sorted[0].name).toBe('Fast Easy');
+ });
+
+ it('treats missing effort as after hard', () => {
+ const recipes = [
+ { id: '1', name: 'No effort', effort: undefined, cookTimeMin: 5 },
+ { id: '2', name: 'Hard', effort: 'hard', cookTimeMin: 5 }
+ ];
+ const sorted = sortEasiestFirst(recipes);
+ expect(sorted[0].effort).toBe('hard');
+ });
+
+ it('treats missing cookTimeMin as after defined values', () => {
+ const recipes = [
+ { id: '1', name: 'No time', effort: 'easy', cookTimeMin: undefined },
+ { id: '2', name: 'Has time', effort: 'easy', cookTimeMin: 30 }
+ ];
+ const sorted = sortEasiestFirst(recipes);
+ expect(sorted[0].name).toBe('Has time');
+ });
+
+ it('does not mutate the original array', () => {
+ const recipes = [
+ { id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
+ { id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 }
+ ];
+ const original = [...recipes];
+ sortEasiestFirst(recipes);
+ expect(recipes[0].effort).toBe(original[0].effort);
+ });
+});
diff --git a/frontend/src/lib/planner/week.ts b/frontend/src/lib/planner/week.ts
index 69c8b76..6b31114 100644
--- a/frontend/src/lib/planner/week.ts
+++ b/frontend/src/lib/planner/week.ts
@@ -75,6 +75,25 @@ export function isToday(dateStr: string): boolean {
return dateStr === todayStr;
}
+const EFFORT_ORDER: Record = { easy: 0, medium: 1, hard: 2 };
+
+/**
+ * Returns a new array of recipes sorted easiest first (effort ASC, cookTimeMin ASC).
+ * Used for the J4 mid-week swap context — different from variety-first sorting in J2.
+ */
+export function sortEasiestFirst(
+ recipes: T[]
+): T[] {
+ return [...recipes].sort((a, b) => {
+ const ea = a.effort != null ? (EFFORT_ORDER[a.effort] ?? 99) : 99;
+ const eb = b.effort != null ? (EFFORT_ORDER[b.effort] ?? 99) : 99;
+ if (ea !== eb) return ea - eb;
+ const ta = a.cookTimeMin ?? Infinity;
+ const tb = b.cookTimeMin ?? Infinity;
+ return ta - tb;
+ });
+}
+
/**
* Formats a week range: "30. Mär – 5. Apr 2026".
*/
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte
index 1b69802..ba03be8 100644
--- a/frontend/src/routes/(app)/planner/+page.svelte
+++ b/frontend/src/routes/(app)/planner/+page.svelte
@@ -6,9 +6,11 @@
import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte';
+ import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
+ import SwapSuggestionList from '$lib/planner/SwapSuggestionList.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
- import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
+ import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } from '$lib/planner/week';
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
@@ -55,8 +57,19 @@
let panelState = $state({ kind: 'idle' });
- // Mobile bottom sheet for RecipePicker
+ // Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false);
+ let actionSheetOpen = $state(false);
+ let swapSheetOpen = $state(false);
+ let swapLoading = $state(false);
+
+ // Recipes already in any slot this week — used for ⚠ overlap warnings
+ let currentWeekRecipeIds = $derived(
+ new Set(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
+ );
+
+ // Recipes sorted easiest-first for the swap suggestion list
+ let sortedRecipes = $derived(sortEasiestFirst(data.recipes));
// Hidden form field bindings
let addPlanId = $state('');
@@ -126,6 +139,13 @@
deleteSlotFormEl.requestSubmit();
}
+ async function handleSwapPick(recipeId: string, recipeName: string) {
+ swapLoading = true;
+ await handleRecipePick(recipeId, recipeName);
+ swapSheetOpen = false;
+ swapLoading = false;
+ }
+
function closePanelToIdle() {
panelState = { kind: 'idle' };
}
@@ -205,7 +225,8 @@
isToday={selectedDay === today}
isSelected={true}
readonly={!isPlanner}
- onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined}
+ onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
+ onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
/>
@@ -255,7 +276,7 @@
{/if}
-
+
(pickerOpen = false)}>
+
+
+ { actionSheetOpen = false; swapSheetOpen = true; }}
+ oncancel={() => (actionSheetOpen = false)}
+ />
+
+
+ (swapSheetOpen = false)} height="70vh">
+ {@const replacingMeta = [
+ selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
+ selectedSlot.recipe?.effort ?? null
+ ].filter(Boolean).join(' · ')}
+
+ (swapSheetOpen = false)}
+ />
+
+
@@ -472,11 +521,13 @@
{:else if panelState.kind === 'recipe-picker'}
{@const pickerDate = panelState.date}
+ {@const pickerSlot = slotMap[pickerDate] ?? null}
+ {@const isSwapContext = !!pickerSlot?.recipe}
- Rezept wählen
+ {isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
-
-
-
+ {#if isSwapContext}
+ {@const replacingMeta = [
+ pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
+ pickerSlot.recipe.effort ?? null
+ ].filter(Boolean).join(' · ')}
+
+
+
+ {:else}
+
+
+
+ {/if}
{/if}