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 <noreply@anthropic.com>
This commit is contained in:
201
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
201
frontend/src/lib/planner/DesktopDayTile.svelte
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||||
|
|
||||||
|
interface TagItem {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
tagType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotRecipe {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
effort?: string;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
heroImageUrl?: string | null;
|
||||||
|
tags?: TagItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
id?: string | null;
|
||||||
|
slotDate?: string;
|
||||||
|
recipe?: SlotRecipe | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
recipe: any;
|
||||||
|
scoreDelta: number;
|
||||||
|
hasConflict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
slot,
|
||||||
|
isToday,
|
||||||
|
activeSlotId,
|
||||||
|
isPlanner,
|
||||||
|
slotMap,
|
||||||
|
suggestions,
|
||||||
|
onflip,
|
||||||
|
onclose,
|
||||||
|
onswap,
|
||||||
|
onremove,
|
||||||
|
onaddrecipe
|
||||||
|
}: {
|
||||||
|
slot: Slot;
|
||||||
|
isToday: boolean;
|
||||||
|
activeSlotId: string | null;
|
||||||
|
isPlanner: boolean;
|
||||||
|
slotMap: Record<string, any>;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
onflip?: (slotId: string) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
onswap?: () => void;
|
||||||
|
onremove?: () => void;
|
||||||
|
onaddrecipe?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const slotId = $derived(slot.id ?? '');
|
||||||
|
|
||||||
|
const isFlipped = $derived(activeSlotId === slot.id && !!slot.recipe);
|
||||||
|
const isDimmed = $derived(activeSlotId !== null && activeSlotId !== slot.id && !!slot.recipe);
|
||||||
|
|
||||||
|
function handleFlip() {
|
||||||
|
onflip?.(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onflip?.(slotId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(--color-surface)';
|
||||||
|
})());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if slot.recipe}
|
||||||
|
<div
|
||||||
|
data-testid="day-meal-card"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={slot.recipe?.name ?? 'Gericht'}
|
||||||
|
aria-expanded={isFlipped}
|
||||||
|
data-today={isToday}
|
||||||
|
data-flipped={isFlipped}
|
||||||
|
data-dimmed={isDimmed}
|
||||||
|
class="scene"
|
||||||
|
onclick={handleFlip}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
>
|
||||||
|
<div class="card" class:flipped={isFlipped}>
|
||||||
|
<div
|
||||||
|
class="card-front"
|
||||||
|
style="background: {gradientBackground}; background-size: cover; background-position: center;"
|
||||||
|
>
|
||||||
|
<p style="font-family: var(--font-display); font-size: 13px; padding: 8px; margin: 0; color: var(--color-text);">
|
||||||
|
{slot.recipe.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-back" aria-hidden={!isFlipped}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onclose?.(); }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if slot.recipe.cookTimeMin}
|
||||||
|
<span style="font-size: 12px;">{slot.recipe.cookTimeMin} min</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if slot.recipe.effort}
|
||||||
|
<span style="font-size: 12px; margin-left: 4px;">{slot.recipe.effort}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<a href="/recipes/{slot.recipe.id}/cook" onclick={(e) => e.stopPropagation()}>Koch-Modus</a>
|
||||||
|
<a href="/recipes/{slot.recipe.id}" onclick={(e) => e.stopPropagation()}>Rezept ansehen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isPlanner}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onswap?.(); }}
|
||||||
|
style="margin-top: 8px; display: block;"
|
||||||
|
>
|
||||||
|
Gericht tauschen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isPlanner && slot.id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onremove?.(); }}
|
||||||
|
style="margin-top: 4px; display: block;"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyDayTile
|
||||||
|
slotDate={slot.slotDate ?? ''}
|
||||||
|
slotId={slot.id ?? ''}
|
||||||
|
{isPlanner}
|
||||||
|
{slotMap}
|
||||||
|
topSuggestion={undefined}
|
||||||
|
{onaddrecipe}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scene {
|
||||||
|
perspective: 900px;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.card.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
.card-front,
|
||||||
|
.card-back {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
background: var(--color-page);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
172
frontend/src/lib/planner/DesktopDayTile.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user