feat(planner): integrate C4 RecipePicker with PanelState machine + slot actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,52 @@ export const load: PageServerLoad = async ({ fetch, url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
addSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotDate = formData.get('slotDate') as string;
|
||||||
|
const recipeId = formData.get('recipeId') as string;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotId = formData.get('slotId') as string;
|
||||||
|
const recipeId = formData.get('recipeId') as string;
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } },
|
||||||
|
body: { recipeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) return { success: false };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotId = formData.get('slotId') as string;
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return { success: false };
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
createPlan: async ({ fetch, request, locals }) => {
|
createPlan: async ({ fetch, request, locals }) => {
|
||||||
// Role guard: only planners may create week plans
|
// Role guard: only planners may create week plans
|
||||||
if (locals.benutzer?.rolle !== 'planer') {
|
if (locals.benutzer?.rolle !== 'planer') {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||||
|
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||||
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
|
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string }; form?: any } = $props();
|
||||||
|
|
||||||
// Capture initial weekStart before reactivity for $state initialization
|
|
||||||
const initialWeekStart: string = data.weekStart;
|
|
||||||
// Use UTC date string (YYYY-MM-DD) consistently
|
// Use UTC date string (YYYY-MM-DD) consistently
|
||||||
const today: string = new Date().toISOString().slice(0, 10);
|
const today: string = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -21,7 +24,12 @@
|
|||||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||||||
|
|
||||||
// Default selected day: today if in this week, else first day
|
// Default selected day: today if in this week, else first day
|
||||||
let selectedDay = $state(weekDays(initialWeekStart).includes(today) ? today : weekDays(initialWeekStart)[0]);
|
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
||||||
|
let selectedDay = $state((() => {
|
||||||
|
const init = data.weekStart;
|
||||||
|
const d = weekDays(init);
|
||||||
|
return d.includes(today) ? today : d[0];
|
||||||
|
})());
|
||||||
|
|
||||||
// When week changes via navigation, reset selected day
|
// When week changes via navigation, reset selected day
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -39,8 +47,40 @@
|
|||||||
|
|
||||||
let weekRange = $derived(formatWeekRange(weekStart));
|
let weekRange = $derived(formatWeekRange(weekStart));
|
||||||
|
|
||||||
|
// Desktop right panel state machine
|
||||||
|
type PanelState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'day-detail'; date: string }
|
||||||
|
| { kind: 'recipe-picker'; date: string };
|
||||||
|
|
||||||
|
let panelState = $state<PanelState>({ kind: 'idle' });
|
||||||
|
|
||||||
|
// Mobile bottom sheet for RecipePicker
|
||||||
|
let pickerOpen = $state(false);
|
||||||
|
|
||||||
|
// Hidden form field bindings
|
||||||
|
let addPlanId = $state('');
|
||||||
|
let addSlotDate = $state('');
|
||||||
|
let addRecipeId = $state('');
|
||||||
|
let addRecipeName = $state('');
|
||||||
|
let updPlanId = $state('');
|
||||||
|
let updSlotId = $state('');
|
||||||
|
let updRecipeId = $state('');
|
||||||
|
let updRecipeName = $state('');
|
||||||
|
let delPlanId = $state('');
|
||||||
|
let delSlotId = $state('');
|
||||||
|
|
||||||
|
let addSlotFormEl: HTMLFormElement;
|
||||||
|
let updateSlotFormEl: HTMLFormElement;
|
||||||
|
let deleteSlotFormEl: HTMLFormElement;
|
||||||
|
|
||||||
|
// UndoBar
|
||||||
|
let undoVisible = $state(false);
|
||||||
|
let undoMessage = $state('');
|
||||||
|
|
||||||
function handleSelectDay(day: string) {
|
function handleSelectDay(day: string) {
|
||||||
selectedDay = day;
|
selectedDay = day;
|
||||||
|
panelState = { kind: 'day-detail', date: day };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||||
@@ -51,6 +91,52 @@
|
|||||||
|
|
||||||
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||||||
|
// Capture date before modifying panel state
|
||||||
|
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
||||||
|
|
||||||
|
// Close pickers
|
||||||
|
pickerOpen = false;
|
||||||
|
if (panelState.kind === 'recipe-picker') {
|
||||||
|
panelState = { kind: 'idle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSlot = slotMap[date];
|
||||||
|
|
||||||
|
if (existingSlot?.id) {
|
||||||
|
updPlanId = weekPlan!.id;
|
||||||
|
updSlotId = existingSlot.id;
|
||||||
|
updRecipeId = recipeId;
|
||||||
|
updRecipeName = recipeName;
|
||||||
|
await tick();
|
||||||
|
updateSlotFormEl.requestSubmit();
|
||||||
|
} else {
|
||||||
|
addPlanId = weekPlan!.id;
|
||||||
|
addSlotDate = date;
|
||||||
|
addRecipeId = recipeId;
|
||||||
|
addRecipeName = recipeName;
|
||||||
|
await tick();
|
||||||
|
addSlotFormEl.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
undoVisible = false;
|
||||||
|
deleteSlotFormEl.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanelToIdle() {
|
||||||
|
panelState = { kind: 'idle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanelToDayDetail() {
|
||||||
|
if (panelState.kind === 'recipe-picker') {
|
||||||
|
panelState = { kind: 'day-detail', date: panelState.date };
|
||||||
|
} else {
|
||||||
|
panelState = { kind: 'idle' };
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mobile & Tablet: vertical stack -->
|
<!-- Mobile & Tablet: vertical stack -->
|
||||||
@@ -76,12 +162,13 @@
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
{#if isPlanner}
|
{#if isPlanner}
|
||||||
<a
|
<button
|
||||||
href="/planner/suggestions?day={selectedDay}"
|
type="button"
|
||||||
|
onclick={() => (pickerOpen = true)}
|
||||||
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"
|
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
|
+ Gericht
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -128,7 +215,7 @@
|
|||||||
Restliche Woche
|
Restliche Woche
|
||||||
</h2>
|
</h2>
|
||||||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||||||
{#each remainingSlotsWithMeal as slot}
|
{#each remainingSlotsWithMeal as slot (slot.slotDate)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => handleSelectDay(slot.slotDate)}
|
onclick={() => handleSelectDay(slot.slotDate)}
|
||||||
@@ -166,6 +253,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mobile RecipePicker in BottomSheet -->
|
||||||
|
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
||||||
|
<RecipePicker
|
||||||
|
planId={weekPlan?.id ?? ''}
|
||||||
|
date={selectedDay}
|
||||||
|
dateLabel={formatDayLabel(selectedDay)}
|
||||||
|
currentVarietyScore={varietyScore?.score ?? 0}
|
||||||
|
suggestions={[]}
|
||||||
|
allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []}
|
||||||
|
onpick={handleRecipePick}
|
||||||
|
/>
|
||||||
|
</BottomSheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: 3-panel layout -->
|
<!-- Desktop: 3-panel layout -->
|
||||||
@@ -200,12 +300,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if isPlanner}
|
{#if isPlanner}
|
||||||
<a
|
<button
|
||||||
href="/planner/suggestions?day={selectedDay}"
|
type="button"
|
||||||
|
onclick={() => (panelState = { kind: 'recipe-picker', date: 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"
|
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
|
+ Gericht hinzufügen
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -240,7 +341,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-7 gap-[8px]">
|
<div class="grid grid-cols-7 gap-[8px]">
|
||||||
{#each days as day}
|
{#each days as day (day)}
|
||||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||||
{@const isTodayDay = day === today}
|
{@const isTodayDay = day === today}
|
||||||
{@const isSelectedDay = day === selectedDay}
|
{@const isSelectedDay = day === selectedDay}
|
||||||
@@ -266,7 +367,12 @@
|
|||||||
<!-- Meal tile -->
|
<!-- Meal tile -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => handleSelectDay(day)}
|
onclick={() => {
|
||||||
|
handleSelectDay(day);
|
||||||
|
if (!slot.recipe && isPlanner) {
|
||||||
|
panelState = { kind: 'recipe-picker', date: day };
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(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)]
|
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 && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
||||||
@@ -293,57 +399,187 @@
|
|||||||
|
|
||||||
<!-- Right detail panel -->
|
<!-- 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">
|
<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">
|
{#if panelState.kind === 'idle'}
|
||||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
<div class="flex flex-1 flex-col items-center justify-center">
|
||||||
{formatDayLabel(selectedDay)} · Abendessen
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selectedSlot?.recipe}
|
{:else if panelState.kind === 'day-detail'}
|
||||||
|
{@const detailDate = panelState.date}
|
||||||
|
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
||||||
|
|
||||||
|
<!-- Panel header with close button -->
|
||||||
|
<div class="mb-3 flex items-start justify-between">
|
||||||
|
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
{formatDayLabel(detailDate)} · Abendessen
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closePanelToIdle}
|
||||||
|
aria-label="Panel schließen"
|
||||||
|
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if detailSlot.recipe}
|
||||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||||
{selectedSlot.recipe.name}
|
{detailSlot.recipe.name}
|
||||||
</h2>
|
</h2>
|
||||||
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin}
|
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
|
||||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
<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(' · ')}
|
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- View and cook actions shown to all roles -->
|
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<a
|
<a
|
||||||
href="/recipes/{selectedSlot.recipe.id}"
|
href="/recipes/{detailSlot.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)]"
|
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
|
Rezept ansehen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/recipes/{selectedSlot.recipe.id}/cook"
|
href="/recipes/{detailSlot.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"
|
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
|
Koch-Modus
|
||||||
</a>
|
</a>
|
||||||
<!-- Swap action: planner only -->
|
|
||||||
{#if isPlanner}
|
{#if isPlanner}
|
||||||
<a
|
<button
|
||||||
href="/planner/suggestions?day={selectedDay}"
|
type="button"
|
||||||
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)]"
|
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||||
|
class="block w-full 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
|
Gericht tauschen
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||||
{#if isPlanner}
|
{#if isPlanner}
|
||||||
<a
|
<button
|
||||||
href="/planner/suggestions?day={selectedDay}"
|
type="button"
|
||||||
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)]"
|
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||||
|
class="mt-3 block w-full 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
|
+ Gericht wählen
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else if panelState.kind === 'recipe-picker'}
|
||||||
|
{@const pickerDate = panelState.date}
|
||||||
|
|
||||||
|
<!-- Panel header with back/close button -->
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Rezept wählen
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closePanelToDayDetail}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||||
|
<RecipePicker
|
||||||
|
planId={weekPlan?.id ?? ''}
|
||||||
|
date={pickerDate}
|
||||||
|
dateLabel={formatDayLabel(pickerDate)}
|
||||||
|
currentVarietyScore={varietyScore?.score ?? 0}
|
||||||
|
suggestions={[]}
|
||||||
|
allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []}
|
||||||
|
onpick={handleRecipePick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden forms for slot mutations -->
|
||||||
|
<div class="hidden">
|
||||||
|
<!-- Add slot -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addSlot"
|
||||||
|
bind:this={addSlotFormEl}
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', addPlanId);
|
||||||
|
formData.set('slotDate', addSlotDate);
|
||||||
|
formData.set('recipeId', addRecipeId);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && result.data?.success) {
|
||||||
|
delPlanId = addPlanId;
|
||||||
|
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||||||
|
undoMessage = `${addRecipeName} hinzugefügt`;
|
||||||
|
undoVisible = true;
|
||||||
|
}
|
||||||
|
await update({ reset: false });
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={addPlanId} />
|
||||||
|
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||||||
|
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Update slot -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateSlot"
|
||||||
|
bind:this={updateSlotFormEl}
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', updPlanId);
|
||||||
|
formData.set('slotId', updSlotId);
|
||||||
|
formData.set('recipeId', updRecipeId);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && result.data?.success) {
|
||||||
|
delPlanId = updPlanId;
|
||||||
|
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||||||
|
undoMessage = `${updRecipeName} eingetragen`;
|
||||||
|
undoVisible = true;
|
||||||
|
}
|
||||||
|
await update({ reset: false });
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={updPlanId} />
|
||||||
|
<input type="hidden" name="slotId" value={updSlotId} />
|
||||||
|
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Delete slot (for undo) -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteSlot"
|
||||||
|
bind:this={deleteSlotFormEl}
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', delPlanId);
|
||||||
|
formData.set('slotId', delSlotId);
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={delPlanId} />
|
||||||
|
<input type="hidden" name="slotId" value={delSlotId} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Undo toast -->
|
||||||
|
<UndoBar
|
||||||
|
visible={undoVisible}
|
||||||
|
message={undoMessage}
|
||||||
|
onundo={handleUndo}
|
||||||
|
ondismiss={() => (undoVisible = false)}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ vi.mock('$env/dynamic/private', () => ({
|
|||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
const mockPost = vi.fn();
|
const mockPost = vi.fn();
|
||||||
|
const mockPatch = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
vi.mock('$lib/server/api', () => ({
|
vi.mock('$lib/server/api', () => ({
|
||||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('planner page — load', () => {
|
describe('planner page — load', () => {
|
||||||
@@ -193,3 +195,89 @@ describe('planner page — variety score partial failure', () => {
|
|||||||
expect(result.varietyScore).toBeNull();
|
expect(result.varietyScore).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planner page — slot actions', () => {
|
||||||
|
let actions: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
mockDelete.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
actions = mod.actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotDate', '2026-04-01');
|
||||||
|
formData.set('recipeId', 'r1');
|
||||||
|
mockPost.mockResolvedValue({ data: { id: 's1', slotDate: '2026-04-01' }, error: undefined });
|
||||||
|
const result = await actions.addSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{id}/slots',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { id: 'plan-1' } },
|
||||||
|
body: { slotDate: '2026-04-01', recipeId: 'r1' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.slot?.id).toBe('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addSlot returns failure when API errors', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotDate', '2026-04-01');
|
||||||
|
formData.set('recipeId', 'r1');
|
||||||
|
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||||
|
const result = await actions.addSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotId', 's1');
|
||||||
|
formData.set('recipeId', 'r2');
|
||||||
|
mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||||
|
const result = await actions.updateSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { planId: 'plan-1', slotId: 's1' } },
|
||||||
|
body: { recipeId: 'r2' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotId', 's1');
|
||||||
|
mockDelete.mockResolvedValue({ error: undefined });
|
||||||
|
const result = await actions.deleteSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { planId: 'plan-1', slotId: 's1' } }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user