fix(suggestions): address PR review — input validation, redirect, UI fixes

- Add planId and recipeId (UUID) validation to pickSuggestion action
- Redirect to planner after successful slot pick (spec: returns to C1)
- Fix SuggestionCard recipe name truncation: truncate → line-clamp-2
- Add self-start alignment to rank number column in SuggestionCard
- Collapse SuggestionContextBanner by default (expanded → collapsed)
- Add TODO comment for hardcoded reasoning score threshold in page
- Update tests: redirect assertion, planId/recipeId validation cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 11:18:34 +02:00
parent c94656d998
commit 1f2ec97500
6 changed files with 75 additions and 24 deletions

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)]"> <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 --> <!-- 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> <span class="font-[var(--font-display)] text-[32px] font-[300] leading-none text-[var(--color-text-muted)]">{rank}</span>
</div> </div>
<!-- Card content --> <!-- Card content -->
<div class="flex-1 min-w-0"> <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'} {suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
</p> </p>
{#if metadata} {#if metadata}

View File

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

View File

@@ -18,21 +18,27 @@ describe('SuggestionContextBanner', () => {
expect(screen.getByTestId('context-banner')).toBeTruthy(); 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 } }); 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(/Pasta/)).toBeTruthy();
expect(screen.getByText(/Curry/)).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 } }); 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 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); await fireEvent.click(toggle);
// After toggle the state changes // After toggle: expanded
expect(detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true').toBe(initiallyVisible); 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', () => { it('renders with no slots gracefully', () => {

View File

@@ -1,4 +1,5 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api'; import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week'; import { getWeekStart } from '$lib/planner/week';
@@ -42,14 +43,21 @@ export const actions: Actions = {
const planId = formData.get('planId') as string; const planId = formData.get('planId') as string;
const recipeId = formData.get('recipeId') as string; const recipeId = formData.get('recipeId') as string;
const slotDate = formData.get('slotDate') as string; const slotDate = formData.get('slotDate') as string;
const weekStart = formData.get('weekStart') as string;
// Validate slotDate format // Validate slotDate format
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) { if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
return { success: false, error: 'Ungültiges Datum.' }; return { success: false, error: 'Ungültiges Datum.' };
} }
if (!planId || !recipeId) { // Validate planId is non-empty
return { success: false, error: 'Fehlende Pflichtfelder.' }; 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); const api = apiClient(fetch);
@@ -62,6 +70,7 @@ export const actions: Actions = {
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' }; 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 selectedDay = $derived(data.selectedDay);
let weekStart = $derived(data.weekStart); 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( let rankedSuggestions = $derived(
suggestions.map((s: any, i: number) => ({ suggestions.map((s: any, i: number) => ({
...s, ...s,

View File

@@ -115,11 +115,34 @@ describe('suggestions page — pickSuggestion action', () => {
actions = mod.actions; 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 }); mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
const formData = new FormData(); const formData = new FormData();
formData.set('planId', 'plan-1'); 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('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30'); formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({ const result = await actions.pickSuggestion({
@@ -127,18 +150,30 @@ describe('suggestions page — pickSuggestion action', () => {
request: { formData: async () => formData }, request: { formData: async () => formData },
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
}); });
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({ expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
params: { path: { id: 'plan-1' } }, expect(mockPost).not.toHaveBeenCalled();
body: { slotDate: '2026-04-01', recipeId: 'r1' } });
}));
expect(result).toEqual({ success: true }); 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 () => { it('returns error when API fails', async () => {
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } }); mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
const formData = new FormData(); const formData = new FormData();
formData.set('planId', 'plan-1'); 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('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30'); formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({ const result = await actions.pickSuggestion({
@@ -152,7 +187,7 @@ describe('suggestions page — pickSuggestion action', () => {
it('returns permission error for member role', async () => { it('returns permission error for member role', async () => {
const formData = new FormData(); const formData = new FormData();
formData.set('planId', 'plan-1'); 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('slotDate', '2026-04-01');
formData.set('weekStart', '2026-03-30'); formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({ const result = await actions.pickSuggestion({
@@ -167,7 +202,7 @@ describe('suggestions page — pickSuggestion action', () => {
it('returns error for invalid slotDate format', async () => { it('returns error for invalid slotDate format', async () => {
const formData = new FormData(); const formData = new FormData();
formData.set('planId', 'plan-1'); 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('slotDate', 'not-a-date');
formData.set('weekStart', '2026-03-30'); formData.set('weekStart', '2026-03-30');
const result = await actions.pickSuggestion({ const result = await actions.pickSuggestion({