feat(planner): J4 swap flow — action sheet + easiest-first suggestions #45
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user