Files
mealprep/frontend/src/routes/(app)/planner/+page.svelte
Marcel Raddatz 0ae1767649 feat(planner): align tile design with spec
Front face:
- Full dual gradient overlay (dark top 32% → transparent → dark bottom 55%)
- Day abbreviation + date number pill at top of each tile
- Recipe name 13px/weight-300 with text-shadow
- Meta line (cookTimeMin · effort) below name
- Glassmorphism tag pills (protein + cuisine only)
- State rings via box-shadow (yellow for today, green for selected)
- Dimming (opacity 0.42) on non-selected filled tiles

Back face:
- Koch-Modus as green primary button
- Entfernen as red outline (transparent bg)
- All buttons 11px / weight 500

EmptyDayTile: add day header + spec-aligned suggestion list layout
Page: remove external column header (now rendered inside each tile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:29:58 +02:00

602 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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 DesktopDayTile from '$lib/planner/DesktopDayTile.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte';
import RecipePickerDrawer from '$lib/planner/RecipePickerDrawer.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.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 type { Suggestion } from '$lib/planner/types';
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
// Use UTC date string (YYYY-MM-DD) consistently
const today: string = new Date().toISOString().slice(0, 10);
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 ?? []);
// SlotRecipe from the API has no tags — merge from data.recipes by id
const recipeById = $derived(
Object.fromEntries((data.recipes ?? []).map((r: any) => [r.id, r]))
);
let slotMap = $derived(
Object.fromEntries(
slots.map((s: any) => [
s.slotDate,
s.recipe
? { ...s, recipe: { ...s.recipe, tags: recipeById[s.recipe.id]?.tags ?? [] } }
: s
])
)
);
// Default selected day: today if in this week, else first day
// 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(() => {
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: string) => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
let remainingSlotsWithMeal = $derived(remainingSlots.filter((s: any) => s.recipe));
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
let weekRange = $derived(formatWeekRange(weekStart));
// Mobile bottom sheet for RecipePicker (empty slot) and swap flow
let pickerOpen = $state(false);
let actionSheetOpen = $state(false);
let swapSheetOpen = $state(false);
let swapLoading = $state(false);
// Desktop flip tile + drawer state (page-owned per Kai's architecture decision)
let activeSlotId = $state<string | null>(null);
let drawerOpen = $state(false);
let drawerSlotId = $state<string | null>(null);
const activePickerDate = $derived(
pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay
: drawerOpen && drawerSlotId ? drawerSlotId
: null
);
let suggestions: Suggestion[] = $state([]);
let isLoadingSuggestions = $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('');
let undoCallback = $state<(() => void) | null>(null);
$effect(() => {
if (!activePickerDate || !weekPlan?.id) {
suggestions = [];
isLoadingSuggestions = false;
return;
}
const controller = new AbortController();
isLoadingSuggestions = true;
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
.then((r) => r.json())
.then((d) => { suggestions = d.suggestions ?? []; })
.catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
.finally(() => { isLoadingSuggestions = false; });
return () => controller.abort();
});
// Single Escape key handler — priority: drawer > flip (Kai architecture decision)
$effect(() => {
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (drawerOpen) {
drawerOpen = false;
drawerSlotId = null;
} else if (activeSlotId) {
activeSlotId = null;
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
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 });
}
async function handleRecipePick(recipeId: string, recipeName: string) {
// Drawer date takes priority (desktop), then mobile picker date
const date = drawerOpen && drawerSlotId ? drawerSlotId : selectedDay;
// Close all pickers
pickerOpen = false;
drawerOpen = false;
drawerSlotId = null;
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;
undoCallback?.();
}
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
// Capture primitive values immediately — slot may be a reactive proxy that
// becomes stale after the first await (tick flushes state + re-render).
const slotId = slot.id;
const slotDate = slot.slotDate;
const recipeName = slot.recipe?.name ?? '';
const recipeId = slot.recipe?.id ?? '';
if (!slotId || !recipeId) return;
actionSheetOpen = false;
undoCallback = async () => {
addPlanId = weekPlan!.id;
addSlotDate = slotDate;
addRecipeId = recipeId;
addRecipeName = recipeName;
await tick();
addSlotFormEl.requestSubmit();
};
delPlanId = weekPlan!.id;
delSlotId = slotId;
await tick();
deleteSlotFormEl.requestSubmit();
undoMessage = `${recipeName} entfernt`;
undoVisible = true;
}
async function handleSwapPick(recipeId: string, recipeName: string) {
swapLoading = true;
await handleRecipePick(recipeId, recipeName);
swapSheetOpen = false;
swapLoading = false;
}
// Desktop tile handlers
function handleTileFlip(slotId: string) {
activeSlotId = slotId;
}
function handleTileClose() {
activeSlotId = null;
}
function handleTileSwap(slotDate: string) {
activeSlotId = null;
drawerSlotId = slotDate;
drawerOpen = true;
}
async function handleTileRemove(slot: any) {
activeSlotId = null;
await handleRemoveMeal(slot);
}
function handleEmptyTileAdd(slotDate: string) {
drawerSlotId = slotDate;
drawerOpen = true;
}
const drawerSlot = $derived(drawerSlotId ? (slotMap[drawerSlotId] ?? null) : null);
const drawerReplacingMeta = $derived(
drawerSlot?.recipe
? [drawerSlot.recipe.cookTimeMin ? `${drawerSlot.recipe.cookTimeMin} Min` : null, drawerSlot.recipe.effort ?? null]
.filter(Boolean)
.join(' · ')
: null
);
</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="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
<button
type="button"
onclick={() => navigateWeek('next')}
aria-label="Nächste Woche"
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
</button>
{#if isPlanner}
<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
</button>
{/if}
</div>
</header>
<!-- Variety banner: sticky below the top nav so it's always visible (spec requirement) -->
{#if varietyScore}
<div class="sticky z-10 px-4 pt-3" style="top: 56px;">
<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}
isSelected={true}
readonly={!isPlanner}
onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
/>
</div>
<!-- Remaining days list -->
{#if remainingSlotsWithMeal.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 remainingSlotsWithMeal as slot (slot.slotDate)}
<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 truncate font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)]">
{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}
<!-- Mobile: empty slot → RecipePicker -->
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
<RecipePicker
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
/>
</BottomSheet>
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
<MealActionSheet
open={actionSheetOpen}
slot={selectedSlot}
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
oncancel={() => (actionSheetOpen = false)}
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
/>
<!-- Mobile: swap suggestions sheet -->
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
{@const replacingMeta = [
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
selectedSlot.recipe?.effort ?? null
].filter(Boolean).join(' · ')}
<RecipePicker
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
isDisabled={swapLoading}
excludeRecipeId={selectedSlot.recipe?.id}
replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
onpick={handleSwapPick}
/>
</BottomSheet>
</div>
<!-- Desktop: 2-panel layout (sidebar + full-width flip-tile grid) -->
<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="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] 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="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] 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="flex min-h-[40px] items-center rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
>
Heute
</button>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left sidebar (unchanged) -->
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
{#if varietyScore}
<div class="mt-auto">
<VarietyScoreCard
score={varietyScore.score ?? 0}
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
showReviewLink={true}
/>
</div>
{/if}
</aside>
<!-- Main grid — full width, full height -->
<main class="flex-1 overflow-hidden 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 h-full grid-cols-7 gap-2">
{#each days as day (day)}
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today}
{@const isThisTileActive = drawerSlotId === day}
<div class="h-full">
<DesktopDayTile
{slot}
isToday={isTodayDay}
{activeSlotId}
{isPlanner}
{slotMap}
{suggestions}
topSuggestion={isThisTileActive && suggestions.length > 0 ? suggestions[0] : undefined}
onflip={handleTileFlip}
onclose={handleTileClose}
onswap={() => handleTileSwap(day)}
onremove={() => handleTileRemove(slot)}
onaddrecipe={() => handleEmptyTileAdd(day)}
/>
</div>
{/each}
</div>
{/if}
</main>
</div>
<!-- Recipe picker drawer (slide-in from right) -->
<RecipePickerDrawer
open={drawerOpen}
slotDate={drawerSlotId ?? ''}
planId={weekPlan?.id ?? ''}
{suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
onclose={() => { drawerOpen = false; drawerSlotId = null; }}
excludeRecipeId={drawerSlot?.recipe?.id}
replacingRecipe={drawerSlot?.recipe ? { name: drawerSlot.recipe.name, meta: drawerReplacingMeta || undefined } : undefined}
/>
</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) {
const slotId = (result.data as any)?.slot?.id ?? '';
delPlanId = addPlanId;
delSlotId = slotId;
undoCallback = () => deleteSlotFormEl.requestSubmit();
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 ?? '';
undoCallback = () => deleteSlotFormEl.requestSubmit();
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)}
/>