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 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<PanelState>({ 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<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
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}
/>
</div>
@@ -255,7 +273,7 @@
</div>
{/if}
<!-- Mobile RecipePicker in BottomSheet -->
<!-- Mobile: empty slot → RecipePicker -->
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
<RecipePicker
planId={weekPlan?.id ?? ''}
@@ -267,6 +285,32 @@
onpick={handleRecipePick}
/>
</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>
<!-- Desktop: 3-panel layout -->
@@ -472,11 +516,13 @@
{:else if panelState.kind === 'recipe-picker'}
{@const pickerDate = panelState.date}
{@const pickerSlot = slotMap[pickerDate] ?? null}
{@const isSwapContext = !!pickerSlot?.recipe}
<!-- Panel header with back/close button -->
<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)]">
Rezept wählen
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
</p>
<button
type="button"
@@ -488,17 +534,33 @@
</button>
</div>
<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 isSwapContext}
{@const replacingMeta = [
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
pickerSlot.recipe.effort ?? null
].filter(Boolean).join(' · ')}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4 px-4">
<SwapSuggestionList
replacingName={pickerSlot.recipe.name}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{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}
</aside>
</div>