feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe?: SlotRecipe;
|
||||
simulatedScore?: number;
|
||||
reasoningType?: 'good' | 'warning';
|
||||
reasoningLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
suggestion,
|
||||
rank,
|
||||
planId,
|
||||
slotDate,
|
||||
weekStart
|
||||
}: {
|
||||
suggestion: Suggestion;
|
||||
rank: number;
|
||||
planId: string;
|
||||
slotDate: string;
|
||||
weekStart: string;
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
suggestion.recipe?.cookTimeMin != null ? `${suggestion.recipe.cookTimeMin} Min` : null,
|
||||
suggestion.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 shadow-[var(--shadow-card)]">
|
||||
<!-- Rank number -->
|
||||
<div class="w-10 flex-shrink-0 self-start text-right">
|
||||
<span class="font-[var(--font-display)] text-[32px] font-[300] leading-none text-[var(--color-text-muted)]">{rank}</span>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-[var(--font-sans)] text-[15px] font-medium text-[var(--color-text)] line-clamp-2">
|
||||
{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
|
||||
</p>
|
||||
{#if metadata}
|
||||
<p class="mt-0.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Reasoning badge -->
|
||||
{#if suggestion.reasoningType && suggestion.reasoningLabel}
|
||||
<div
|
||||
data-testid="reasoning-badge"
|
||||
data-type={suggestion.reasoningType}
|
||||
class="mt-2 inline-flex items-center rounded-full px-2 py-0.5 font-[var(--font-sans)] text-[11px] font-medium
|
||||
{suggestion.reasoningType === 'good'
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
|
||||
: 'bg-[var(--yellow-tint)] text-[var(--yellow-text)]'}"
|
||||
>
|
||||
{suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pick action -->
|
||||
<form method="POST" action="?/pickSuggestion" class="flex-shrink-0">
|
||||
<input type="hidden" name="planId" value={planId} />
|
||||
<input type="hidden" name="recipeId" value={suggestion.recipe?.id} />
|
||||
<input type="hidden" name="slotDate" value={slotDate} />
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="font-[var(--font-sans)] text-[13px] font-medium tracking-[0.04em] text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Wählen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import SuggestionCard from './SuggestionCard.svelte';
|
||||
|
||||
const goodSuggestion = {
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2,
|
||||
reasoningType: 'good' as const,
|
||||
reasoningLabel: 'Frisches Protein · Aufwandsbalance'
|
||||
};
|
||||
|
||||
const warningSuggestion = {
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1,
|
||||
reasoningType: 'warning' as const,
|
||||
reasoningLabel: 'Hähnchen schon 2 Tage dabei'
|
||||
};
|
||||
|
||||
describe('SuggestionCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders rank number', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders cook time and effort metadata', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText(/25 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders green reasoning badge for good suggestions', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
expect(badge.textContent).toContain('Frisches Protein');
|
||||
});
|
||||
|
||||
it('renders yellow reasoning badge for warnings', () => {
|
||||
render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('warning');
|
||||
expect(badge.textContent).toContain('Hähnchen');
|
||||
});
|
||||
|
||||
it('renders a pick button/form', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('card without reasoning renders without crashing', () => {
|
||||
const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined };
|
||||
render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user