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:
89
frontend/src/lib/planner/EmptyDayTile.svelte
Normal file
89
frontend/src/lib/planner/EmptyDayTile.svelte
Normal 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>
|
||||||
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal file
88
frontend/src/lib/planner/EmptyDayTile.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user