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:
2026-04-08 22:44:29 +02:00
parent 25c575c167
commit ba41f6984b
2 changed files with 302 additions and 0 deletions

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

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import DayPicker from './DayPicker.svelte';
const weekStart = '2026-03-30'; // Monday
const today = '2026-04-01'; // Wednesday
// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled
const slots = [
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } },
{ id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } },
{ id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } },
{ id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } },
{ id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } }
];
const baseProps = {
recipeName: 'Mushroom Risotto',
recipeId: 'recipe-42',
planId: 'plan-1',
weekStart,
today,
slots,
onconfirm: vi.fn(),
onweekchange: vi.fn()
};
describe('DayPicker', () => {
it('shows recipe name in header', () => {
render(DayPicker, { props: baseProps });
expect(screen.getByText('Mushroom Risotto')).toBeTruthy();
});
it('shows 7 day chips', () => {
render(DayPicker, { props: baseProps });
const chips = screen.getAllByTestId(/^chip-/);
expect(chips).toHaveLength(7);
});
it('marks empty slot chips with data-state="empty"', () => {
render(DayPicker, { props: baseProps });
// Do (2026-04-03) and Sa (2026-04-05) are empty
const doChip = screen.getByTestId('chip-2026-04-03');
expect(doChip.getAttribute('data-state')).toBe('empty');
});
it('marks filled slot chips with data-state="filled"', () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
expect(moChip.getAttribute('data-state')).toBe('filled');
});
it('marks today chip with data-state="today"', () => {
render(DayPicker, { props: baseProps });
const todayChip = screen.getByTestId('chip-2026-04-01');
expect(todayChip.getAttribute('data-state')).toBe('today');
});
it('selecting an empty chip changes its state to sel-empty', async () => {
render(DayPicker, { props: baseProps });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
expect(doChip.getAttribute('data-state')).toBe('sel-empty');
});
it('selecting a filled chip changes its state to sel-filled', async () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
expect(moChip.getAttribute('data-state')).toBe('sel-filled');
});
it('shows replace warning when filled chip is selected', async () => {
render(DayPicker, { props: baseProps });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
expect(screen.getByTestId('replace-warning')).toBeTruthy();
expect(screen.getByText(/Pasta/)).toBeTruthy();
});
it('does not show replace warning when empty chip is selected', async () => {
render(DayPicker, { props: baseProps });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
expect(screen.queryByTestId('replace-warning')).toBeNull();
});
it('confirm button is disabled when no chip is selected', () => {
render(DayPicker, { props: baseProps });
const btn = screen.getByTestId('confirm-btn');
expect(btn.hasAttribute('disabled')).toBe(true);
});
it('calls onconfirm with date and null slotId when empty chip confirmed', async () => {
const onconfirm = vi.fn();
render(DayPicker, { props: { ...baseProps, onconfirm } });
const doChip = screen.getByTestId('chip-2026-04-03');
await userEvent.click(doChip);
const btn = screen.getByTestId('confirm-btn');
await userEvent.click(btn);
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null });
});
it('calls onconfirm with date and slotId when filled chip confirmed', async () => {
const onconfirm = vi.fn();
render(DayPicker, { props: { ...baseProps, onconfirm } });
const moChip = screen.getByTestId('chip-2026-03-30');
await userEvent.click(moChip);
const btn = screen.getByTestId('confirm-btn');
await userEvent.click(btn);
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' });
});
it('shows prev/next week navigation buttons', () => {
render(DayPicker, { props: baseProps });
expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy();
});
it('calls onweekchange with prev week when prev button clicked', async () => {
const onweekchange = vi.fn();
render(DayPicker, { props: { ...baseProps, onweekchange } });
await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ }));
expect(onweekchange).toHaveBeenCalledWith('2026-03-23');
});
it('calls onweekchange with next week when next button clicked', async () => {
const onweekchange = vi.fn();
render(DayPicker, { props: { ...baseProps, onweekchange } });
await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ }));
expect(onweekchange).toHaveBeenCalledWith('2026-04-06');
});
});