feat(suggestions): C2 — Meal suggestions (variety-aware) #40
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user