feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54
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