feat(planner): implement C1 weekly planner home screen (#26)
Three-breakpoint layout (mobile/tablet/desktop) with VarietyScoreCard, WeekStrip, DayMealCard components. Server loads week plan and variety score via API; read-only role behavior derived from benutzer.rolle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
45
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
45
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data: weekPlan, error } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (error || !weekPlan?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart };
|
||||
}
|
||||
|
||||
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||
params: { path: { id: weekPlan.id } }
|
||||
});
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
varietyScore: varietyScore ?? null,
|
||||
weekStart
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createPlan: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const weekStart = formData.get('weekStart') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans', {
|
||||
body: { weekStart }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Plan konnte nicht erstellt werden.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -1 +1,345 @@
|
||||
<h1 class="text-2xl font-medium p-6">Planer</h1>
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatWeekRange, isToday } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Capture initial weekStart before reactivity for $state initialization
|
||||
const initialWeekStart: string = data.weekStart;
|
||||
const todayStr = getWeekStart(new Date());
|
||||
const today = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
let weekStart = $derived(data.weekStart);
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let varietyScore = $derived(data.varietyScore);
|
||||
|
||||
let days = $derived(weekDays(weekStart));
|
||||
let slots = $derived(weekPlan?.slots ?? []);
|
||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||||
|
||||
// Default selected day: today if in this week, else first day
|
||||
let selectedDay = $state(weekDays(initialWeekStart).includes(today) ? today : weekDays(initialWeekStart)[0]);
|
||||
|
||||
// When week changes via navigation, reset selected day
|
||||
$effect(() => {
|
||||
const newDays = weekDays(weekStart);
|
||||
if (!newDays.includes(selectedDay)) {
|
||||
selectedDay = newDays.includes(today) ? today : newDays[0];
|
||||
}
|
||||
});
|
||||
|
||||
let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null });
|
||||
let remainingSlots = $derived(days.filter(d => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
|
||||
|
||||
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
|
||||
|
||||
let weekRange = $derived(formatWeekRange(weekStart));
|
||||
|
||||
function handleSelectDay(day: string) {
|
||||
selectedDay = day;
|
||||
}
|
||||
|
||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||
let newWeekStart: string;
|
||||
if (direction === 'prev') newWeekStart = prevWeek(weekStart);
|
||||
else if (direction === 'next') newWeekStart = nextWeek(weekStart);
|
||||
else newWeekStart = getWeekStart(new Date());
|
||||
|
||||
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile & Tablet: vertical stack -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Top nav (sticky) -->
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Diese Woche</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Variety banner (always visible) -->
|
||||
{#if varietyScore}
|
||||
<div class="px-4 pt-3">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day strip -->
|
||||
<div class="px-4 pt-3">
|
||||
<WeekStrip
|
||||
{weekStart}
|
||||
{slots}
|
||||
{selectedDay}
|
||||
{today}
|
||||
onselectDay={handleSelectDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected day card -->
|
||||
<div class="px-4 pt-4">
|
||||
<p class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)}
|
||||
</p>
|
||||
<DayMealCard slot={selectedSlot} isToday={selectedDay === today} readonly={!isPlanner} />
|
||||
</div>
|
||||
|
||||
<!-- Remaining days list -->
|
||||
{#if remainingSlots.filter((s: any) => s.recipe).length > 0}
|
||||
<div class="px-4 pt-6 pb-4">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Restliche Woche
|
||||
</h2>
|
||||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||||
{#each remainingSlots.filter((s: any) => s.recipe) as slot}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(slot.slotDate)}
|
||||
class="flex w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left hover:border-[var(--green-light)]"
|
||||
>
|
||||
<span class="min-w-[36px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate).split(',')[0]}
|
||||
</span>
|
||||
<span class="flex-1 font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)] truncate">
|
||||
{slot.recipe?.name}
|
||||
</span>
|
||||
{#if isPlanner}
|
||||
<span class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">→</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty week state -->
|
||||
{#if !weekPlan}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 3-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">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Wochenplaner</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{weekRange}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('today')}
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left sidebar -->
|
||||
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<nav class="flex-1">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">Navigation</p>
|
||||
</nav>
|
||||
<!-- Variety widget at bottom -->
|
||||
{#if varietyScore}
|
||||
<div class="mt-auto">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Main calendar (only scrollable panel) -->
|
||||
<main class="flex-1 overflow-y-auto p-5">
|
||||
{#if !weekPlan}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button type="submit" class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white">
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-7 gap-[8px]">
|
||||
{#each days as day}
|
||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const isSelectedDay = day === selectedDay}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Column header -->
|
||||
<div class="mb-2 text-center">
|
||||
<div
|
||||
class="mx-auto flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
||||
>
|
||||
{dateNum}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meal tile -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(day)}
|
||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
||||
{slot.recipe ? 'bg-[var(--color-surface)]' : 'border-dashed bg-transparent'}
|
||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
||||
{!isTodayDay && !isSelectedDay && slot.recipe ? 'border-[var(--color-border)]' : ''}
|
||||
{!slot.recipe ? 'border-[var(--color-border)]' : ''}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe.name}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
|
||||
<span class="text-[18px]">+</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Right detail panel -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
|
||||
<div class="mb-3">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)} · Abendessen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if selectedSlot?.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{selectedSlot.recipe.name}
|
||||
</h2>
|
||||
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[selectedSlot.recipe.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !isPlanner === false}
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="mt-3 block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht wählen
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
130
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
130
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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('planner page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } }
|
||||
]
|
||||
};
|
||||
|
||||
it('fetches week plan for the current week by default', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] },
|
||||
error: undefined
|
||||
});
|
||||
const url = new URL('http://localhost/planner');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) }));
|
||||
});
|
||||
|
||||
it('uses weekStart from URL search params if provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) }));
|
||||
});
|
||||
|
||||
it('returns weekPlan with slots in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe('plan-1');
|
||||
expect(result.weekPlan.slots).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns variety score in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.varietyScore.score).toBe(7.5);
|
||||
expect(result.varietyScore.ingredientOverlaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan when API returns 404', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the weekStart used for the query', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('creates week plan if not found and fetches variety score after creation', async () => {
|
||||
// When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — actions', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('createPlan action calls POST /v1/week-plans', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: 'plan-new', weekStart: '2026-03-30', slots: [] }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ body: { weekStart: '2026-03-30' } }));
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('createPlan action returns error when API fails', async () => {
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user