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