From f97cf49bd0026a2233fc8596a104f4f39614d0ea Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:04:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(planner):=20overhaul=20desktop=20layout=20?= =?UTF-8?q?=E2=80=94=20flip=20tiles,=20no=20right=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/planner/DesktopDayTile.svelte | 6 +- .../src/lib/planner/DesktopDayTile.test.ts | 20 +- .../src/lib/planner/RecipePickerDrawer.svelte | 26 +- frontend/src/lib/planner/types.ts | 8 + .../src/routes/(app)/planner/+page.server.ts | 3 +- .../src/routes/(app)/planner/+page.svelte | 310 ++++++------------ 6 files changed, 136 insertions(+), 237 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 2476f96..4e2e369 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -35,6 +35,7 @@ isPlanner, slotMap, suggestions, + topSuggestion, onflip, onclose, onswap, @@ -47,6 +48,7 @@ isPlanner: boolean; slotMap: Record; suggestions: Suggestion[]; + topSuggestion?: Suggestion; onflip?: (slotId: string) => void; onclose?: () => void; onswap?: () => void; @@ -83,7 +85,7 @@ {#if slot.recipe}
{/if} diff --git a/frontend/src/lib/planner/DesktopDayTile.test.ts b/frontend/src/lib/planner/DesktopDayTile.test.ts index 89603a2..1f1c151 100644 --- a/frontend/src/lib/planner/DesktopDayTile.test.ts +++ b/frontend/src/lib/planner/DesktopDayTile.test.ts @@ -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'); }); }); diff --git a/frontend/src/lib/planner/RecipePickerDrawer.svelte b/frontend/src/lib/planner/RecipePickerDrawer.svelte index 3719288..4bd25da 100644 --- a/frontend/src/lib/planner/RecipePickerDrawer.svelte +++ b/frontend/src/lib/planner/RecipePickerDrawer.svelte @@ -60,18 +60,20 @@
- +
- + {#if open} + + {/if}
diff --git a/frontend/src/lib/planner/types.ts b/frontend/src/lib/planner/types.ts index 1dae0f5..7d80c2f 100644 --- a/frontend/src/lib/planner/types.ts +++ b/frontend/src/lib/planner/types.ts @@ -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 { diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts index 602dc39..e318cf4 100644 --- a/frontend/src/routes/(app)/planner/+page.server.ts +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -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) { diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index a64bccd..cb3575f 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -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({ 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(null); + let drawerOpen = $state(false); + let drawerSlotId = $state(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(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 + ); @@ -369,7 +398,7 @@ - + - {#if isPlanner} - - {/if}
- +