feat(planner): add EmptyDayTile component

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:47:19 +02:00
parent f37f20d34e
commit 2b7a7cceec
2 changed files with 177 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { computeReasoningTags } from './reasoningTags';
interface TagItem {
id?: string;
name?: string;
tagType?: string;
}
interface SuggestionRecipe {
id: string;
name: string;
cookTimeMin?: number;
effort?: string;
tags?: TagItem[];
}
interface TopSuggestion {
recipe: SuggestionRecipe;
scoreDelta: number;
hasConflict: boolean;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: any | null;
}
let {
slotDate,
slotId,
isPlanner,
slotMap,
topSuggestion,
onaddrecipe
}: {
slotDate: string;
slotId: string;
isPlanner: boolean;
slotMap: Record<string, Slot>;
topSuggestion?: TopSuggestion;
onaddrecipe?: () => void;
} = $props();
let reasoningTags = $derived(
topSuggestion ? computeReasoningTags(slotMap, topSuggestion.recipe) : []
);
</script>
<div
data-testid="empty-day-tile"
role="group"
class="h-full flex flex-col gap-2 p-3"
style="border: 1px dashed var(--color-border);"
>
{#if isPlanner}
<button
type="button"
aria-label="Gericht wählen"
onclick={() => onaddrecipe?.()}
class="self-start font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
>
+ Gericht wählen
</button>
{/if}
{#if topSuggestion}
<p class="font-[var(--font-display)] text-[12px] text-[var(--color-text-muted)] leading-snug">
{topSuggestion.recipe.name}
</p>
{#if reasoningTags.length > 0}
<div class="flex flex-wrap gap-1">
{#each reasoningTags as tag (tag.id)}
<span
data-testid="reasoning-tag"
class="inline-block rounded px-1.5 py-0.5 font-[var(--font-sans)] text-[11px] font-medium"
style={tag.color === 'green'
? 'background: var(--green-tint); color: var(--green-dark);'
: 'background: var(--yellow-tint); color: var(--yellow-text);'}
>
{tag.label}
</span>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -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();
});
});
});