feat(planner): wire J4 swap flow — mobile action sheet + desktop inline panel

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 10:13:45 +02:00
parent c8c2605f31
commit dd9a86d4e9

View File

@@ -6,9 +6,11 @@
import WeekStrip from '$lib/planner/WeekStrip.svelte'; import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte'; import DayMealCard from '$lib/planner/DayMealCard.svelte';
import RecipePicker from '$lib/planner/RecipePicker.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 BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.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(); let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
@@ -55,8 +57,18 @@
let panelState = $state<PanelState>({ kind: 'idle' }); let panelState = $state<PanelState>({ kind: 'idle' });
// Mobile bottom sheet for RecipePicker // Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false); 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<string>(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 // Hidden form field bindings
let addPlanId = $state(''); let addPlanId = $state('');
@@ -126,6 +138,11 @@
deleteSlotFormEl.requestSubmit(); deleteSlotFormEl.requestSubmit();
} }
async function handleSwapPick(recipeId: string, recipeName: string) {
swapSheetOpen = false;
await handleRecipePick(recipeId, recipeName);
}
function closePanelToIdle() { function closePanelToIdle() {
panelState = { kind: 'idle' }; panelState = { kind: 'idle' };
} }
@@ -205,7 +222,8 @@
isToday={selectedDay === today} isToday={selectedDay === today}
isSelected={true} isSelected={true}
readonly={!isPlanner} readonly={!isPlanner}
onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined} onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
/> />
</div> </div>
@@ -255,7 +273,7 @@
</div> </div>
{/if} {/if}
<!-- Mobile RecipePicker in BottomSheet --> <!-- Mobile: empty slot → RecipePicker -->
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}> <BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
<RecipePicker <RecipePicker
planId={weekPlan?.id ?? ''} planId={weekPlan?.id ?? ''}
@@ -267,6 +285,32 @@
onpick={handleRecipePick} onpick={handleRecipePick}
/> />
</BottomSheet> </BottomSheet>
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
<MealActionSheet
open={actionSheetOpen}
slot={selectedSlot}
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
oncancel={() => (actionSheetOpen = false)}
/>
<!-- Mobile: swap suggestions sheet -->
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
{@const replacingMeta = [
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
selectedSlot.recipe?.effort ?? null
].filter(Boolean).join(' · ')}
<div style="padding: 16px;">
<SwapSuggestionList
replacingName={selectedSlot.recipe?.name ?? ''}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
onpick={handleSwapPick}
oncancel={() => (swapSheetOpen = false)}
/>
</div>
</BottomSheet>
</div> </div>
<!-- Desktop: 3-panel layout --> <!-- Desktop: 3-panel layout -->
@@ -472,11 +516,13 @@
{:else if panelState.kind === 'recipe-picker'} {:else if panelState.kind === 'recipe-picker'}
{@const pickerDate = panelState.date} {@const pickerDate = panelState.date}
{@const pickerSlot = slotMap[pickerDate] ?? null}
{@const isSwapContext = !!pickerSlot?.recipe}
<!-- Panel header with back/close button --> <!-- Panel header with back/close button -->
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]"> <p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Rezept wählen {isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
</p> </p>
<button <button
type="button" type="button"
@@ -488,17 +534,33 @@
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto -mx-4 -mb-4"> {#if isSwapContext}
<RecipePicker {@const replacingMeta = [
planId={weekPlan?.id ?? ''} pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
date={pickerDate} pickerSlot.recipe.effort ?? null
dateLabel={formatDayLabel(pickerDate)} ].filter(Boolean).join(' · ')}
currentVarietyScore={varietyScore?.score ?? 0} <div class="flex-1 overflow-y-auto -mx-4 -mb-4 px-4">
suggestions={[]} <SwapSuggestionList
allRecipes={data.recipes} replacingName={pickerSlot.recipe.name}
onpick={handleRecipePick} replacingMeta={replacingMeta || undefined}
/> recipes={sortedRecipes}
</div> {currentWeekRecipeIds}
onpick={handleRecipePick}
/>
</div>
{:else}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
currentVarietyScore={varietyScore?.score ?? 0}
suggestions={[]}
allRecipes={data.recipes}
onpick={handleRecipePick}
/>
</div>
{/if}
{/if} {/if}
</aside> </aside>
</div> </div>