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:
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal file
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal 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>
|
||||
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal file
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal 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>
|
||||
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user