From ba41f6984b5b8a120c54ef6a63a50f6d6fee4c0f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 22:44:29 +0200 Subject: [PATCH] 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 --- frontend/src/lib/planner/DayPicker.svelte | 168 +++++++++++++++++++++ frontend/src/lib/planner/DayPicker.test.ts | 134 ++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 frontend/src/lib/planner/DayPicker.svelte create mode 100644 frontend/src/lib/planner/DayPicker.test.ts diff --git a/frontend/src/lib/planner/DayPicker.svelte b/frontend/src/lib/planner/DayPicker.svelte new file mode 100644 index 0000000..b1a31c7 --- /dev/null +++ b/frontend/src/lib/planner/DayPicker.svelte @@ -0,0 +1,168 @@ + + +
+ +
+

+ Tag wählen +

+

+ {recipeName} +

+
+ + +
+ + + {formatWeekRange(weekStart)} + + +
+ + +
+ {#each days as date (date)} + {@const state = chipState(date)} + + {/each} +
+ + + {#if selectedDate && existingRecipeName} +
+ Ersetzt {existingRecipeName} an diesem Tag. +
+ {/if} + + +
+ +
+
diff --git a/frontend/src/lib/planner/DayPicker.test.ts b/frontend/src/lib/planner/DayPicker.test.ts new file mode 100644 index 0000000..a896e0d --- /dev/null +++ b/frontend/src/lib/planner/DayPicker.test.ts @@ -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'); + }); +});