feat(planner): add RecipePicker component (C4) and suggestions API endpoint
C4 sheet content: Empfohlen section with variety delta badges, Alle Rezepte with client-side search filter. GET /planner endpoint proxies suggestions to backend for lazy client-side loading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe: Recipe;
|
||||
simulatedScore: number;
|
||||
}
|
||||
|
||||
let {
|
||||
planId,
|
||||
date,
|
||||
dateLabel,
|
||||
currentVarietyScore = 0,
|
||||
suggestions = [],
|
||||
allRecipes = [],
|
||||
onpick
|
||||
}: {
|
||||
planId: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
currentVarietyScore?: number;
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? allRecipes
|
||||
: allRecipes.filter((r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
function recipeMetadata(recipe: Recipe): string {
|
||||
return [
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;">
|
||||
Rezept wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{dateLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Rezept suchen…"
|
||||
style="width: 100%; box-sizing: border-box; padding: 5px 8px; font-size: 11px; font-family: var(--font-sans); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text);"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfohlen section -->
|
||||
{#if suggestions.length > 0}
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
|
||||
{#each suggestions as suggestion (suggestion.recipe.id)}
|
||||
{@const delta = suggestion.simulatedScore - currentVarietyScore}
|
||||
{@const meta = recipeMetadata(suggestion.recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{suggestion.recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if delta > 0}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{delta.toFixed(0)} Punkte
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="warning"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||
>
|
||||
⚠ Variationskonflikt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Alle Rezepte
|
||||
</div>
|
||||
|
||||
{#if filteredRecipes.length === 0}
|
||||
<p style="padding: 10px 12px; font-size: 11px; color: var(--color-text-muted); margin: 0;">
|
||||
Keine Treffer
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredRecipes as recipe (recipe.id)}
|
||||
{@const meta = recipeMetadata(recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(recipe.id, recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RecipePicker from './RecipePicker.svelte';
|
||||
|
||||
const suggestions = [
|
||||
{ recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, simulatedScore: 9.5 },
|
||||
{ recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 }
|
||||
];
|
||||
|
||||
const allRecipes = [
|
||||
{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 },
|
||||
{ id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy', cookTimeMin: 20 },
|
||||
{ id: 'r3', name: 'Tomatensuppe', effort: 'easy', cookTimeMin: 30 }
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
planId: 'plan-1',
|
||||
date: '2026-04-05',
|
||||
dateLabel: 'Samstag, 5. April',
|
||||
currentVarietyScore: 7.5,
|
||||
suggestions,
|
||||
allRecipes,
|
||||
onpick: vi.fn()
|
||||
};
|
||||
|
||||
describe('RecipePicker', () => {
|
||||
it('shows date label in header', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText('Samstag, 5. April')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Empfohlen section', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows all suggestion recipe names', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText('Lachsfilet')).toBeTruthy();
|
||||
expect(screen.getByText('Hähnchen-Curry')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows green badge for suggestions with positive delta', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge
|
||||
const badge = screen.getByTestId('badge-s1');
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
});
|
||||
|
||||
it('shows yellow badge for suggestions with zero or negative delta', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge
|
||||
const badge = screen.getByTestId('badge-s2');
|
||||
expect(badge.getAttribute('data-type')).toBe('warning');
|
||||
});
|
||||
|
||||
it('shows Alle Rezepte section', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText(/Alle Rezepte/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows all recipe names in Alle Rezepte', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
|
||||
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
|
||||
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters recipes by search query', async () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
const input = screen.getByRole('searchbox');
|
||||
await userEvent.type(input, 'Spaghetti');
|
||||
expect(screen.queryByText('Beef Bourguignon')).toBeNull();
|
||||
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onpick with recipeId and name when Wählen clicked for suggestion', async () => {
|
||||
const onpick = vi.fn();
|
||||
render(RecipePicker, { props: { ...baseProps, onpick } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
await userEvent.click(buttons[0]);
|
||||
expect(onpick).toHaveBeenCalledWith('s1', 'Lachsfilet');
|
||||
});
|
||||
|
||||
it('calls onpick when Wählen clicked for all-recipes item', async () => {
|
||||
const onpick = vi.fn();
|
||||
render(RecipePicker, { props: { ...baseProps, onpick } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
// First 2 are suggestions, rest are allRecipes
|
||||
await userEvent.click(buttons[2]);
|
||||
expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon');
|
||||
});
|
||||
|
||||
it('shows empty state when search has no results', async () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
const input = screen.getByRole('searchbox');
|
||||
await userEvent.type(input, 'xyznotfound');
|
||||
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
// GET /planner?planId=&date= — returns suggestions JSON for C4 recipe picker
|
||||
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||
const planId = url.searchParams.get('planId');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
if (!planId || !date) {
|
||||
return json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
||||
params: { path: { id: planId }, query: { slotDate: date } }
|
||||
});
|
||||
|
||||
const suggestions = (data?.suggestions ?? []).sort(
|
||||
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
||||
);
|
||||
|
||||
return json({ suggestions });
|
||||
};
|
||||
Reference in New Issue
Block a user