From dac83c70ea4ba663631740c15843aecd6342646c Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:02:47 +0200
Subject: [PATCH 01/11] feat(planner): DayMealCard gains onactionsheet prop for
full-card mobile tap target
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/DayMealCard.svelte | 100 +++++++++++--------
frontend/src/lib/planner/DayMealCard.test.ts | 34 +++++++
2 files changed, 95 insertions(+), 39 deletions(-)
diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte
index fb03bdf..26eae34 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,67 @@
);
-
- {#if slot.recipe}
+{#if actionSheetMode}
+
+{:else}
+
+ {#if slot.recipe}
+
+ {slot.recipe.name}
+
+ {#if metadata}
+
{metadata}
+ {/if}
- {#if !readonly}
-
+ Jetzt kochen
+
+ {#if onaddrecipe}
+
+ {/if}
+
+ {/if}
+ {:else}
+
Kein Gericht geplant
+ {#if !readonly && onaddrecipe}
+
+ {/if}
{/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();
+ });
+ });
});
--
2.49.1
From 8756bf93d937859f3d4ea853cfbf99523a29d2f4 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:03:47 +0200
Subject: [PATCH 02/11] feat(planner): add sortEasiestFirst utility for J4 swap
context
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/week.test.ts | 52 ++++++++++++++++++++++++++-
frontend/src/lib/planner/week.ts | 19 ++++++++++
2 files changed, 70 insertions(+), 1 deletion(-)
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".
*/
--
2.49.1
From 1b2a02881dd666fb0f578649e578b383ba9e2605 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:07:46 +0200
Subject: [PATCH 03/11] feat(planner): add MealActionSheet component for mobile
swap trigger
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/planner/MealActionSheet.svelte | 111 ++++++++++++++++++
.../src/lib/planner/MealActionSheet.test.ts | 79 +++++++++++++
2 files changed, 190 insertions(+)
create mode 100644 frontend/src/lib/planner/MealActionSheet.svelte
create mode 100644 frontend/src/lib/planner/MealActionSheet.test.ts
diff --git a/frontend/src/lib/planner/MealActionSheet.svelte b/frontend/src/lib/planner/MealActionSheet.svelte
new file mode 100644
index 0000000..9199d35
--- /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..399952c
--- /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: /Swap this meal/i })).toBeTruthy();
+ expect(screen.getByRole('link', { name: /Cook now/i })).toBeTruthy();
+ expect(screen.getByRole('link', { name: /View recipe/i })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy();
+ });
+
+ it('Cook now links to the cook route', () => {
+ render(MealActionSheet, { props: baseProps });
+ const link = screen.getByRole('link', { name: /Cook now/i });
+ expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
+ });
+
+ it('View recipe links to the recipe detail route', () => {
+ render(MealActionSheet, { props: baseProps });
+ const link = screen.getByRole('link', { name: /View recipe/i });
+ expect(link.getAttribute('href')).toBe('/recipes/r1');
+ });
+
+ it('clicking Swap this meal calls onswap', async () => {
+ const onswap = vi.fn();
+ const user = userEvent.setup();
+ render(MealActionSheet, { props: { ...baseProps, onswap } });
+ await user.click(screen.getByRole('button', { name: /Swap this meal/i }));
+ expect(onswap).toHaveBeenCalledOnce();
+ });
+
+ it('clicking Cancel calls oncancel', async () => {
+ const oncancel = vi.fn();
+ const user = userEvent.setup();
+ render(MealActionSheet, { props: { ...baseProps, oncancel } });
+ await user.click(screen.getByRole('button', { name: /Cancel/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();
+ });
+});
--
2.49.1
From c8c2605f3187324f1f48f82be698d743c0c85f61 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:10:44 +0200
Subject: [PATCH 04/11] feat(planner): add SwapSuggestionList component for J4
swap context
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/planner/SwapSuggestionList.svelte | 116 ++++++++++++++++++
.../lib/planner/SwapSuggestionList.test.ts | 88 +++++++++++++
2 files changed, 204 insertions(+)
create mode 100644 frontend/src/lib/planner/SwapSuggestionList.svelte
create mode 100644 frontend/src/lib/planner/SwapSuggestionList.test.ts
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
new file mode 100644
index 0000000..68273f8
--- /dev/null
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -0,0 +1,116 @@
+
+
+
+
+
+ Replacing
+
+
+ {replacingName}{#if replacingMeta}
{replacingMeta}{/if}
+
+
+
+
+
+ Swap to (easiest first)
+
+
+
+{#if recipes.length === 0}
+
+ Keine Rezepte verfügbar.
+
+{:else}
+ {#each recipes 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..9fc9f32
--- /dev/null
+++ b/frontend/src/lib/planner/SwapSuggestionList.test.ts
@@ -0,0 +1,88 @@
+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(/Replacing/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('renders the easiest-first eyebrow label', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.getByText(/easiest first/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 Pick 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: /Pick/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('renders optional Cancel button when oncancel provided', () => {
+ render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
+ expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy();
+ });
+
+ it('does not render Cancel button when oncancel not provided', () => {
+ render(SwapSuggestionList, { props: baseProps });
+ expect(screen.queryByRole('button', { name: /Cancel/i })).toBeNull();
+ });
+
+ it('clicking Cancel calls oncancel', async () => {
+ const oncancel = vi.fn();
+ const user = userEvent.setup();
+ render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
+ await user.click(screen.getByRole('button', { name: /Cancel/i }));
+ expect(oncancel).toHaveBeenCalledOnce();
+ });
+});
--
2.49.1
From dd9a86d4e9a26d78359569cf78adaf42cb8a7589 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:13:45 +0200
Subject: [PATCH 05/11] =?UTF-8?q?feat(planner):=20wire=20J4=20swap=20flow?=
=?UTF-8?q?=20=E2=80=94=20mobile=20action=20sheet=20+=20desktop=20inline?=
=?UTF-8?q?=20panel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mobile: DayMealCard tap opens MealActionSheet; Swap → SwapSuggestionsSheet
(BottomSheet + SwapSuggestionList, easiest-first). Empty slots still open
RecipePicker directly.
Desktop: recipe-picker panel detects swap context (slot has recipe) and
renders SwapSuggestionList; empty slots continue to show RecipePicker.
Co-Authored-By: Claude Sonnet 4.6
---
.../src/routes/(app)/planner/+page.svelte | 94 +++++++++++++++----
1 file changed, 78 insertions(+), 16 deletions(-)
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte
index 1b69802..5de86c8 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,18 @@
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);
+
+ // 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 +138,11 @@
deleteSlotFormEl.requestSubmit();
}
+ async function handleSwapPick(recipeId: string, recipeName: string) {
+ swapSheetOpen = false;
+ await handleRecipePick(recipeId, recipeName);
+ }
+
function closePanelToIdle() {
panelState = { kind: 'idle' };
}
@@ -205,7 +222,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 +273,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 +516,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}
--
2.49.1
From 30722d9bcc2803820a4e38c56dfc1bd26a776c75 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:28:56 +0200
Subject: [PATCH 06/11] refactor(planner): extract shared recipe info markup
into DayMealCard snippet
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/DayMealCard.svelte | 23 ++++++++++-----------
1 file changed, 11 insertions(+), 12 deletions(-)
diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte
index 26eae34..df39f4b 100644
--- a/frontend/src/lib/planner/DayMealCard.svelte
+++ b/frontend/src/lib/planner/DayMealCard.svelte
@@ -48,6 +48,15 @@
);
+{#snippet recipeInfo()}
+
+ {slot.recipe?.name ?? ''}
+
+ {#if metadata}
+ {metadata}
+ {/if}
+{/snippet}
+
{#if actionSheetMode}
{:else}
{#if slot.recipe}
-
- {slot.recipe.name}
-
- {#if metadata}
-
{metadata}
- {/if}
+ {@render recipeInfo()}
{#if !readonly}
--
2.49.1
From 8e3256d960cb24ef07cbc11d76bd6b5d39564abf Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:29:54 +0200
Subject: [PATCH 07/11] fix(planner): translate SwapSuggestionList copy to
German
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/planner/SwapSuggestionList.svelte | 8 ++++----
.../lib/planner/SwapSuggestionList.test.ts | 20 +++++++++----------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
index 68273f8..d49210d 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.svelte
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -39,7 +39,7 @@
- Replacing
+ Wird ersetzt
- Swap to (easiest first)
+ Ersetzen durch (einfachste zuerst)
@@ -98,7 +98,7 @@
onclick={() => onpick(recipe.id, recipe.name)}
style="background: none; border: none; cursor: pointer; font-size: 11px; font-weight: 500; color: var(--green); font-family: var(--font-sans); flex-shrink: 0;"
>
- Pick
+ Wählen
{/each}
@@ -111,6 +111,6 @@
onclick={oncancel}
style="width: 100%; background: none; border: none; cursor: pointer; color: var(--color-text-muted); font-size: 13px; text-align: center; padding: 8px 0; font-family: var(--font-sans);"
>
- Cancel
+ Abbrechen
{/if}
diff --git a/frontend/src/lib/planner/SwapSuggestionList.test.ts b/frontend/src/lib/planner/SwapSuggestionList.test.ts
index 9fc9f32..1313db4 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.test.ts
+++ b/frontend/src/lib/planner/SwapSuggestionList.test.ts
@@ -20,7 +20,7 @@ const baseProps = {
describe('SwapSuggestionList', () => {
it('renders the Replacing banner', () => {
render(SwapSuggestionList, { props: baseProps });
- expect(screen.getByText(/Replacing/i)).toBeTruthy();
+ expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
});
it('renders old meal name with strikethrough', () => {
@@ -32,7 +32,7 @@ describe('SwapSuggestionList', () => {
it('renders the easiest-first eyebrow label', () => {
render(SwapSuggestionList, { props: baseProps });
- expect(screen.getByText(/easiest first/i)).toBeTruthy();
+ expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
});
it('renders all recipe names', () => {
@@ -42,11 +42,11 @@ describe('SwapSuggestionList', () => {
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
- it('clicking Pick calls onpick with recipeId and name', async () => {
+ 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: /Pick/i });
+ const buttons = screen.getAllByRole('button', { name: /Wählen/i });
await user.click(buttons[0]);
expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
});
@@ -68,21 +68,21 @@ describe('SwapSuggestionList', () => {
expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
});
- it('renders optional Cancel button when oncancel provided', () => {
+ it('renders optional Abbrechen button when oncancel provided', () => {
render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
- expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
});
- it('does not render Cancel button when oncancel not provided', () => {
+ it('does not render Abbrechen button when oncancel not provided', () => {
render(SwapSuggestionList, { props: baseProps });
- expect(screen.queryByRole('button', { name: /Cancel/i })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
});
- it('clicking Cancel calls oncancel', async () => {
+ 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: /Cancel/i }));
+ await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
});
--
2.49.1
From 278fda7d90a67615d49d623644c239c6fd1f9ab1 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:30:39 +0200
Subject: [PATCH 08/11] fix(planner): translate MealActionSheet button labels
to German
Co-Authored-By: Claude Sonnet 4.6
---
.../src/lib/planner/MealActionSheet.svelte | 8 +++----
.../src/lib/planner/MealActionSheet.test.ts | 24 +++++++++----------
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/frontend/src/lib/planner/MealActionSheet.svelte b/frontend/src/lib/planner/MealActionSheet.svelte
index 9199d35..d0b6fde 100644
--- a/frontend/src/lib/planner/MealActionSheet.svelte
+++ b/frontend/src/lib/planner/MealActionSheet.svelte
@@ -79,7 +79,7 @@
style="width:100%;background:var(--orange-tint);border:1px solid #FBCDA4;color:var(--orange-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
onclick={onswap}
>
- ↻ Swap this meal
+ ↻ Gericht tauschen
{#if slot.recipe}
@@ -87,14 +87,14 @@
href="/recipes/{slot.recipe.id}/cook"
style="display:block;width:100%;background:var(--green-tint);border:1px solid var(--green-light);color:var(--green-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
>
- 🍳 Cook now
+ 🍳 Jetzt kochen
- 👁 View recipe
+ 👁 Rezept ansehen
{/if}
@@ -103,7 +103,7 @@
style="width:100%;background:none;border:none;color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;text-align:center;cursor:pointer;padding:12px"
onclick={oncancel}
>
- Cancel
+ Abbrechen
diff --git a/frontend/src/lib/planner/MealActionSheet.test.ts b/frontend/src/lib/planner/MealActionSheet.test.ts
index 399952c..6307790 100644
--- a/frontend/src/lib/planner/MealActionSheet.test.ts
+++ b/frontend/src/lib/planner/MealActionSheet.test.ts
@@ -30,37 +30,37 @@ describe('MealActionSheet', () => {
it('renders all 4 action buttons', () => {
render(MealActionSheet, { props: baseProps });
- expect(screen.getByRole('button', { name: /Swap this meal/i })).toBeTruthy();
- expect(screen.getByRole('link', { name: /Cook now/i })).toBeTruthy();
- expect(screen.getByRole('link', { name: /View recipe/i })).toBeTruthy();
- expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy();
+ 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('Cook now links to the cook route', () => {
+ it('Jetzt kochen links to the cook route', () => {
render(MealActionSheet, { props: baseProps });
- const link = screen.getByRole('link', { name: /Cook now/i });
+ const link = screen.getByRole('link', { name: /Jetzt kochen/i });
expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
});
- it('View recipe links to the recipe detail route', () => {
+ it('Rezept ansehen links to the recipe detail route', () => {
render(MealActionSheet, { props: baseProps });
- const link = screen.getByRole('link', { name: /View recipe/i });
+ const link = screen.getByRole('link', { name: /Rezept ansehen/i });
expect(link.getAttribute('href')).toBe('/recipes/r1');
});
- it('clicking Swap this meal calls onswap', async () => {
+ 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: /Swap this meal/i }));
+ await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
expect(onswap).toHaveBeenCalledOnce();
});
- it('clicking Cancel calls oncancel', async () => {
+ 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: /Cancel/i }));
+ await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
--
2.49.1
From 9482ecbf36cc5c5a062a7547099b582a257c2a4d Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:31:13 +0200
Subject: [PATCH 09/11] fix(planner): add truncation and title attribute to
replacing-name span
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/SwapSuggestionList.svelte | 5 +++--
frontend/src/lib/planner/SwapSuggestionList.test.ts | 6 ++++++
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
index d49210d..0049a93 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.svelte
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -43,9 +43,10 @@
- {replacingName}{#if replacingMeta}
{replacingMeta}{/if}
+ {replacingName}{#if replacingMeta} · {replacingMeta}{/if}
diff --git a/frontend/src/lib/planner/SwapSuggestionList.test.ts b/frontend/src/lib/planner/SwapSuggestionList.test.ts
index 1313db4..d8b3294 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.test.ts
+++ b/frontend/src/lib/planner/SwapSuggestionList.test.ts
@@ -30,6 +30,12 @@ describe('SwapSuggestionList', () => {
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();
--
2.49.1
From b4fa3ca23ebe35cf86e2d30a11f8d3b58139290c Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:32:59 +0200
Subject: [PATCH 10/11] =?UTF-8?q?feat(planner):=20add=20isLoading=20prop?=
=?UTF-8?q?=20to=20SwapSuggestionList=20=E2=80=94=20disables=20Pick=20butt?=
=?UTF-8?q?ons=20during=20PATCH?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/SwapSuggestionList.svelte | 5 ++++-
frontend/src/lib/planner/SwapSuggestionList.test.ts | 12 ++++++++++++
frontend/src/routes/(app)/planner/+page.svelte | 6 +++++-
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
index 0049a93..3a928e9 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.svelte
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -11,6 +11,7 @@
replacingMeta,
recipes,
currentWeekRecipeIds,
+ isLoading = false,
onpick,
oncancel
}: {
@@ -18,6 +19,7 @@
replacingMeta?: string;
recipes: Recipe[];
currentWeekRecipeIds: Set;
+ isLoading?: boolean;
onpick: (recipeId: string, recipeName: string) => void;
oncancel?: () => void;
} = $props();
@@ -97,7 +99,8 @@
diff --git a/frontend/src/lib/planner/SwapSuggestionList.test.ts b/frontend/src/lib/planner/SwapSuggestionList.test.ts
index d8b3294..bd862a1 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.test.ts
+++ b/frontend/src/lib/planner/SwapSuggestionList.test.ts
@@ -74,6 +74,18 @@ describe('SwapSuggestionList', () => {
expect(screen.getByTestId('swap-empty-state')).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();
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte
index 5de86c8..769e219 100644
--- a/frontend/src/routes/(app)/planner/+page.svelte
+++ b/frontend/src/routes/(app)/planner/+page.svelte
@@ -61,6 +61,7 @@
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(
@@ -139,8 +140,10 @@
}
async function handleSwapPick(recipeId: string, recipeName: string) {
- swapSheetOpen = false;
+ swapLoading = true;
await handleRecipePick(recipeId, recipeName);
+ swapSheetOpen = false;
+ swapLoading = false;
}
function closePanelToIdle() {
@@ -306,6 +309,7 @@
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
+ isLoading={swapLoading}
onpick={handleSwapPick}
oncancel={() => (swapSheetOpen = false)}
/>
--
2.49.1
From f0bbb3b0091df3a0a2588fee798c37be4e2db0e0 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 9 Apr 2026 10:38:30 +0200
Subject: [PATCH 11/11] fix(planner): exclude current recipe from swap
suggestions
Adds excludeRecipeId prop to SwapSuggestionList so the meal being
replaced is not offered as a swap candidate.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/lib/planner/SwapSuggestionList.svelte | 10 ++++++++--
.../src/lib/planner/SwapSuggestionList.test.ts | 14 ++++++++++++++
frontend/src/routes/(app)/planner/+page.svelte | 2 ++
3 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte
index 3a928e9..98ee619 100644
--- a/frontend/src/lib/planner/SwapSuggestionList.svelte
+++ b/frontend/src/lib/planner/SwapSuggestionList.svelte
@@ -11,6 +11,7 @@
replacingMeta,
recipes,
currentWeekRecipeIds,
+ excludeRecipeId,
isLoading = false,
onpick,
oncancel
@@ -19,11 +20,16 @@
replacingMeta?: string;
recipes: Recipe[];
currentWeekRecipeIds: Set;
+ excludeRecipeId?: string;
isLoading?: boolean;
onpick: (recipeId: string, recipeName: string) => void;
oncancel?: () => void;
} = $props();
+ let visibleRecipes = $derived(
+ excludeRecipeId ? recipes.filter((r) => r.id !== excludeRecipeId) : recipes
+ );
+
function recipeMeta(recipe: Recipe): string {
return [
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} min` : null,
@@ -60,7 +66,7 @@
-{#if recipes.length === 0}
+{#if visibleRecipes.length === 0}
{:else}
- {#each recipes as recipe (recipe.id)}
+ {#each visibleRecipes as recipe (recipe.id)}
{@const meta = recipeMeta(recipe)}
{@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
{
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 });
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte
index 769e219..ba03be8 100644
--- a/frontend/src/routes/(app)/planner/+page.svelte
+++ b/frontend/src/routes/(app)/planner/+page.svelte
@@ -309,6 +309,7 @@
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
+ excludeRecipeId={selectedSlot.recipe?.id}
isLoading={swapLoading}
onpick={handleSwapPick}
oncancel={() => (swapSheetOpen = false)}
@@ -549,6 +550,7 @@
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
+ excludeRecipeId={pickerSlot.recipe.id}
onpick={handleRecipePick}
/>
--
2.49.1