From dd9a86d4e9a26d78359569cf78adaf42cb8a7589 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 10:13:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(planner):=20wire=20J4=20swap=20flow=20?= =?UTF-8?q?=E2=80=94=20mobile=20action=20sheet=20+=20desktop=20inline=20pa?= =?UTF-8?q?nel?= 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}