feat(planner): add DayPicker component (C6)
7-chip week strip with 5 slot states, inline replace warning, confirm button, and prev/next week navigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
|
||||
|
||||
interface Slot {
|
||||
id: string;
|
||||
slotDate: string;
|
||||
recipe: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
recipeName,
|
||||
recipeId,
|
||||
planId,
|
||||
weekStart,
|
||||
today,
|
||||
slots = [],
|
||||
onconfirm,
|
||||
onweekchange
|
||||
}: {
|
||||
recipeName: string;
|
||||
recipeId: string;
|
||||
planId: string;
|
||||
weekStart: string;
|
||||
today: string;
|
||||
slots: Slot[];
|
||||
onconfirm: (result: { date: string; slotId: string | null }) => void;
|
||||
onweekchange: (newWeekStart: string) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedDate = $state<string | null>(null);
|
||||
|
||||
const slotMap = $derived(
|
||||
new Map(slots.map((s) => [s.slotDate, s]))
|
||||
);
|
||||
|
||||
const days = $derived(weekDays(weekStart));
|
||||
|
||||
function chipState(date: string): string {
|
||||
const isSelected = selectedDate === date;
|
||||
const slot = slotMap.get(date);
|
||||
const hasFilled = slot?.recipe != null;
|
||||
|
||||
if (isSelected) {
|
||||
return hasFilled ? 'sel-filled' : 'sel-empty';
|
||||
}
|
||||
if (date === today) return 'today';
|
||||
return hasFilled ? 'filled' : 'empty';
|
||||
}
|
||||
|
||||
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
|
||||
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
|
||||
const existingSlotId = $derived(selectedSlot?.id ?? null);
|
||||
|
||||
function chipStyle(state: string): string {
|
||||
switch (state) {
|
||||
case 'empty':
|
||||
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
|
||||
case 'filled':
|
||||
return 'border-color: var(--color-border); background: var(--color-surface);';
|
||||
case 'today':
|
||||
return 'border-color: var(--yellow); background: var(--yellow-tint);';
|
||||
case 'sel-empty':
|
||||
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
|
||||
case 'sel-filled':
|
||||
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChipClick(date: string) {
|
||||
selectedDate = date;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedDate) return;
|
||||
onconfirm({ date: selectedDate, slotId: existingSlotId });
|
||||
}
|
||||
|
||||
function dayNumber(date: string): string {
|
||||
return date.slice(-2).replace(/^0/, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
Tag wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{recipeName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Week navigation -->
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorherige Woche"
|
||||
onclick={() => onweekchange(prevWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
|
||||
{formatWeekRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächste Woche"
|
||||
onclick={() => onweekchange(nextWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day chips -->
|
||||
<div
|
||||
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
|
||||
>
|
||||
{#each days as date (date)}
|
||||
{@const state = chipState(date)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chip-{date}"
|
||||
data-state={state}
|
||||
onclick={() => handleChipClick(date)}
|
||||
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
|
||||
>
|
||||
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
|
||||
{formatDayAbbr(date, 'narrow')}
|
||||
</span>
|
||||
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
|
||||
{dayNumber(date)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Replace warning -->
|
||||
{#if selectedDate && existingRecipeName}
|
||||
<div
|
||||
data-testid="replace-warning"
|
||||
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
|
||||
>
|
||||
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm button -->
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-btn"
|
||||
disabled={!selectedDate}
|
||||
onclick={handleConfirm}
|
||||
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
|
||||
>
|
||||
Einplanen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user