feat(planner): overhaul desktop layout — flip tiles, no right panel

Replaces 3-panel layout with 2-panel (sidebar + full-width grid):
- Remove persistent right panel and toolbar + Gericht hinzufügen button
- grid-cols-7 tiles use DesktopDayTile (CSS 3D card flip)
- RecipePickerDrawer slides in on tile CTA / Gericht tauschen
- Page-owned activeSlotId + drawerOpen/drawerSlotId state
- Single Escape handler: drawer > flip priority
- Extend server load to forward recipe tags from /v1/recipes API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:04:26 +02:00
parent 2cebf504f2
commit f97cf49bd0
6 changed files with 136 additions and 237 deletions

View File

@@ -35,6 +35,7 @@
isPlanner, isPlanner,
slotMap, slotMap,
suggestions, suggestions,
topSuggestion,
onflip, onflip,
onclose, onclose,
onswap, onswap,
@@ -47,6 +48,7 @@
isPlanner: boolean; isPlanner: boolean;
slotMap: Record<string, any>; slotMap: Record<string, any>;
suggestions: Suggestion[]; suggestions: Suggestion[];
topSuggestion?: Suggestion;
onflip?: (slotId: string) => void; onflip?: (slotId: string) => void;
onclose?: () => void; onclose?: () => void;
onswap?: () => void; onswap?: () => void;
@@ -83,7 +85,7 @@
{#if slot.recipe} {#if slot.recipe}
<div <div
data-testid="day-meal-card" data-testid="day-meal-card-{slot.slotDate ?? ''}"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={slot.recipe?.name ?? 'Gericht'} aria-label={slot.recipe?.name ?? 'Gericht'}
@@ -155,7 +157,7 @@
slotId={slot.id ?? ''} slotId={slot.id ?? ''}
{isPlanner} {isPlanner}
{slotMap} {slotMap}
topSuggestion={undefined} {topSuggestion}
{onaddrecipe} {onaddrecipe}
/> />
{/if} {/if}

View File

@@ -27,30 +27,30 @@ describe('DesktopDayTile — filled slot', () => {
it('has data-testid="day-meal-card" on the scene element', () => { it('has data-testid="day-meal-card" on the scene element', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
expect(screen.getByTestId('day-meal-card')).toBeTruthy(); expect(screen.getByTestId("day-meal-card-2026-04-14")).toBeTruthy();
}); });
it('applies today ring when isToday', () => { it('applies today ring when isToday', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-today')).toBe('true'); expect(scene.getAttribute('data-today')).toBe('true');
}); });
it('applies selected ring when activeSlotId matches slot id', () => { it('applies selected ring when activeSlotId matches slot id', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-flipped')).toBe('true'); expect(scene.getAttribute('data-flipped')).toBe('true');
}); });
it('dims tile when another slot is active', () => { it('dims tile when another slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('true'); expect(scene.getAttribute('data-dimmed')).toBe('true');
}); });
it('is not dimmed when no slot is active', () => { it('is not dimmed when no slot is active', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('data-dimmed')).toBe('false'); expect(scene.getAttribute('data-dimmed')).toBe('false');
}); });
}); });
@@ -60,7 +60,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn(); const onflip = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
await user.click(screen.getByTestId('day-meal-card')); await user.click(screen.getByTestId("day-meal-card-2026-04-14"));
expect(onflip).toHaveBeenCalledWith('s1'); expect(onflip).toHaveBeenCalledWith('s1');
}); });
@@ -68,7 +68,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn(); const onflip = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId('day-meal-card').focus(); screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard('{Enter}'); await user.keyboard('{Enter}');
expect(onflip).toHaveBeenCalledWith('s1'); expect(onflip).toHaveBeenCalledWith('s1');
}); });
@@ -77,7 +77,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn(); const onflip = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } });
screen.getByTestId('day-meal-card').focus(); screen.getByTestId("day-meal-card-2026-04-14").focus();
await user.keyboard(' '); await user.keyboard(' ');
expect(onflip).toHaveBeenCalledWith('s1'); expect(onflip).toHaveBeenCalledWith('s1');
}); });
@@ -147,13 +147,13 @@ describe('DesktopDayTile — filled slot', () => {
it('aria-expanded is true when flipped', () => { it('aria-expanded is true when flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('true'); expect(scene.getAttribute('aria-expanded')).toBe('true');
}); });
it('aria-expanded is false when not flipped', () => { it('aria-expanded is false when not flipped', () => {
render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } });
const scene = screen.getByTestId('day-meal-card'); const scene = screen.getByTestId("day-meal-card-2026-04-14");
expect(scene.getAttribute('aria-expanded')).toBe('false'); expect(scene.getAttribute('aria-expanded')).toBe('false');
}); });
}); });

