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

View File

@@ -27,30 +27,30 @@ describe('DesktopDayTile — filled slot', () => {
it('has data-testid="day-meal-card" on the scene element', () => {
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', () => {
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');
});
it('applies selected ring when activeSlotId matches slot id', () => {
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');
});
it('dims tile when another slot is active', () => {
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');
});
it('is not dimmed when no slot is active', () => {
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');
});
});
@@ -60,7 +60,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn();
const user = userEvent.setup();
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');
});
@@ -68,7 +68,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn();
const user = userEvent.setup();
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}');
expect(onflip).toHaveBeenCalledWith('s1');
});
@@ -77,7 +77,7 @@ describe('DesktopDayTile — filled slot', () => {
const onflip = vi.fn();
const user = userEvent.setup();
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(' ');
expect(onflip).toHaveBeenCalledWith('s1');
});
@@ -147,13 +147,13 @@ describe('DesktopDayTile — filled slot', () => {
it('aria-expanded is true when flipped', () => {
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');
});
it('aria-expanded is false when not flipped', () => {
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');
});
});

View File

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

View File

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

View File

@@ -21,7 +21,8 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
name: r.name!,
cookTimeMin: r.cookTimeMin,
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) {

View File

@@ -5,7 +5,9 @@
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.svelte';
import DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte';
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
@@ -49,35 +51,27 @@
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
let pickerOpen = $state(false);
let actionSheetOpen = $state(false);
let swapSheetOpen = $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(
pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date
: drawerOpen && drawerSlotId ? drawerSlotId
: null
);
let suggestions: Suggestion[] = $state([]);
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
let addPlanId = $state('');
let addSlotDate = $state('');
@@ -115,9 +109,23 @@
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) {
selectedDay = day;
panelState = { kind: 'day-detail', date: day };
}
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
@@ -130,14 +138,13 @@
}
async function handleRecipePick(recipeId: string, recipeName: string) {
// Capture date before modifying panel state
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
// Drawer date takes priority (desktop), then mobile picker date
const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
// Close pickers
// Close all pickers
pickerOpen = false;
if (panelState.kind === 'recipe-picker') {
panelState = { kind: 'idle' };
}
drawerOpen = false;
drawerSlotId = null;
const existingSlot = slotMap[date];
@@ -196,17 +203,39 @@
swapLoading = false;
}
function closePanelToIdle() {
panelState = { kind: 'idle' };
// Desktop tile handlers
function handleTileFlip(slotId: string) {
activeSlotId = slotId;
}
function closePanelToDayDetail() {
if (panelState.kind === 'recipe-picker') {
panelState = { kind: 'day-detail', date: panelState.date };
} else {
panelState = { kind: 'idle' };
function handleTileClose() {
activeSlotId = null;
}
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>
<!-- Mobile & Tablet: vertical stack -->
@@ -369,7 +398,7 @@
</BottomSheet>
</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">
<!-- Topbar -->
<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
</button>
</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>
<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">
<!-- Variety widget at bottom -->
{#if varietyScore}
<div class="mt-auto">
<VarietyScoreCard
@@ -426,8 +445,8 @@
{/if}
</aside>
<!-- Main calendar (only scrollable panel) -->
<main class="flex-1 overflow-y-auto p-5">
<!-- Main grid — full width, full height -->
<main class="flex-1 overflow-hidden p-5">
{#if !weekPlan}
<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>
@@ -441,198 +460,65 @@
{/if}
</div>
{:else}
<div class="grid grid-cols-7 gap-[8px]">
<div class="grid h-full grid-cols-7 gap-2">
{#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today}
{@const isSelectedDay = day === selectedDay}
{@const dateNum = day.slice(-2).replace(/^0/, '')}
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
{@const isThisTileActive = drawerSlotId === day}
<div class="flex flex-col">
<!-- Column header: day name + date badge -->
<div class="flex h-full flex-col">
<!-- Column header -->
<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)]">
{dayAbbr}
</p>
<div
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
{isTodayDay ? 'bg-[var(--yellow)] text-white' : 'bg-transparent text-[var(--color-text)]'}"
>
{dateNum}
</div>
</div>
<!-- Meal tile -->
<button
type="button"
onclick={() => {
handleSelectDay(day);
if (!slot.recipe && isPlanner) {
panelState = { kind: 'recipe-picker', date: day };
}
}}
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
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)]
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
>
{#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>
<!-- Flip tile -->
<div class="min-h-0 flex-1">
<DesktopDayTile
{slot}
isToday={isTodayDay}
{activeSlotId}
{isPlanner}
{slotMap}
{suggestions}
topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
onflip={handleTileFlip}
onclose={handleTileClose}
onswap={() => handleTileSwap(day)}
onremove={() => handleTileRemove(slot)}
onaddrecipe={() => handleEmptyTileAdd(day)}
/>
</div>
{/if}
</button>
</div>
{/each}
</div>
{/if}
</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>
{:else if panelState.kind === 'day-detail'}
{@const detailDate = panelState.date}
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
<!-- 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
<!-- Recipe picker drawer (slide-in from right) -->
<RecipePickerDrawer
open={drawerOpen}
slotDate={drawerSlotId ?? ''}
planId={weekPlan?.id ?? ''}
date={pickerDate}
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}
{suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
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>
<!-- Hidden forms for slot mutations -->