feat(suggestions): C2 — Meal suggestions (variety-aware) (#40)

feat(suggestions): implement C2 meal suggestion screen (Issue #27)

Co-authored-by: Marcel Raddatz <marcel@raddatz.cloud>
Co-committed-by: Marcel Raddatz <marcel@raddatz.cloud>
This commit was merged in pull request #40.
This commit is contained in:
2026-04-03 11:18:45 +02:00
committed by marcel
parent 05e47c3dac
commit 7c07bc443b
7 changed files with 768 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
<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>

View File

@@ -0,0 +1,60 @@
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();
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { formatDayLabel } from './week';
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
}
interface Slot {
id?: string;
slotDate?: string;
recipe?: SlotRecipe | null;
}
interface WeekPlan {
id?: string;
weekStart?: string;
slots?: Slot[];
}
let {
selectedDay,
weekPlan
}: {
selectedDay: string;
weekPlan: WeekPlan | null;
} = $props();
let expanded = $state(false);
let slotsWithMeal = $derived(
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)
);
function toggle() {
expanded = !expanded;
}
</script>
<div
data-testid="context-banner"
class="rounded-[var(--radius-md)] border border-[var(--green-light)] bg-[var(--green-tint)] px-4 py-3"
>
<div class="flex items-center justify-between">
<p class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]">
Vorschläge für <strong>{formatDayLabel(selectedDay)}</strong>
</p>
<button
type="button"
onclick={toggle}
aria-expanded={expanded}
aria-controls="context-detail"
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
{expanded ? 'Filter ausblenden ▲' : 'Filter einblenden ▼'}
</button>
</div>
<div
id="context-detail"
data-testid="context-detail"
aria-hidden={!expanded}
{...expanded ? {} : { hidden: true }}
>
{#if slotsWithMeal.length > 0}
<div class="mt-3">
<p class="mb-1 font-[var(--font-sans)] text-[11px] uppercase tracking-wide text-[var(--color-text-muted)]">
Diese Woche bisher
</p>
<ul class="space-y-1">
{#each slotsWithMeal as slot}
<li class="flex gap-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text)]">
<span class="text-[var(--color-text-muted)]">{formatDayLabel(slot.slotDate!).split(',')[0]}</span>
<span>{slot.recipe?.name}</span>
</li>
{/each}
</ul>
</div>
{:else}
<p class="mt-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Noch keine Gerichte diese Woche geplant
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import SuggestionContextBanner from './SuggestionContextBanner.svelte';
const weekPlan = {
id: 'plan-1',
weekStart: '2026-03-30',
slots: [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Hard' } }
]
};
describe('SuggestionContextBanner', () => {
it('renders the selected day label', () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
// Day label should be visible
expect(screen.getByTestId('context-banner')).toBeTruthy();
});
it('renders meals from the current week after expanding', async () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
// Banner starts collapsed — expand it first
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
await fireEvent.click(toggle);
expect(screen.getByText(/Pasta/)).toBeTruthy();
expect(screen.getByText(/Curry/)).toBeTruthy();
});
it('starts collapsed and expands on toggle', async () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
const detail = screen.getByTestId('context-detail');
// Initially collapsed
expect(detail.hasAttribute('hidden')).toBe(true);
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
await fireEvent.click(toggle);
// After toggle: expanded
expect(detail.hasAttribute('hidden')).toBe(false);
await fireEvent.click(toggle);
// After second toggle: collapsed again
expect(detail.hasAttribute('hidden')).toBe(true);
});
it('renders with no slots gracefully', () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } });
expect(screen.getByTestId('context-banner')).toBeTruthy();
});
});