feat(suggestions): implement C2 meal suggestions screen (#27)
Two-panel desktop layout (280px context panel + flex suggestions panel)
and mobile layout (sticky header + collapsible context banner + ranked list).
- SuggestionCard: rank number, recipe metadata, green/yellow reasoning badge, pick form
- SuggestionContextBanner: collapsible week-so-far list + filter reasons
- Server load: fetches week plan + variety-aware suggestions sorted by simulatedScore
- pickSuggestion action: role guard, date validation, POST to /v1/week-plans/{id}/slots
- Browse full library link passes selectFor+week params to recipe library (B1 integration point)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal file
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe?: SlotRecipe;
|
||||
simulatedScore?: number;
|
||||
reasoningType?: 'good' | 'warning';
|
||||
reasoningLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
suggestion,
|
||||
rank,
|
||||
planId,
|
||||
slotDate,
|
||||
weekStart
|
||||
}: {
|
||||
suggestion: Suggestion;
|
||||
rank: number;
|
||||
planId: string;
|
||||
slotDate: string;
|
||||
weekStart: string;
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
suggestion.recipe?.cookTimeMin != null ? `${suggestion.recipe.cookTimeMin} Min` : null,
|
||||
suggestion.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
|
||||
</p>
|
||||
{#if metadata}
|
||||
<p class="mt-0.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Reasoning badge -->
|
||||
{#if suggestion.reasoningType && suggestion.reasoningLabel}
|
||||
<div
|
||||
data-testid="reasoning-badge"
|
||||
data-type={suggestion.reasoningType}
|
||||
class="mt-2 inline-flex items-center rounded-full px-2 py-0.5 font-[var(--font-sans)] text-[11px] font-medium
|
||||
{suggestion.reasoningType === 'good'
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
|
||||
: 'bg-[var(--yellow-tint)] text-[var(--yellow-text)]'}"
|
||||
>
|
||||
{suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pick action -->
|
||||
<form method="POST" action="?/pickSuggestion" class="flex-shrink-0">
|
||||
<input type="hidden" name="planId" value={planId} />
|
||||
<input type="hidden" name="recipeId" value={suggestion.recipe?.id} />
|
||||
<input type="hidden" name="slotDate" value={slotDate} />
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="font-[var(--font-sans)] text-[13px] font-medium tracking-[0.04em] text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Wählen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal file
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import SuggestionCard from './SuggestionCard.svelte';
|
||||
|
||||
const goodSuggestion = {
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2,
|
||||
reasoningType: 'good' as const,
|
||||
reasoningLabel: 'Frisches Protein · Aufwandsbalance'
|
||||
};
|
||||
|
||||
const warningSuggestion = {
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1,
|
||||
reasoningType: 'warning' as const,
|
||||
reasoningLabel: 'Hähnchen schon 2 Tage dabei'
|
||||
};
|
||||
|
||||
describe('SuggestionCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders rank number', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders cook time and effort metadata', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText(/25 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders green reasoning badge for good suggestions', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
expect(badge.textContent).toContain('Frisches Protein');
|
||||
});
|
||||
|
||||
it('renders yellow reasoning badge for warnings', () => {
|
||||
render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('warning');
|
||||
expect(badge.textContent).toContain('Hähnchen');
|
||||
});
|
||||
|
||||
it('renders a pick button/form', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('card without reasoning renders without crashing', () => {
|
||||
const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined };
|
||||
render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { formatDayLabel } from './week';
|
||||
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
interface WeekPlan {
|
||||
id?: string;
|
||||
weekStart?: string;
|
||||
slots?: Slot[];
|
||||
}
|
||||
|
||||
let {
|
||||
selectedDay,
|
||||
weekPlan
|
||||
}: {
|
||||
selectedDay: string;
|
||||
weekPlan: WeekPlan | null;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(true);
|
||||
|
||||
let slotsWithMeal = $derived(
|
||||
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="context-banner"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--green-light)] bg-[var(--green-tint)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]">
|
||||
Vorschläge für <strong>{formatDayLabel(selectedDay)}</strong>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="context-detail"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
{expanded ? 'Filter ausblenden ▲' : 'Filter einblenden ▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="context-detail"
|
||||
data-testid="context-detail"
|
||||
aria-hidden={!expanded}
|
||||
{...expanded ? {} : { hidden: true }}
|
||||
>
|
||||
{#if slotsWithMeal.length > 0}
|
||||
<div class="mt-3">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[11px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each slotsWithMeal as slot}
|
||||
<li class="flex gap-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text)]">
|
||||
<span class="text-[var(--color-text-muted)]">{formatDayLabel(slot.slotDate!).split(',')[0]}</span>
|
||||
<span>{slot.recipe?.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
42
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
42
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SuggestionContextBanner from './SuggestionContextBanner.svelte';
|
||||
|
||||
const weekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Hard' } }
|
||||
]
|
||||
};
|
||||
|
||||
describe('SuggestionContextBanner', () => {
|
||||
it('renders the selected day label', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
// Day label should be visible
|
||||
expect(screen.getByTestId('context-banner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders meals from the current week', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||
expect(screen.getByText(/Curry/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows/hides detail 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');
|
||||
await fireEvent.click(toggle);
|
||||
// After toggle the state changes
|
||||
expect(detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true').toBe(initiallyVisible);
|
||||
});
|
||||
|
||||
it('renders with no slots gracefully', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } });
|
||||
expect(screen.getByTestId('context-banner')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
const selectedDay = url.searchParams.get('day') ?? weekStart;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
|
||||
// Load the week plan for context (week-so-far display)
|
||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (weekPlanError || !weekPlan?.id) {
|
||||
return { weekPlan: null, suggestions: [], selectedDay, weekStart };
|
||||
}
|
||||
|
||||
// Load variety-aware suggestions for the selected day
|
||||
const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
||||
params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } }
|
||||
});
|
||||
|
||||
// Sort by simulatedScore descending (highest = best variety fit)
|
||||
const suggestions = (suggestionsData?.suggestions ?? []).sort(
|
||||
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
||||
);
|
||||
|
||||
return { weekPlan, suggestions, selectedDay, weekStart };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
pickSuggestion: async ({ fetch, request, locals }) => {
|
||||
// Role guard: only planners may assign meals
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return { success: false, error: 'Keine Berechtigung.' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
const slotDate = formData.get('slotDate') 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.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||
params: { path: { id: planId } },
|
||||
body: { slotDate, recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
198
frontend/src/routes/(app)/planner/suggestions/+page.svelte
Normal file
198
frontend/src/routes/(app)/planner/suggestions/+page.svelte
Normal file
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
|
||||
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
|
||||
import { formatDayLabel } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let suggestions = $derived(data.suggestions ?? []);
|
||||
let selectedDay = $derived(data.selectedDay);
|
||||
let weekStart = $derived(data.weekStart);
|
||||
|
||||
// Add rank and derive reasoning from simulatedScore for display
|
||||
let rankedSuggestions = $derived(
|
||||
suggestions.map((s: any, i: number) => ({
|
||||
...s,
|
||||
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
|
||||
reasoningLabel:
|
||||
(s.simulatedScore ?? 0) >= 7.5
|
||||
? 'Passt gut zur Woche'
|
||||
: 'Wiederholung möglich'
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gerichtsvorschläge — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile layout: full-width list with context banner -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Mobile topbar -->
|
||||
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
aria-label="Zurück zum Planer"
|
||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- Context banner -->
|
||||
<div class="px-4 pt-3">
|
||||
<SuggestionContextBanner {selectedDay} {weekPlan} />
|
||||
</div>
|
||||
|
||||
<!-- Suggestion list -->
|
||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Browse full library fallback -->
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 2-panel layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar -->
|
||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
aria-label="Zurück zum Planer"
|
||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left context panel (280px) -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</h2>
|
||||
|
||||
{#if weekPlan?.slots?.length}
|
||||
<ul class="space-y-2">
|
||||
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
|
||||
<li class="flex items-baseline gap-2">
|
||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
|
||||
</span>
|
||||
{#if slot.recipe}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{slot.recipe.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Filter reasons -->
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Filterkriterien
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Keine Zutatenwiederholungen (3 Tage)
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Protein-Abwechslung beachten
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Aufwandsbalance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Browse library link in desktop panel footer -->
|
||||
<div class="mt-auto pt-6">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Bibliothek →
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right suggestions panel -->
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('suggestions page — load', () => {
|
||||
let load: any;
|
||||
|
||||
const mockSuggestions = {
|
||||
suggestions: [
|
||||
{
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2
|
||||
},
|
||||
{
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches suggestions for the given plan and day', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
||||
params: expect.objectContaining({ path: { id: 'plan-1' } })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns suggestions list sorted by simulatedScore descending', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
|
||||
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
|
||||
});
|
||||
|
||||
it('returns the selectedDay from URL params', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-04-01');
|
||||
});
|
||||
|
||||
it('returns empty suggestions when API fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns week plan slots for context display', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults day to weekStart when no day param provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-03-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions page — pickSuggestion action', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('adds a slot to the week plan via POST', 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('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(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 });
|
||||
});
|
||||
|
||||
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('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: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns permission error for member role', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', 'r1');
|
||||
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: 'mitglied' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for invalid slotDate format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', 'r1');
|
||||
formData.set('slotDate', 'not-a-date');
|
||||
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: expect.any(String) });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user