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

Merged
marcel merged 2 commits from feat/issue-27-meal-suggestions into master 2026-04-03 11:18:45 +02:00
6 changed files with 75 additions and 24 deletions
Showing only changes of commit 1f2ec97500 - Show all commits

View File

@@ -39,13 +39,13 @@
<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 text-right">
<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)] truncate">
<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}

View File

@@ -27,7 +27,7 @@
weekPlan: WeekPlan | null;
} = $props();
let expanded = $state(true);
let expanded = $state(false);
let slotsWithMeal = $derived(
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)

View File

@@ -18,21 +18,27 @@ describe('SuggestionContextBanner', () => {
expect(screen.getByTestId('context-banner')).toBeTruthy();
});
it('renders meals from the current week', () => {
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('shows/hides detail on toggle', async () => {
it('starts collapsed and expands on toggle', async () => {
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
const toggle = screen.getByRole('button', { name: /Kontext|Filter|Details|ausblenden|einblenden/i });
// Initially expanded or collapsed — toggling should change visibility
const detail = screen.getByTestId('context-detail');
const initiallyVisible = !detail.hasAttribute('hidden');
// Initially collapsed
expect(detail.hasAttribute('hidden')).toBe(true);
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
await fireEvent.click(toggle);
// After toggle the state changes
expect(detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true').toBe(initiallyVisible);
// 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', () => {

View File

@@ -1,4 +1,5 @@
import type { PageServerLoad, Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week';
@@ -42,14 +43,21 @@ export const actions: Actions = {
const planId = formData.get('planId') as string;
const recipeId = formData.get('recipeId') as string;
const slotDate = formData.get('slotDate') as string;
const weekStart = formData.get('weekStart') as string;
// Validate slotDate format
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
return { success: false, error: 'Ungültiges Datum.' };
}
if (!planId || !recipeId) {
return { success: false, error: 'Fehlende Pflichtfelder.' };
// Validate planId is non-empty
if (!planId) {
return { success: false, error: 'Fehlende Plan-ID.' };
}
// Validate recipeId is UUID-like format
if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) {
return { success: false, error: 'Ungültige Rezept-ID.' };
}
const api = apiClient(fetch);
@@ -62,6 +70,7 @@ export const actions: Actions = {
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' };
}
return { success: true };
// Redirect back to the planner after successful pick (spec: "returns to C1")
redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`);
}
};

View File

@@ -10,7 +10,8 @@
let selectedDay = $derived(data.selectedDay);
let weekStart = $derived(data.weekStart);
// Add rank and derive reasoning from simulatedScore for display
// Add rank and derive reasoning from simulatedScore for display.
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
let rankedSuggestions = $derived(
suggestions.map((s: any, i: number) => ({
...s,

View File

@@ -115,11 +115,34 @@ describe('suggestions page — pickSuggestion action', () => {
actions = mod.actions;
});
it('adds a slot to the week plan via POST', async () => {
it('adds a slot to the week plan via POST and redirects to planner', async () => {
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'r1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
try {
await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner?week=2026-03-30');
}
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
params: { path: { id: 'plan-1' } },
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
}));
});
it('returns error when planId is missing', async () => {
const formData = new FormData();
formData.set('planId', '');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
@@ -127,18 +150,30 @@ describe('suggestions page — pickSuggestion action', () => {
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
params: { path: { id: 'plan-1' } },
body: { slotDate: '2026-04-01', recipeId: 'r1' }
}));
expect(result).toEqual({ success: true });
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('returns error for invalid recipeId format', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'not-a-uuid');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
fetch: vi.fn(),
request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
});
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
expect(mockPost).not.toHaveBeenCalled();
});
it('returns error when API fails', async () => {
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'r1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
@@ -152,7 +187,7 @@ describe('suggestions page — pickSuggestion action', () => {
it('returns permission error for member role', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'r1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({
@@ -167,7 +202,7 @@ describe('suggestions page — pickSuggestion action', () => {
it('returns error for invalid slotDate format', async () => {
const formData = new FormData();
formData.set('planId', 'plan-1');
formData.set('recipeId', 'r1');
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
formData.set('slotDate', 'not-a-date');
formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({