@@ -1,14 +1,17 @@
< 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 WeekStrip from '$lib/planner/WeekStrip.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' ;
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
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 ])));
// 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
$effect (() => {
@@ -39,8 +47,40 @@
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 ) {
selectedDay = day ;
panelState = { kind : 'day-detail' , date : day };
}
async function navigateWeek ( direction : 'prev' | 'next' | 'today' ) {
@@ -51,6 +91,52 @@
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 >
<!-- Mobile & Tablet: vertical stack -->
@@ -76,12 +162,13 @@
›
</ button >
{ #if isPlanner }
< a
href = "/planner/suggestions?day= { selectedDay } "
< button
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 "
>
+ Gericht
</ a >
</ button >
{ /if }
</ div >
</ header >
@@ -128,7 +215,7 @@
Restliche Woche
</ h2 >
< 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
type = "button"
onclick = {() => handleSelectDay ( slot . slotDate )}
@@ -166,6 +253,19 @@
{ /if }
</ div >
{ /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 >
<!-- Desktop: 3-panel layout -->
@@ -200,12 +300,13 @@
</ button >
</ div >
{ #if isPlanner }
< a
href = "/planner/suggestions?day= { selectedDay } "
< button
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 "
>
+ Gericht hinzufügen
</ a >
</ button >
{ /if }
</ header >
@@ -240,7 +341,7 @@
</ div >
{ : else }
< 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 isTodayDay = day === today }
{ @const isSelectedDay = day === selectedDay }
@@ -266,7 +367,12 @@
<!-- Meal tile -->
< 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 )} `}
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)]' : ''}
@@ -293,57 +399,187 @@
<!-- 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 panelState.kind === 'idle'}
<div class="flex flex-1 flex-col items-center justify-center ">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</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(' · ') }
{: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>
{ /if }
<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>
<!-- View and cook actions shown to all roles -->
< 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 >
<!-- Swap action: planner only -->
{ #if isPlanner }
{#if detailSlot.recipe}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
{detailSlot.recipe.name}
</h2>
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{[detailSlot.recipe.cookTimeMin ? ` $ { detailSlot . recipe . cookTimeMin } Min ` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ') }
</ p >
{ /if }
< div class = "mt-4 space-y-2" >
< a
href = "/planner/suggestions?day= { selectedDay } "
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)]"
>
Gericht tausc hen
Rezept anse hen
</ a >
< a
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"
>
Koch-Modus
</ a >
{ #if isPlanner }
< button
type = "button"
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
</ button >
{ /if }
</ div >
{ : else }
< p class = "font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]" > Kein Gericht geplant</ p >
{ #if isPlanner }
< button
type = "button"
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
</ button >
{ /if }
</ div >
{ : 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 }
{ :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 >
</ 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 )}
/>