View File

@@ -60,8 +60,9 @@
</button> </button>
</div> </div>
<!-- RecipePicker content --> <!-- RecipePicker content — only mount when open to avoid duplicate text in DOM -->
<div style="overflow-y: auto; flex: 1;"> <div style="overflow-y: auto; flex: 1;">
{#if open}
<RecipePicker <RecipePicker
{planId} {planId}
date={slotDate} date={slotDate}
@@ -73,5 +74,6 @@
{excludeRecipeId} {excludeRecipeId}
{replacingRecipe} {replacingRecipe}
/> />
{/if}
</div> </div>
</div> </div>

View File

@@ -1,8 +1,16 @@
export interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
export interface Recipe { export interface Recipe {
id: string; id: string;
name: string; name: string;
effort?: string; effort?: string;
cookTimeMin?: number; cookTimeMin?: number;
heroImageUrl?: string | null;
tags?: TagItem[];
} }
export interface Suggestion { export interface Suggestion {

View File

@@ -21,7 +21,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
name: r.name!, name: r.name!,
cookTimeMin: r.cookTimeMin, cookTimeMin: r.cookTimeMin,
effort: r.effort, effort: r.effort,
heroImageUrl: r.heroImageUrl heroImageUrl: r.heroImageUrl,
tags: (r.tags ?? []).map((t: any) => ({ id: t.id, name: t.name, tagType: t.tagType }))
})); }));
if (weekPlanResult.error || !weekPlanResult.data?.id) { if (weekPlanResult.error || !weekPlanResult.data?.id) {

View File

@@ -5,7 +5,9 @@
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte'; import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
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 DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte'; import RecipePicker from '$lib/planner/RecipePicker.svelte';
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.svelte'; import MealActionSheet from '$lib/planner/MealActionSheet.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';
@@ -49,35 +51,27 @@
let weekRange = $derived(formatWeekRange(weekStart)); let weekRange = $derived(formatWeekRange(weekStart));
// Desktop right panel state machine
type PanelState =
| { kind: 'idle' }
| { kind: 'day-detail'; date: string }
| { kind: 'recipe-picker'; date: string };
let panelState = $state<PanelState>({ kind: 'idle' });
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow // Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false); let pickerOpen = $state(false);
let actionSheetOpen = $state(false); let actionSheetOpen = $state(false);
let swapSheetOpen = $state(false); let swapSheetOpen = $state(false);
let swapLoading = $state(false); let swapLoading = $state(false);
// Desktop flip tile + drawer state (page-owned per Kai's architecture decision)
let activeSlotId = $state<string | null>(null);
let drawerOpen = $state(false);
let drawerSlotId = $state<string | null>(null);
const activePickerDate = $derived( const activePickerDate = $derived(
pickerOpen ? selectedDay pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay : swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date : drawerOpen && drawerSlotId ? drawerSlotId
: null : null
); );
let suggestions: Suggestion[] = $state([]); let suggestions: Suggestion[] = $state([]);
let isLoadingSuggestions = $state(false); let isLoadingSuggestions = $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))
);
// Hidden form field bindings // Hidden form field bindings
let addPlanId = $state(''); let addPlanId = $state('');
let addSlotDate = $state(''); let addSlotDate = $state('');
@@ -115,9 +109,23 @@
return () => controller.abort(); return () => controller.abort();
}); });
// Single Escape key handler — priority: drawer > flip (Kai architecture decision)
$effect(() => {
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (drawerOpen) {
drawerOpen = false;
drawerSlotId = null;
} else if (activeSlotId) {
activeSlotId = null;
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function handleSelectDay(day: string) { function handleSelectDay(day: string) {
selectedDay = day; selectedDay = day;
panelState = { kind: 'day-detail', date: day };
} }
async function navigateWeek(direction: 'prev' | 'next' | 'today') { async function navigateWeek(direction: 'prev' | 'next' | 'today') {
@@ -130,14 +138,13 @@
} }
async function handleRecipePick(recipeId: string, recipeName: string) { async function handleRecipePick(recipeId: string, recipeName: string) {
// Capture date before modifying panel state // Drawer date takes priority (desktop), then mobile picker date
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay; const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
// Close pickers // Close all pickers
pickerOpen = false; pickerOpen = false;
if (panelState.kind === 'recipe-picker') { drawerOpen = false;
panelState = { kind: 'idle' }; drawerSlotId = null;
}
const existingSlot = slotMap[date]; const existingSlot = slotMap[date];
@@ -196,17 +203,39 @@
swapLoading = false; swapLoading = false;
} }
function closePanelToIdle() { // Desktop tile handlers
panelState = { kind: 'idle' }; function handleTileFlip(slotId: string) {
activeSlotId = slotId;
} }
function closePanelToDayDetail() { function handleTileClose() {
if (panelState.kind === 'recipe-picker') { activeSlotId = null;
panelState = { kind: 'day-detail', date: panelState.date };
} else {
panelState = { kind: 'idle' };
} }
function handleTileSwap(slotDate: string) {
activeSlotId = null;
drawerSlotId = slotDate;
drawerOpen = true;
} }
async function handleTileRemove(slot: any) {
activeSlotId = null;
await handleRemoveMeal(slot);
}
function handleEmptyTileAdd(slotDate: string) {
drawerSlotId = slotDate;
drawerOpen = true;
}
const drawerSlot = $derived(drawerSlotId ? (slotMap[drawerSlotId] ?? null) : null);
const drawerReplacingMeta = $derived(
drawerSlot?.recipe
? [drawerSlot.recipe.cookTimeMin ? `${drawerSlot.recipe.cookTimeMin} Min` : null, drawerSlot.recipe.effort ?? null]
.filter(Boolean)
.join(' · ')
: null
);
</script> </script>
<!-- Mobile & Tablet: vertical stack --> <!-- Mobile & Tablet: vertical stack -->
@@ -369,7 +398,7 @@
</BottomSheet> </BottomSheet>
</div> </div>
<!-- Desktop: 3-panel layout --> <!-- Desktop: 2-panel layout (sidebar + full-width flip-tile grid) -->
<div class="hidden h-screen lg:flex lg:flex-col"> <div class="hidden h-screen lg:flex lg:flex-col">
<!-- Topbar --> <!-- Topbar -->
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4"> <header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
@@ -400,21 +429,11 @@
Heute Heute
</button> </button>
</div> </div>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
+ Gericht hinzufügen
</button>
{/if}
</header> </header>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Left sidebar --> <!-- Left sidebar (unchanged) -->
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4"> <aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<!-- Variety widget at bottom -->
{#if varietyScore} {#if varietyScore}
<div class="mt-auto"> <div class="mt-auto">
<VarietyScoreCard <VarietyScoreCard
@@ -426,8 +445,8 @@
{/if} {/if}
</aside> </aside>
<!-- Main calendar (only scrollable panel) --> <!-- Main grid — full width, full height -->
<main class="flex-1 overflow-y-auto p-5"> <main class="flex-1 overflow-hidden p-5">
{#if !weekPlan} {#if !weekPlan}
<div class="flex h-full flex-col items-center justify-center"> <div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p> <p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
@@ -441,198 +460,65 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="grid grid-cols-7 gap-[8px]"> <div class="grid h-full grid-cols-7 gap-2">
{#each days as day (day)} {#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }} {@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today} {@const isTodayDay = day === today}
{@const isSelectedDay = day === selectedDay}
{@const dateNum = day.slice(-2).replace(/^0/, '')} {@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const dayAbbr = formatDayAbbr(day, 'narrow')} {@const dayAbbr = formatDayAbbr(day, 'narrow')}
{@const isThisTileActive = drawerSlotId === day}
<div class="flex flex-col"> <div class="flex h-full flex-col">
<!-- Column header: day name + date badge --> <!-- Column header -->
<div class="mb-2 flex flex-col items-center gap-1"> <div class="mb-2 flex flex-col items-center gap-1">
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]"> <p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
{dayAbbr} {dayAbbr}
</p> </p>
<div <div
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''} {isTodayDay ? 'bg-[var(--yellow)] text-white' : 'bg-transparent text-[var(--color-text)]'}"
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
> >
{dateNum} {dateNum}
</div> </div>
</div> </div>
<!-- Meal tile --> <!-- Flip tile -->
<button <div class="min-h-0 flex-1">
type="button" <DesktopDayTile
onclick={() => { {slot}
handleSelectDay(day); isToday={isTodayDay}
if (!slot.recipe && isPlanner) { {activeSlotId}
panelState = { kind: 'recipe-picker', date: day }; {isPlanner}
} {slotMap}
}} {suggestions}
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`} topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)] onflip={handleTileFlip}
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''} onclose={handleTileClose}
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''} onswap={() => handleTileSwap(day)}
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''} onremove={() => handleTileRemove(slot)}
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}" onaddrecipe={() => handleEmptyTileAdd(day)}
> />
{#if slot.recipe}
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
{slot.recipe.name}
</p>
{:else}
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
<span class="text-[18px]" aria-hidden="true">+</span>
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
</div> </div>
{/if}
</button>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</main> </main>
<!-- Right detail panel -->
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
{#if panelState.kind === 'idle'}
<div class="flex flex-1 flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
</div> </div>
{:else if panelState.kind === 'day-detail'} <!-- Recipe picker drawer (slide-in from right) -->
{@const detailDate = panelState.date} <RecipePickerDrawer
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }} open={drawerOpen}
slotDate={drawerSlotId ?? ''}
<!-- Panel header with close button -->
<div class="mb-3 flex items-start justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(detailDate)} · Abendessen
</p>
<button
type="button"
onclick={closePanelToIdle}
aria-label="Panel schließen"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
{#if detailSlot.recipe}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
{detailSlot.recipe.name}
</h2>
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
</p>
{/if}
<div class="mt-4 space-y-2">
<a
href="/recipes/{detailSlot.recipe.id}"
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Rezept ansehen
</a>
<a
href="/recipes/{detailSlot.recipe.id}/cook"
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
>
Koch-Modus
</a>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Gericht tauschen
</button>
{#if detailSlot.id}
<button
type="button"
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
>
Entfernen
</button>
{/if}
{/if}
</div>
{:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if isPlanner}
<button
type="button"
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
>
+ Gericht wählen
</button>
{/if}
{/if}
{: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)]">
{isSwapContext ? 'Gericht tauschen' : 'Rezept wählen'}
</p>
<button
type="button"
onclick={closePanelToDayDetail}
aria-label="Zurück"
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
×
</button>
</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">
<RecipePicker
planId={weekPlan?.id ?? ''} planId={weekPlan?.id ?? ''}
date={pickerDate} {suggestions}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
excludeRecipeId={pickerSlot.recipe.id}
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
onpick={handleRecipePick}
/>
</div>
{:else}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes} allRecipes={data.recipes}
isLoading={isLoadingSuggestions} isLoading={isLoadingSuggestions}
onpick={handleRecipePick} onpick={handleRecipePick}
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
excludeRecipeId={drawerSlot?.recipe?.id}
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
/> />
</div>
{/if}
{/if}
</aside>
</div>
</div> </div>
<!-- Hidden forms for slot mutations --> <!-- Hidden forms for slot mutations -->