From f2071ca5d83291212943f8a433dcfe1204296962 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 10:44:46 +0200 Subject: [PATCH 01/30] feat(planner): add flip-tile design tokens to app.css Adds --color-ring-today, --color-ring-selected, --opacity-dimmed, 9 protein gradient tokens and 5 cuisine gradient tokens as @theme custom properties, integrating into the existing token layer. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 23 +++++++++++++++++++ frontend/src/lib/design-system/tokens.test.ts | 22 +++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 988ac1c..f7ba542 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -86,4 +86,27 @@ --btn-font-size: 13px; --btn-font-weight: 500; --btn-letter-spacing: 0.04em; + + /* ── Planner flip-tile semantic tokens ──────────────────────────── */ + --color-ring-today: var(--yellow-text); + --color-ring-selected: var(--green-dark); + --opacity-dimmed: 0.38; + + /* ── Protein gradient tokens ────────────────────────────────────── */ + --gradient-protein-haehnchen: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + --gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); + --gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + --gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%); + --gradient-protein-veg: linear-gradient(135deg, #86efac 0%, #4ade80 100%); + --gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%); + --gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%); + --gradient-protein-ei: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + --gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%); + + /* ── Cuisine gradient tokens ────────────────────────────────────── */ + --gradient-cuisine-italienisch: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); + --gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%); + --gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%); + --gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%); + --gradient-cuisine-mediterran: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); } diff --git a/frontend/src/lib/design-system/tokens.test.ts b/frontend/src/lib/design-system/tokens.test.ts index 1928339..7c6d1ae 100644 --- a/frontend/src/lib/design-system/tokens.test.ts +++ b/frontend/src/lib/design-system/tokens.test.ts @@ -47,7 +47,27 @@ const requiredTokens = [ // Shadows '--shadow-card', '--shadow-raised', - '--shadow-overlay' + '--shadow-overlay', + // Planner flip-tile semantic tokens + '--color-ring-today', + '--color-ring-selected', + '--opacity-dimmed', + // Protein gradient tokens + '--gradient-protein-haehnchen', + '--gradient-protein-rind', + '--gradient-protein-fisch', + '--gradient-protein-tofu', + '--gradient-protein-veg', + '--gradient-protein-schwein', + '--gradient-protein-lamm', + '--gradient-protein-ei', + '--gradient-protein-huelsenfruechte', + // Cuisine gradient tokens + '--gradient-cuisine-italienisch', + '--gradient-cuisine-asiatisch', + '--gradient-cuisine-indisch', + '--gradient-cuisine-mexikanisch', + '--gradient-cuisine-mediterran' ]; describe('design token completeness', () => { -- 2.49.1 From f37f20d34e88603780cf7b20cea9be4ebb9ce995 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 10:45:42 +0200 Subject: [PATCH 02/30] feat(planner): add computeReasoningTags pure helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives ReasoningTag[] from slotMap + recipe. Covers Neues Protein (protein not yet in week) and Aufwand: leicht (cookTimeMin < 30 or effort einfach/leicht). No component dependency — Vitest-testable. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/reasoningTags.test.ts | 106 ++++++++++++++++++ frontend/src/lib/planner/reasoningTags.ts | 63 +++++++++++ 2 files changed, 169 insertions(+) create mode 100644 frontend/src/lib/planner/reasoningTags.test.ts create mode 100644 frontend/src/lib/planner/reasoningTags.ts diff --git a/frontend/src/lib/planner/reasoningTags.test.ts b/frontend/src/lib/planner/reasoningTags.test.ts new file mode 100644 index 0000000..9099f96 --- /dev/null +++ b/frontend/src/lib/planner/reasoningTags.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { computeReasoningTags, type ReasoningTag } from './reasoningTags'; + +// SlotMap fixture helpers +const emptySlotMap = {}; + +const slotMapWithChicken = { + '2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Chicken curry', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } }, +}; + +const slotMapWithBeefAndChicken = { + '2026-04-07': { id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Steak', tags: [{ id: 't2', name: 'Rind', tagType: 'protein' }] } }, + '2026-04-08': { id: 's2', slotDate: '2026-04-08', recipe: { id: 'r2', name: 'Chicken', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] } }, +}; + +const fishRecipe = { id: 'r3', name: 'Lachs', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] }; +const chickenRecipe = { id: 'r1', name: 'Chicken curry', cookTimeMin: 45, effort: 'mittel', tags: [{ id: 't1', name: 'Hähnchen', tagType: 'protein' }] }; +const easyRecipe = { id: 'r4', name: 'Salat', cookTimeMin: 15, effort: 'einfach', tags: [] }; +const heavyRecipe = { id: 'r5', name: 'Roulade', cookTimeMin: 90, effort: 'aufwändig', tags: [] }; + +describe('computeReasoningTags', () => { + describe('Neues Protein tag', () => { + it('returns Neues Protein tag when recipe protein is not in week', () => { + const tags = computeReasoningTags(slotMapWithChicken, fishRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).toContain('neues-protein'); + }); + + it('does not return Neues Protein when recipe protein is already in week', () => { + const tags = computeReasoningTags(slotMapWithChicken, chickenRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).not.toContain('neues-protein'); + }); + + it('returns Neues Protein when recipe has protein tag and slotMap is empty', () => { + const tags = computeReasoningTags(emptySlotMap, fishRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).toContain('neues-protein'); + }); + + it('does not return Neues Protein when recipe has no protein tag', () => { + const tags = computeReasoningTags(emptySlotMap, easyRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).not.toContain('neues-protein'); + }); + }); + + describe('Aufwand: leicht tag', () => { + it('returns Aufwand tag when cookTimeMin is less than 30', () => { + const tags = computeReasoningTags(emptySlotMap, easyRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).toContain('aufwand-leicht'); + }); + + it('returns Aufwand tag when effort is einfach regardless of cookTime', () => { + const recipe = { ...fishRecipe, cookTimeMin: 45 }; + const tags = computeReasoningTags(emptySlotMap, recipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).toContain('aufwand-leicht'); + }); + + it('does not return Aufwand tag for heavy recipe', () => { + const tags = computeReasoningTags(emptySlotMap, heavyRecipe); + const tagTypes = tags.map((t: ReasoningTag) => t.id); + expect(tagTypes).not.toContain('aufwand-leicht'); + }); + + it('returns Aufwand tag exactly at cookTimeMin 29', () => { + const recipe = { ...heavyRecipe, cookTimeMin: 29, effort: undefined }; + const tags = computeReasoningTags(emptySlotMap, recipe); + expect(tags.map((t: ReasoningTag) => t.id)).toContain('aufwand-leicht'); + }); + + it('does not return Aufwand tag at cookTimeMin 30 with non-easy effort', () => { + const recipe = { ...heavyRecipe, cookTimeMin: 30, effort: 'mittel' }; + const tags = computeReasoningTags(emptySlotMap, recipe); + expect(tags.map((t: ReasoningTag) => t.id)).not.toContain('aufwand-leicht'); + }); + }); + + describe('ReasoningTag shape', () => { + it('each tag has id, label, and color', () => { + const tags = computeReasoningTags(emptySlotMap, fishRecipe); + for (const tag of tags) { + expect(tag).toHaveProperty('id'); + expect(tag).toHaveProperty('label'); + expect(tag).toHaveProperty('color'); + } + }); + }); + + describe('multiple tags', () => { + it('returns multiple tags when multiple conditions are true', () => { + const recipe = { id: 'r6', name: 'Easy fish', cookTimeMin: 20, effort: 'einfach', tags: [{ id: 't3', name: 'Fisch', tagType: 'protein' }] }; + const tags = computeReasoningTags(slotMapWithBeefAndChicken, recipe); + const tagIds = tags.map((t: ReasoningTag) => t.id); + expect(tagIds).toContain('neues-protein'); + expect(tagIds).toContain('aufwand-leicht'); + }); + + it('returns empty array when no conditions are true', () => { + const tags = computeReasoningTags(slotMapWithChicken, { ...chickenRecipe, cookTimeMin: 60, effort: 'aufwändig' }); + expect(tags).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/lib/planner/reasoningTags.ts b/frontend/src/lib/planner/reasoningTags.ts new file mode 100644 index 0000000..e0a5c09 --- /dev/null +++ b/frontend/src/lib/planner/reasoningTags.ts @@ -0,0 +1,63 @@ +export interface ReasoningTag { + id: 'neues-protein' | 'aufwand-leicht'; + label: string; + color: 'green' | 'yellow'; +} + +interface TagItem { + id?: string; + name?: string; + tagType?: string; +} + +interface Recipe { + id: string; + name: string; + cookTimeMin?: number; + effort?: string; + tags?: TagItem[]; +} + +interface SlotRecipe { + id?: string; + tags?: TagItem[]; +} + +interface Slot { + id?: string; + slotDate?: string; + recipe?: SlotRecipe | null; +} + +type SlotMap = Record; + +export function computeReasoningTags(slotMap: SlotMap, recipe: Recipe): ReasoningTag[] { + const tags: ReasoningTag[] = []; + + // Neues Protein: recipe has a protein tag not already present in any filled slot + const recipeProtein = recipe.tags?.find((t) => t.tagType === 'protein')?.name; + if (recipeProtein) { + const weekProteins = new Set(); + for (const slot of Object.values(slotMap)) { + if (slot.recipe) { + for (const tag of slot.recipe.tags ?? []) { + if (tag.tagType === 'protein' && tag.name) { + weekProteins.add(tag.name); + } + } + } + } + if (!weekProteins.has(recipeProtein)) { + tags.push({ id: 'neues-protein', label: 'Neues Protein', color: 'green' }); + } + } + + // Aufwand: leicht — cookTimeMin < 30 OR effort is 'einfach'/'leicht' + const isEasyEffort = recipe.effort === 'einfach' || recipe.effort === 'leicht'; + const isQuick = recipe.cookTimeMin != null && recipe.cookTimeMin < 30; + if (isEasyEffort || isQuick) { + tags.push({ id: 'aufwand-leicht', label: 'Aufwand: leicht', color: 'yellow' }); + } + + return tags; +} -- 2.49.1 From 2b7a7cceecffc5788fa023216430903c96ee90aa Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 10:47:19 +0200 Subject: [PATCH 03/30] feat(planner): add EmptyDayTile component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashed-border empty slot tile with + Gericht wählen CTA and lazy reasoning tags (Neues Protein, Aufwand: leicht) derived from topSuggestion prop via computeReasoningTags. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/EmptyDayTile.svelte | 89 +++++++++++++++++++ frontend/src/lib/planner/EmptyDayTile.test.ts | 88 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 frontend/src/lib/planner/EmptyDayTile.svelte create mode 100644 frontend/src/lib/planner/EmptyDayTile.test.ts diff --git a/frontend/src/lib/planner/EmptyDayTile.svelte b/frontend/src/lib/planner/EmptyDayTile.svelte new file mode 100644 index 0000000..fed8d2f --- /dev/null +++ b/frontend/src/lib/planner/EmptyDayTile.svelte @@ -0,0 +1,89 @@ + + +
+ {#if isPlanner} + + {/if} + + {#if topSuggestion} +

+ {topSuggestion.recipe.name} +

+ + {#if reasoningTags.length > 0} +
+ {#each reasoningTags as tag (tag.id)} + + {tag.label} + + {/each} +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/planner/EmptyDayTile.test.ts b/frontend/src/lib/planner/EmptyDayTile.test.ts new file mode 100644 index 0000000..fa52d9b --- /dev/null +++ b/frontend/src/lib/planner/EmptyDayTile.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import EmptyDayTile from './EmptyDayTile.svelte'; + +const slotDate = '2026-04-14'; +const slotId = 'slot-1'; + +const topSuggestionNewProtein = { + recipe: { + id: 'r1', + name: 'Lachs mit Gemüse', + cookTimeMin: 20, + effort: 'einfach', + tags: [{ id: 't1', name: 'Fisch', tagType: 'protein' }] + }, + scoreDelta: 3.2, + hasConflict: false +}; + +const slotMapEmpty = {}; + +describe('EmptyDayTile', () => { + describe('base render', () => { + it('shows + CTA for planner', () => { + render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } }); + expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy(); + }); + + it('hides + CTA for non-planner', () => { + render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: false, slotMap: slotMapEmpty } }); + expect(screen.queryByRole('button', { name: /Gericht wählen/i })).toBeNull(); + }); + + it('calls onaddrecipe when + CTA clicked', async () => { + const onaddrecipe = vi.fn(); + const user = userEvent.setup(); + render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, onaddrecipe } }); + await user.click(screen.getByRole('button', { name: /Gericht wählen/i })); + expect(onaddrecipe).toHaveBeenCalledOnce(); + }); + + it('has data-testid="empty-day-tile"', () => { + render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } }); + expect(screen.getByTestId('empty-day-tile')).toBeTruthy(); + }); + }); + + describe('reasoning tags', () => { + it('shows no tags when no topSuggestion', () => { + render(EmptyDayTile, { props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty } }); + expect(screen.queryByTestId('reasoning-tag')).toBeNull(); + }); + + it('shows Neues Protein tag when topSuggestion has new protein', () => { + render(EmptyDayTile, { + props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein } + }); + expect(screen.getByText('Neues Protein')).toBeTruthy(); + }); + + it('shows Aufwand tag for easy suggestion', () => { + render(EmptyDayTile, { + props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein } + }); + expect(screen.getByText('Aufwand: leicht')).toBeTruthy(); + }); + + it('shows suggestion recipe name when topSuggestion provided', () => { + render(EmptyDayTile, { + props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: topSuggestionNewProtein } + }); + expect(screen.getByText('Lachs mit Gemüse')).toBeTruthy(); + }); + + it('does not show tags when suggestion has no matching conditions', () => { + const heavySuggestion = { + recipe: { id: 'r2', name: 'Roulade', cookTimeMin: 120, effort: 'aufwändig', tags: [] }, + scoreDelta: 1.0, + hasConflict: false + }; + render(EmptyDayTile, { + props: { slotDate, slotId, isPlanner: true, slotMap: slotMapEmpty, topSuggestion: heavySuggestion } + }); + expect(screen.queryByTestId('reasoning-tag')).toBeNull(); + }); + }); +}); -- 2.49.1 From d20cd53be25dc4515aa7bfd73297b42944ed2b8b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 10:51:21 +0200 Subject: [PATCH 04/30] feat(planner): add DesktopDayTile flip-tile component CSS 3D card flip with scene/card/front/back structure. Filled slots show gradient/image front face and action back face (Koch-Modus, tauschen, entfernen). Empty slots delegate to EmptyDayTile. Sibling dimming and aria-expanded via activeSlotId prop. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 201 ++++++++++++++++++ .../src/lib/planner/DesktopDayTile.test.ts | 172 +++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 frontend/src/lib/planner/DesktopDayTile.svelte create mode 100644 frontend/src/lib/planner/DesktopDayTile.test.ts diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte new file mode 100644 index 0000000..2476f96 --- /dev/null +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -0,0 +1,201 @@ + + +{#if slot.recipe} +
+
+
+

+ {slot.recipe.name} +

+
+ +
+ + + {#if slot.recipe.cookTimeMin} + {slot.recipe.cookTimeMin} min + {/if} + + {#if slot.recipe.effort} + {slot.recipe.effort} + {/if} + + + + {#if isPlanner} + + {/if} + + {#if isPlanner && slot.id} + + {/if} +
+
+
+{:else} + +{/if} + + diff --git a/frontend/src/lib/planner/DesktopDayTile.test.ts b/frontend/src/lib/planner/DesktopDayTile.test.ts new file mode 100644 index 0000000..89603a2 --- /dev/null +++ b/frontend/src/lib/planner/DesktopDayTile.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import DesktopDayTile from './DesktopDayTile.svelte'; + +const filledSlot = { + id: 's1', + slotDate: '2026-04-14', + recipe: { + id: 'r1', + name: 'Pasta Bolognese', + cookTimeMin: 45, + effort: 'mittel', + heroImageUrl: null, + tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }] + } +}; + +const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null }; + +describe('DesktopDayTile — filled slot', () => { + describe('front face', () => { + it('renders recipe name on front face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); + }); + + 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(); + }); + + 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'); + 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'); + 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'); + 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'); + expect(scene.getAttribute('data-dimmed')).toBe('false'); + }); + }); + + describe('flip interaction', () => { + it('calls onflip with slot id when scene is clicked', async () => { + 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')); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + + it('calls onflip when Enter key is pressed on scene', async () => { + 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(); + await user.keyboard('{Enter}'); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + + it('calls onflip when Space key is pressed on scene', async () => { + 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(); + await user.keyboard(' '); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + }); + + describe('back face (flipped state)', () => { + it('shows recipe name on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + // Back face should also show recipe name + const names = screen.getAllByText('Pasta Bolognese'); + expect(names.length).toBeGreaterThanOrEqual(1); + }); + + it('shows Koch-Modus link on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy(); + }); + + it('shows Rezept ansehen link on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy(); + }); + + it('shows close button on back face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy(); + }); + + it('calls onclose when close button clicked', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } }); + await user.click(screen.getByRole('button', { name: /Schließen/i })); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('shows Gericht tauschen button for planner on back face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy(); + }); + + it('hides Gericht tauschen for non-planner', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } }); + expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull(); + }); + + it('calls onswap when Gericht tauschen clicked', async () => { + const onswap = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } }); + await user.click(screen.getByRole('button', { name: /Gericht tauschen/i })); + expect(onswap).toHaveBeenCalledOnce(); + }); + + it('shows Entfernen button for planner when slot has id', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy(); + }); + + it('calls onremove when Entfernen clicked', async () => { + const onremove = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } }); + await user.click(screen.getByRole('button', { name: /Entfernen/i })); + expect(onremove).toHaveBeenCalledOnce(); + }); + + 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'); + 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'); + expect(scene.getAttribute('aria-expanded')).toBe('false'); + }); + }); +}); + +describe('DesktopDayTile — empty slot', () => { + it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => { + render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy(); + }); + + it('does not render Koch-Modus for empty slot', () => { + render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull(); + }); +}); -- 2.49.1 From 2cebf504f24da198ca96a6c5ae20c30e33f72dc2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 10:52:56 +0200 Subject: [PATCH 05/30] feat(planner): add RecipePickerDrawer slide-in drawer Wraps RecipePicker in a fixed right-side drawer with backdrop. Slide-in/out transition, backdrop click closes, purely presentational (open + onclose props from parent). Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/RecipePickerDrawer.svelte | 77 ++++++++++++++++++ .../lib/planner/RecipePickerDrawer.test.ts | 80 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 frontend/src/lib/planner/RecipePickerDrawer.svelte create mode 100644 frontend/src/lib/planner/RecipePickerDrawer.test.ts diff --git a/frontend/src/lib/planner/RecipePickerDrawer.svelte b/frontend/src/lib/planner/RecipePickerDrawer.svelte new file mode 100644 index 0000000..3719288 --- /dev/null +++ b/frontend/src/lib/planner/RecipePickerDrawer.svelte @@ -0,0 +1,77 @@ + + + + + + +
+ +
+

+ Rezept wählen +

+ +
+ + +
+ +
+
diff --git a/frontend/src/lib/planner/RecipePickerDrawer.test.ts b/frontend/src/lib/planner/RecipePickerDrawer.test.ts new file mode 100644 index 0000000..2ba8733 --- /dev/null +++ b/frontend/src/lib/planner/RecipePickerDrawer.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import RecipePickerDrawer from './RecipePickerDrawer.svelte'; + +const baseProps = { + open: true, + slotDate: '2026-04-14', + planId: 'plan-1', + suggestions: [], + allRecipes: [ + { id: 'r1', name: 'Pasta Bolognese', cookTimeMin: 45, effort: 'mittel' }, + { id: 'r2', name: 'Lachs', cookTimeMin: 20, effort: 'einfach' } + ], + isLoading: false, + onpick: vi.fn(), + onclose: vi.fn() +}; + +describe('RecipePickerDrawer', () => { + describe('visibility', () => { + it('renders drawer content when open=true', () => { + render(RecipePickerDrawer, { props: baseProps }); + expect(screen.getByTestId('recipe-picker-drawer')).toBeTruthy(); + }); + + it('drawer is not visible when open=false', () => { + render(RecipePickerDrawer, { props: { ...baseProps, open: false } }); + const drawer = screen.getByTestId('recipe-picker-drawer'); + // Drawer exists in DOM but should be off-screen / aria-hidden + expect(drawer.getAttribute('aria-hidden')).toBe('true'); + }); + + it('renders recipe list inside drawer', () => { + render(RecipePickerDrawer, { props: baseProps }); + expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); + }); + }); + + describe('backdrop', () => { + it('renders backdrop when open', () => { + render(RecipePickerDrawer, { props: baseProps }); + expect(screen.getByTestId('drawer-backdrop')).toBeTruthy(); + }); + + it('calls onclose when backdrop is clicked', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(RecipePickerDrawer, { props: { ...baseProps, onclose } }); + await user.click(screen.getByTestId('drawer-backdrop')); + expect(onclose).toHaveBeenCalledOnce(); + }); + }); + + describe('close button', () => { + it('renders a close button inside the drawer', () => { + render(RecipePickerDrawer, { props: baseProps }); + expect(screen.getByRole('button', { name: /schließen|close/i })).toBeTruthy(); + }); + + it('calls onclose when close button clicked', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(RecipePickerDrawer, { props: { ...baseProps, onclose } }); + await user.click(screen.getByRole('button', { name: /schließen|close/i })); + expect(onclose).toHaveBeenCalledOnce(); + }); + }); + + describe('recipe picking', () => { + it('calls onpick when a recipe is selected', async () => { + const onpick = vi.fn(); + const user = userEvent.setup(); + render(RecipePickerDrawer, { props: { ...baseProps, onpick } }); + const pickButtons = screen.getAllByRole('button', { name: /Wählen/i }); + await user.click(pickButtons[0]); + expect(onpick).toHaveBeenCalledOnce(); + }); + }); +}); -- 2.49.1 From f97cf49bd0026a2233fc8596a104f4f39614d0ea Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:04:26 +0200 Subject: [PATCH 06/30] =?UTF-8?q?feat(planner):=20overhaul=20desktop=20lay?= =?UTF-8?q?out=20=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}
- +
-- 2.49.1 From 358edb9a12c54ed346a982380d4e00df537d603a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:23:05 +0200 Subject: [PATCH 07/30] fix(planner): improve DesktopDayTile visual polish - Add dark gradient scrim on card front so recipe name is always readable over images and protein/cuisine gradients - Style card-back actions as proper buttons (border, padding, border-radius) instead of unstyled browser defaults - Add meta chips for cookTimeMin and effort - Scope Entfernen inside isPlanner guard alongside Gericht tauschen Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 119 +++++++++++++----- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 4e2e369..d198f78 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -102,51 +102,59 @@ class="card-front" style="background: {gradientBackground}; background-size: cover; background-position: center;" > -

- {slot.recipe.name} -

+
+

+ {slot.recipe.name} +

+
- {#if slot.recipe.cookTimeMin} - {slot.recipe.cookTimeMin} min + {#if slot.recipe.cookTimeMin || slot.recipe.effort} +
+ {#if slot.recipe.cookTimeMin} + {slot.recipe.cookTimeMin} min + {/if} + {#if slot.recipe.effort} + {slot.recipe.effort} + {/if} +
{/if} - {#if slot.recipe.effort} - {slot.recipe.effort} - {/if} - -
- e.stopPropagation()}>Koch-Modus - e.stopPropagation()}>Rezept ansehen + {#if isPlanner} - - {/if} +
+ - {#if isPlanner && slot.id} - + {#if slot.id} + + {/if} +
{/if}
@@ -195,6 +203,61 @@ padding: 10px; overflow-y: auto; } + .card-front-scrim { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 20px 8px 8px; + background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.55) 100%); + border-radius: 0 0 10px 10px; + } + .btn-close { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + line-height: 1; + color: var(--color-text-muted); + padding: 2px 6px; + float: right; + margin: -4px -4px 4px 4px; + } + .btn-close:hover { + color: var(--color-text); + } + .meta-chip { + font-size: 11px; + padding: 2px 7px; + border-radius: 99px; + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text-muted); + } + .btn-action { + display: block; + width: 100%; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: 12px; + text-align: center; + text-decoration: none; + cursor: pointer; + box-sizing: border-box; + } + .btn-action:hover { + background: var(--color-border); + } + .btn-danger { + color: var(--color-destructive, #dc2626); + border-color: var(--color-destructive, #dc2626); + } + .btn-danger:hover { + background: color-mix(in srgb, var(--color-destructive, #dc2626) 10%, transparent); + } @media (prefers-reduced-motion: reduce) { .card { transition: none; -- 2.49.1 From 8709e85d80c8c73c81740ca94b1a1d2b838a7c1a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:24:20 +0200 Subject: [PATCH 08/30] fix(planner): increase card front recipe name font size to 15px Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index d198f78..4e35c51 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -103,7 +103,7 @@ style="background: {gradientBackground}; background-size: cover; background-position: center;" >
-

+

{slot.recipe.name}

-- 2.49.1 From 389500c1ddf6a20c864a83197fdced8340b57229 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:31:06 +0200 Subject: [PATCH 09/30] fix(planner): transliterate German umlauts in protein gradient CSS key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Hähnchen'.toLowerCase() → 'hähnchen' which never matched the CSS var --gradient-protein-haehnchen. Add toCssKey() to replace ä→ae, ö→oe, ü→ue, ß→ss so gradient fallbacks actually resolve. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 4e35c51..d701ffa 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -72,12 +72,17 @@ } } + const umlautMap: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }; + function toCssKey(name: string): string { + return name.toLowerCase().replace(/[äöüß]/g, (c) => umlautMap[c] ?? c); + } + const gradientBackground = $derived((() => { if (!slot.recipe) return 'var(--color-surface)'; if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`; const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein'); if (proteinTag?.name) { - return `var(--gradient-protein-${proteinTag.name.toLowerCase()})`; + return `var(--gradient-protein-${toCssKey(proteinTag.name)})`; } return 'var(--color-surface)'; })()); -- 2.49.1 From b919a716f5d80c96e4d4aa9d2b5bf6028e3f333e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:32:05 +0200 Subject: [PATCH 10/30] =?UTF-8?q?fix(planner):=20rename=20gradient-protein?= =?UTF-8?q?-veg=20=E2=86=92=20gradient-protein-vegetarisch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS variable key must match the actual tag name after umlaut transliteration. 'veg' would never match a real tag named 'vegetarisch'. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 2 +- frontend/src/lib/design-system/tokens.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index f7ba542..9a568d0 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -97,7 +97,7 @@ --gradient-protein-rind: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); --gradient-protein-fisch: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); --gradient-protein-tofu: linear-gradient(135deg, #22c55e 0%, #15803d 100%); - --gradient-protein-veg: linear-gradient(135deg, #86efac 0%, #4ade80 100%); + --gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%); --gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%); --gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%); --gradient-protein-ei: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); diff --git a/frontend/src/lib/design-system/tokens.test.ts b/frontend/src/lib/design-system/tokens.test.ts index 7c6d1ae..a1db6ba 100644 --- a/frontend/src/lib/design-system/tokens.test.ts +++ b/frontend/src/lib/design-system/tokens.test.ts @@ -57,7 +57,7 @@ const requiredTokens = [ '--gradient-protein-rind', '--gradient-protein-fisch', '--gradient-protein-tofu', - '--gradient-protein-veg', + '--gradient-protein-vegetarisch', '--gradient-protein-schwein', '--gradient-protein-lamm', '--gradient-protein-ei', -- 2.49.1 From 75228058a690573154cc3f3c028b2e53df30ac98 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:37:54 +0200 Subject: [PATCH 11/30] fix(planner): align protein gradient CSS vars with actual seed tag names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename --gradient-protein-ei → --gradient-protein-eier (tag is 'Eier') - Add --gradient-protein-kaese for tag 'Käse' (was missing entirely) The only protein tags in seed data are Käse, Hülsenfrüchte, Eier. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 3 ++- frontend/src/lib/design-system/tokens.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 9a568d0..11f827a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -100,7 +100,8 @@ --gradient-protein-vegetarisch: linear-gradient(135deg, #86efac 0%, #4ade80 100%); --gradient-protein-schwein: linear-gradient(135deg, #fca5a5 0%, #f87171 100%); --gradient-protein-lamm: linear-gradient(135deg, #92400e 0%, #78350f 100%); - --gradient-protein-ei: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + --gradient-protein-eier: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + --gradient-protein-kaese: linear-gradient(135deg, #fcd34d 0%, #d97706 100%); --gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%); /* ── Cuisine gradient tokens ────────────────────────────────────── */ diff --git a/frontend/src/lib/design-system/tokens.test.ts b/frontend/src/lib/design-system/tokens.test.ts index a1db6ba..6ff1db3 100644 --- a/frontend/src/lib/design-system/tokens.test.ts +++ b/frontend/src/lib/design-system/tokens.test.ts @@ -60,7 +60,8 @@ const requiredTokens = [ '--gradient-protein-vegetarisch', '--gradient-protein-schwein', '--gradient-protein-lamm', - '--gradient-protein-ei', + '--gradient-protein-eier', + '--gradient-protein-kaese', '--gradient-protein-huelsenfruechte', // Cuisine gradient tokens '--gradient-cuisine-italienisch', -- 2.49.1 From ed4cdbf230ab64a4224991a1b113b11f15e5cf58 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 11:55:55 +0200 Subject: [PATCH 12/30] fix(planner): merge recipe tags into slotMap from data.recipes SlotRecipe from the week-plan API carries no tags, so the protein gradient lookup in DesktopDayTile always fell through to --color-surface. Build a recipeById lookup from data.recipes and spread tags onto each slot's recipe when constructing slotMap. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/planner/+page.svelte | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index cb3575f..a75bd86 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -25,7 +25,20 @@ let days = $derived(weekDays(weekStart)); let slots = $derived(weekPlan?.slots ?? []); - let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s]))); + // SlotRecipe from the API has no tags — merge from data.recipes by id + const recipeById = $derived( + Object.fromEntries((data.recipes ?? []).map((r: any) => [r.id, r])) + ); + let slotMap = $derived( + Object.fromEntries( + slots.map((s: any) => [ + s.slotDate, + s.recipe + ? { ...s, recipe: { ...s.recipe, tags: recipeById[s.recipe.id]?.tags ?? [] } } + : s + ]) + ) + ); // Default selected day: today if in this week, else first day // We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value. -- 2.49.1 From d901310897e9677a6d3a5b27c37a6df8e18c3665 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:03:03 +0200 Subject: [PATCH 13/30] feat(backend): add heroImageUrl and tags to RecipeSummaryResponse GET /v1/recipes was returning RecipeSummaryResponse with no tags and only heroImagePreview. The planner frontend needs protein tags to pick gradient backgrounds for tiles without a hero image. - Replace JPQL constructor projection with entity query + LEFT JOIN FETCH tags - Map Recipe entity to RecipeSummaryResponse in service (includes tags + heroImageUrl) - Drop heroImagePreview in favour of heroImageUrl on the summary DTO Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/recipeapp/recipe/RecipeRepository.java | 8 +++----- .../main/java/com/recipeapp/recipe/RecipeService.java | 10 +++++++++- .../recipeapp/recipe/dto/RecipeSummaryResponse.java | 4 +++- .../com/recipeapp/recipe/RecipeControllerTest.java | 6 +++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java index 6b7a3d0..eb81386 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java @@ -1,6 +1,5 @@ package com.recipeapp.recipe; -import com.recipeapp.recipe.dto.RecipeSummaryResponse; import com.recipeapp.recipe.entity.Recipe; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -17,9 +16,8 @@ public interface RecipeRepository extends JpaRepository { List findByHouseholdIdAndDeletedAtIsNull(UUID householdId); @Query(""" - SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse( - r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview) - FROM Recipe r + SELECT r FROM Recipe r + LEFT JOIN FETCH r.tags WHERE r.household.id = :householdId AND r.deletedAt IS NULL AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%'))) @@ -27,7 +25,7 @@ public interface RecipeRepository extends JpaRepository { AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) ORDER BY r.createdAt DESC """) - List findFiltered( + List findFiltered( @Param("householdId") UUID householdId, @Param("search") String search, @Param("effort") String effort, diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index 6e3084b..28e4091 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -42,7 +42,15 @@ public class RecipeService { @Transactional(readOnly = true) public List listRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin, String sort, int limit, int offset) { - return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset); + return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset) + .stream() + .map(r -> new RecipeSummaryResponse( + r.getId(), r.getName(), r.getServes(), r.getCookTimeMin(), r.getEffort(), + r.getHeroImageUrl(), + r.getTags().stream() + .map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType())) + .toList())) + .toList(); } @Transactional(readOnly = true) diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java index c128982..a3fd8c2 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java @@ -1,5 +1,6 @@ package com.recipeapp.recipe.dto; +import java.util.List; import java.util.UUID; public record RecipeSummaryResponse( @@ -8,5 +9,6 @@ public record RecipeSummaryResponse( short serves, short cookTimeMin, String effort, - String heroImagePreview + String heroImageUrl, + List tags ) {} diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index 0a1b94b..1e79817 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -46,8 +46,9 @@ class RecipeControllerTest { @Test void listRecipesShouldReturn200WithPagination() throws Exception { + var tag = new TagResponse(UUID.randomUUID(), "Rind", "protein"); var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese", - (short) 4, (short) 45, "medium", null); + (short) 4, (short) 45, "medium", "https://example.com/img.jpg", List.of(tag)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), @@ -62,6 +63,9 @@ class RecipeControllerTest { .param("offset", "0")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].name").value("Spaghetti Bolognese")) + .andExpect(jsonPath("$.data[0].heroImageUrl").value("https://example.com/img.jpg")) + .andExpect(jsonPath("$.data[0].tags[0].name").value("Rind")) + .andExpect(jsonPath("$.data[0].tags[0].tagType").value("protein")) .andExpect(jsonPath("$.meta.pagination.total").value(1)) .andExpect(jsonPath("$.meta.pagination.hasMore").value(false)); } -- 2.49.1 From d54ac6a37a3ac80dccb6bb4e9b5d7d5104e1a7cf Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:12:03 +0200 Subject: [PATCH 14/30] feat(planner): use cuisine gradient as fallback when no protein tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallback chain: heroImageUrl → protein gradient → cuisine gradient → surface. Also rename --gradient-cuisine-italienisch → --gradient-cuisine-deutsch (actual seed tag) with an earthy warm-grey colour. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 2 +- frontend/src/lib/design-system/tokens.test.ts | 2 +- frontend/src/lib/planner/DesktopDayTile.svelte | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 11f827a..da8a085 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -105,7 +105,7 @@ --gradient-protein-huelsenfruechte: linear-gradient(135deg, #a16207 0%, #854d0e 100%); /* ── Cuisine gradient tokens ────────────────────────────────────── */ - --gradient-cuisine-italienisch: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); + --gradient-cuisine-deutsch: linear-gradient(135deg, #78716c 0%, #44403c 100%); --gradient-cuisine-asiatisch: linear-gradient(135deg, #166534 0%, #14532d 100%); --gradient-cuisine-indisch: linear-gradient(135deg, #ca8a04 0%, #a16207 100%); --gradient-cuisine-mexikanisch: linear-gradient(135deg, #ea580c 0%, #c2410c 100%); diff --git a/frontend/src/lib/design-system/tokens.test.ts b/frontend/src/lib/design-system/tokens.test.ts index 6ff1db3..b33ac17 100644 --- a/frontend/src/lib/design-system/tokens.test.ts +++ b/frontend/src/lib/design-system/tokens.test.ts @@ -64,7 +64,7 @@ const requiredTokens = [ '--gradient-protein-kaese', '--gradient-protein-huelsenfruechte', // Cuisine gradient tokens - '--gradient-cuisine-italienisch', + '--gradient-cuisine-deutsch', '--gradient-cuisine-asiatisch', '--gradient-cuisine-indisch', '--gradient-cuisine-mexikanisch', diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index d701ffa..44d8377 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -84,6 +84,10 @@ if (proteinTag?.name) { return `var(--gradient-protein-${toCssKey(proteinTag.name)})`; } + const cuisineTag = slot.recipe.tags?.find((t) => t.tagType === 'cuisine'); + if (cuisineTag?.name) { + return `var(--gradient-cuisine-${toCssKey(cuisineTag.name)})`; + } return 'var(--color-surface)'; })()); -- 2.49.1 From 0ae1767649195eea1b3edfd07c1ef194f9e32d33 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:29:58 +0200 Subject: [PATCH 15/30] feat(planner): align tile design with spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front face: - Full dual gradient overlay (dark top 32% → transparent → dark bottom 55%) - Day abbreviation + date number pill at top of each tile - Recipe name 13px/weight-300 with text-shadow - Meta line (cookTimeMin · effort) below name - Glassmorphism tag pills (protein + cuisine only) - State rings via box-shadow (yellow for today, green for selected) - Dimming (opacity 0.42) on non-selected filled tiles Back face: - Koch-Modus as green primary button - Entfernen as red outline (transparent bg) - All buttons 11px / weight 500 EmptyDayTile: add day header + spec-aligned suggestion list layout Page: remove external column header (now rendered inside each tile) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 255 ++++++++++++++---- .../src/lib/planner/DesktopDayTile.test.ts | 2 +- frontend/src/lib/planner/EmptyDayTile.svelte | 51 ++-- .../src/routes/(app)/planner/+page.svelte | 20 +- 4 files changed, 235 insertions(+), 93 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 44d8377..0a09a86 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -1,5 +1,6 @@
+ +
+ {dayAbbr} + {dateNum} +
+ + {#if isPlanner} - +
+ +
{/if} {#if topSuggestion} -

- {topSuggestion.recipe.name} -

- - {#if reasoningTags.length > 0} -
+
+
Vorschlag
+
+ {topSuggestion.recipe.name} {#each reasoningTags as tag (tag.id)} {tag.label} {/each}
- {/if} +
{/if}
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index a75bd86..7ed61cd 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -477,26 +477,9 @@ {#each days as day (day)} {@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }} {@const isTodayDay = day === today} - {@const dateNum = day.slice(-2).replace(/^0/, '')} - {@const dayAbbr = formatDayAbbr(day, 'narrow')} {@const isThisTileActive = drawerSlotId === day} -
- -
-

- {dayAbbr} -

-
- {dateNum} -
-
- - -
+
handleTileRemove(slot)} onaddrecipe={() => handleEmptyTileAdd(day)} /> -
{/each}
-- 2.49.1 From 8679ebc6e30d339994f66b08f132c25c1e588d4b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:42:06 +0200 Subject: [PATCH 16/30] fix(planner): fix flip tile pointer events and selected ring hover - backface-visibility hides elements visually but not to pointer events; disable pointer events on the hidden face explicitly so the X button on the back face is clickable and the front face doesn't intercept clicks - Add .scene-selected:hover rule so green ring is not overwritten by the higher-specificity .scene:hover box-shadow Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 0a09a86..549583c 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -237,6 +237,10 @@ .scene-selected { box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14); } + /* Keep ring visible on hover — :hover alone has higher specificity than .scene-selected */ + .scene-selected:hover { + box-shadow: 0 0 0 2px var(--green-dark), 0 6px 18px rgba(28,28,24,.14); + } .scene-dimmed { opacity: 0.42; pointer-events: none; @@ -262,6 +266,10 @@ border-radius: 10px; overflow: hidden; } + /* backface-visibility hides visually but NOT pointer events — disable manually */ + .card-back { pointer-events: none; } + .card.flipped .card-back { pointer-events: auto; } + .card.flipped .card-front { pointer-events: none; } /* ── Front face ── */ .tile-overlay { -- 2.49.1 From a43a8ec33fc763688408cbf57c80e22aa26562ad Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:47:10 +0200 Subject: [PATCH 17/30] fix(planner): prevent front face bleeding through flipped card overflow:hidden on direct children of preserve-3d flattens the 3D context in Chrome, causing backface-visibility:hidden to fail. Move border-radius + overflow to inner wrapper divs (.card-front-inner, .card-back-inner) and keep the face elements themselves free of those properties. Also add -webkit-backface-visibility:hidden and will-change:transform for consistent GPU compositing. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 549583c..446e74a 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -126,10 +126,11 @@
-
+
+
@@ -157,10 +158,12 @@
{/if}
+
+
@@ -254,6 +258,7 @@ transform-style: preserve-3d; transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 10px; + will-change: transform; } .card.flipped { transform: rotateY(180deg); @@ -263,6 +268,13 @@ position: absolute; inset: 0; backface-visibility: hidden; + -webkit-backface-visibility: hidden; + /* border-radius and overflow on inner wrappers, not here — + overflow:hidden on preserve-3d children flattens the 3D context */ + } + .card-front-inner { + position: absolute; + inset: 0; border-radius: 10px; overflow: hidden; } @@ -359,10 +371,15 @@ /* ── Back face ── */ .card-back { transform: rotateY(180deg); + } + .card-back-inner { + position: absolute; + inset: 0; + border-radius: 10px; + overflow-y: auto; background: var(--color-page); border: 1px solid var(--color-border); padding: 10px; - overflow-y: auto; display: flex; flex-direction: column; } -- 2.49.1 From 38528a50e558f6401c9bb20e7c2d3df896397899 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:49:28 +0200 Subject: [PATCH 18/30] fix(planner): eliminate front-face bleed by removing preserve-3d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transform-style:preserve-3d on a parent with box-shadow/transition causes Chrome to fail backface-visibility:hidden. Replace with independent per-face rotateY transforms: front: 0deg → -180deg (flipped) back: 180deg → 0deg (flipped) No preserve-3d needed — each face is its own compositing layer. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 446e74a..3e43a6a 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -123,10 +123,8 @@ onclick={handleFlip} onkeydown={handleKeydown} > -
- -
+
-
+
-
{:else} -- 2.49.1 From fc682bfc546b235942d1840cac430a05b955eac3 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:50:28 +0200 Subject: [PATCH 19/30] fix(planner): increase tile front face recipe name to 15px Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 3e43a6a..2eae425 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -327,7 +327,7 @@ z-index: 2; } .tile-name { - font-size: 13px; + font-size: 15px; font-weight: 300; color: #fff; line-height: 1.3; -- 2.49.1 From eb3f6fad2587a96e6c1657f722e39011e66d3f91 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:52:05 +0200 Subject: [PATCH 20/30] fix(planner): bump front face font sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit name: 15→17px, meta: 10→12px, tags: 8→10px, day-abbr: 9→11px, day-num: 10→12px Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 2eae425..731bc39 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -295,7 +295,7 @@ z-index: 2; } .tile-day-abbr { - font-size: 9px; + font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: rgba(255,255,255,.85); @@ -307,7 +307,7 @@ display: flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: 12px; font-weight: 500; color: rgba(255,255,255,.9); background: rgba(255,255,255,.22); @@ -327,7 +327,7 @@ z-index: 2; } .tile-name { - font-size: 15px; + font-size: 17px; font-weight: 300; color: #fff; line-height: 1.3; @@ -335,7 +335,7 @@ text-shadow: 0 1px 3px rgba(0,0,0,.4); } .tile-meta { - font-size: 10px; + font-size: 12px; color: rgba(255,255,255,.75); margin: 2px 0 0; } @@ -346,7 +346,7 @@ margin-top: 5px; } .tile-tag { - font-size: 8px; + font-size: 10px; font-weight: 500; padding: 2px 5px; border-radius: 2px; -- 2.49.1 From 7f4413852d1248c157159f23ce5f726194d270fb Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 12:53:29 +0200 Subject: [PATCH 21/30] fix(planner): bump front face font sizes again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit name: 17→19px, meta: 12→14px, tags: 10→12px, day-abbr: 11→13px, day-num: 12→14px Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DesktopDayTile.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 731bc39..7bb9d8c 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -295,7 +295,7 @@ z-index: 2; } .tile-day-abbr { - font-size: 11px; + font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: rgba(255,255,255,.85); @@ -307,7 +307,7 @@ display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 14px; font-weight: 500; color: rgba(255,255,255,.9); background: rgba(255,255,255,.22); @@ -327,7 +327,7 @@ z-index: 2; } .tile-name { - font-size: 17px; + font-size: 19px; font-weight: 300; color: #fff; line-height: 1.3; @@ -335,7 +335,7 @@ text-shadow: 0 1px 3px rgba(0,0,0,.4); } .tile-meta { - font-size: 12px; + font-size: 14px; color: rgba(255,255,255,.75); margin: 2px 0 0; } @@ -346,7 +346,7 @@ margin-top: 5px; } .tile-tag { - font-size: 10px; + font-size: 12px; font-weight: 500; padding: 2px 5px; border-radius: 2px; -- 2.49.1 From 66447a7ea0d773610bc6253dbf250a8052cda547 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 14:12:03 +0200 Subject: [PATCH 22/30] refactor(planner): export Slot and SlotMap from types.ts Adds shared Slot and SlotMap interfaces so DesktopDayTile, EmptyDayTile, and reasoningTags can import rather than re-declare. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/lib/planner/types.ts b/frontend/src/lib/planner/types.ts index 7d80c2f..ff9ff96 100644 --- a/frontend/src/lib/planner/types.ts +++ b/frontend/src/lib/planner/types.ts @@ -13,6 +13,14 @@ export interface Recipe { tags?: TagItem[]; } +export interface Slot { + id?: string | null; + slotDate?: string; + recipe?: Recipe | null; +} + +export type SlotMap = Record; + export interface Suggestion { recipe: Recipe; scoreDelta: number; -- 2.49.1 From b0800ca4f30a0397bb2b3d74adc33622900ea39f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 14:12:51 +0200 Subject: [PATCH 23/30] refactor(planner): import shared types in DesktopDayTile instead of re-declaring Removes local TagItem, SlotRecipe, Slot, Suggestion interfaces and imports Recipe, Slot, Suggestion from types.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index 7bb9d8c..af709f5 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -1,33 +1,7 @@