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:
2026-04-03 11:01:17 +02:00
parent 0511a735a5
commit e3f8d8ad73
10 changed files with 976 additions and 1 deletions

View File

@@ -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>