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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user