fix(planner): address all PR review blockers
- Fix logic bug `{#if !isPlanner === false}` - view/cook buttons now visible for all roles, swap only for planner
- Convert Tauschen from dead button to link with suggestions href
- Add week.ts unit tests (23 tests covering getWeekStart Sunday edge case, prevWeek/nextWeek, weekDays, isToday, formatWeekRange)
- Fix isToday to use UTC consistently (.toISOString().slice(0,10)) instead of local date
- Add server-side role guard to createPlan action (403 for members)
- Add weekStart format validation in createPlan action
- Add isSelected prop to DayMealCard with green treatment
- Make variety banner sticky on mobile (always visible per spec)
- Add day name abbreviation above date badge in desktop column headers
- Remove placeholder Navigation text from desktop sidebar
- Add aria-label to desktop empty tile buttons
- Add variety score partial failure test, multiple overlaps test, WeekStrip today+selected test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,10 +15,12 @@
|
||||
let {
|
||||
slot,
|
||||
isToday = false,
|
||||
isSelected = false,
|
||||
readonly = false
|
||||
}: {
|
||||
slot: Slot;
|
||||
isToday?: boolean;
|
||||
isSelected?: boolean;
|
||||
readonly?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -30,13 +32,21 @@
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
let borderClass = $derived(
|
||||
isToday
|
||||
? 'border-[var(--yellow)] bg-[var(--yellow-tint)]'
|
||||
: isSelected
|
||||
? 'border-[var(--green)] bg-[var(--green-tint)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors
|
||||
{isToday ? 'border-[var(--yellow)] bg-[var(--yellow-tint)]' : 'border-[var(--color-border)] bg-[var(--color-surface)]'}"
|
||||
data-selected={isSelected}
|
||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
@@ -54,19 +64,19 @@
|
||||
>
|
||||
Jetzt kochen
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
<a
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if !readonly}
|
||||
<a
|
||||
href="/planner/suggestions"
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
|
||||
@@ -14,16 +14,22 @@ describe('DayMealCard', () => {
|
||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Cook now and Swap buttons when not readonly', () => {
|
||||
it('shows Cook now and Tauschen links when not readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides action buttons when readonly', () => {
|
||||
it('Tauschen link navigates to suggestions for the slot day', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
const link = screen.getByRole('link', { name: /Tauschen/i });
|
||||
expect(link.getAttribute('href')).toContain('2026-03-30');
|
||||
});
|
||||
|
||||
it('hides action links when readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: true } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('applies today styling when isToday is true', () => {
|
||||
@@ -32,6 +38,12 @@ describe('DayMealCard', () => {
|
||||
expect(card.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected styling when isSelected is true and not today', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, isSelected: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders empty state when slot has no recipe', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/Kein Gericht/i)).toBeTruthy();
|
||||
@@ -42,4 +54,10 @@ describe('DayMealCard', () => {
|
||||
expect(screen.getByText(/30 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('empty state shows add link with suggestions href', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
const link = screen.getByRole('link', { name: /Gericht hinzufügen/i });
|
||||
expect(link.getAttribute('href')).toContain('2026-03-31');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,4 +53,21 @@ describe('VarietyScoreCard', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
|
||||
expect(screen.getByText('0')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders multiple ingredient overlap warnings', () => {
|
||||
render(VarietyScoreCard, {
|
||||
props: {
|
||||
...baseProps,
|
||||
ingredientOverlaps: [
|
||||
{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] },
|
||||
{ ingredientName: 'Zwiebel', days: ['2026-03-30', '2026-04-01', '2026-04-02'] },
|
||||
{ ingredientName: 'Knoblauch', days: ['2026-03-31', '2026-04-01'] }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Tomate/)).toBeTruthy();
|
||||
expect(screen.getByText(/Zwiebel/)).toBeTruthy();
|
||||
expect(screen.getByText(/Knoblauch/)).toBeTruthy();
|
||||
expect(screen.getByText(/3 Mahlzeiten/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,13 @@ describe('WeekStrip', () => {
|
||||
expect(dot.getAttribute('data-has-meal')).toBe('false');
|
||||
});
|
||||
|
||||
it('when today equals selected day, both data-today and data-selected are true', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-04-03', today: '2026-04-03' } });
|
||||
const chip = screen.getByTestId('day-chip-2026-04-03');
|
||||
expect(chip.getAttribute('data-today')).toBe('true');
|
||||
expect(chip.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('calls onselectDay callback when chip is clicked', async () => {
|
||||
let emitted: string | null = null;
|
||||
render(WeekStrip, {
|
||||
|
||||
146
frontend/src/lib/planner/week.test.ts
Normal file
146
frontend/src/lib/planner/week.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getWeekStart,
|
||||
prevWeek,
|
||||
nextWeek,
|
||||
weekDays,
|
||||
isToday,
|
||||
formatWeekRange,
|
||||
formatDayLabel
|
||||
} from './week';
|
||||
|
||||
describe('getWeekStart', () => {
|
||||
it('returns Monday for a Monday input', () => {
|
||||
// 2026-03-30 is a Monday
|
||||
const d = new Date('2026-03-30T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Wednesday input', () => {
|
||||
// 2026-04-01 is a Wednesday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-01T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Sunday input (edge case — goes back 6 days)', () => {
|
||||
// 2026-04-05 is a Sunday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-05T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Saturday input', () => {
|
||||
// 2026-04-04 is a Saturday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-04T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary correctly (Dec 28 2025 → week starts Dec 22 2025)', () => {
|
||||
const d = new Date('2025-12-28T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2025-12-22');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevWeek', () => {
|
||||
it('returns the Monday 7 days before', () => {
|
||||
expect(prevWeek('2026-03-30')).toBe('2026-03-23');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(prevWeek('2026-04-06')).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(prevWeek('2026-01-05')).toBe('2025-12-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextWeek', () => {
|
||||
it('returns the Monday 7 days after', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(nextWeek('2025-12-29')).toBe('2026-01-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('returns exactly 7 dates', () => {
|
||||
expect(weekDays('2026-03-30')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('starts on the given weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[0]).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('ends 6 days after weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[6]).toBe('2026-04-05');
|
||||
});
|
||||
|
||||
it('has consecutive daily dates', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
for (let i = 1; i < 7; i++) {
|
||||
const prev = new Date(days[i - 1] + 'T00:00:00Z');
|
||||
const curr = new Date(days[i] + 'T00:00:00Z');
|
||||
expect(curr.getTime() - prev.getTime()).toBe(86400000);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles month boundary correctly', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[1]).toBe('2026-03-31');
|
||||
expect(days[2]).toBe('2026-04-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToday', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns true for today (UTC)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-03')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for yesterday', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-02')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for tomorrow', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-04')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWeekRange', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatWeekRange('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains both start and end month info', () => {
|
||||
const range = formatWeekRange('2026-03-30');
|
||||
// Start is March 30, end is April 5 — range should span both months
|
||||
expect(range).toContain('–');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDayLabel', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains a comma separator', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toContain(',');
|
||||
});
|
||||
});
|
||||
@@ -67,11 +67,11 @@ export function formatDayFull(dateStr: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if dateStr is today (local time).
|
||||
* Returns true if dateStr is today (UTC date).
|
||||
* Uses UTC consistently with all other date functions in this module.
|
||||
*/
|
||||
export function isToday(dateStr: string): boolean {
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
return dateStr === todayStr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user