diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 9988ceb..1d6e630 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -86,7 +86,13 @@ describe('PeriodPopover', () => { }) it('prefills name/dates/parent/repeat from the wish', () => { - render() + // startDate/endDate here are the clicked band's real placed dates + // (the 2028 occurrence, which happens to equal the wish's first + // occurrence) — see the dedicated "later-year occurrence" tests below + // for the case where they diverge from `wish.firstYear`. + render( + , + ) expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar') expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15') expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26') @@ -94,6 +100,12 @@ describe('PeriodPopover', () => { expect(screen.getByTestId('period-repeat-yearly')).toBeChecked() }) + it('falls back to the wish\'s window-derived dates when no placed dates are supplied', () => { + render() + expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15') + expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26') + }) + it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => { const jul = makeLeaveWish({ windowStartMonth: 12, @@ -102,11 +114,54 @@ describe('PeriodPopover', () => { windowEndDay: 16, firstYear: 2028, }) - render() + render() expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20') expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16') }) + it('prefills the clicked later-year occurrence\'s dates, not the wish\'s first-occurrence window', () => { + // The wish was first taken in 2026; the user clicked its 2028 band. + const yearlyFrom2026 = makeLeaveWish({ + windowStartMonth: 6, + windowStartDay: 15, + windowEndMonth: 7, + windowEndDay: 26, + firstYear: 2026, + recurrence: 'yearly', + }) + render( + , + ) + expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15') + expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26') + // Non-date fields still come from the wish itself, unaffected by which + // occurrence was clicked. + expect(screen.getByTestId('period-repeat-yearly')).toBeChecked() + expect(screen.getByTestId('period-parent-select')).toHaveValue('either') + }) + + it('prefills a flexible wish\'s actual placed dates, not its bounding window', () => { + // A flexible/wrapping wish whose *window* spans Dec 20 - Jan 16, but + // the allocator actually placed this occurrence a few days later. + const flexible = makeLeaveWish({ + windowStartMonth: 12, + windowStartDay: 20, + windowEndMonth: 1, + windowEndDay: 16, + firstYear: 2028, + }) + render( + , + ) + expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-27') + expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-10') + }) + it('shows "until age 4" checked when lastYear is null', () => { render() expect(screen.getByTestId('period-until-age4')).toBeChecked() diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index 2a3f7e2..f11b450 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -64,13 +64,27 @@ function parseISO(date: string): { year: number; month: number; day: number } { return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 } } -function formStateFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): FormState { +function formStateFromWish( + wish: LeaveWish, + startDate: string, + endDate: string, + t: (key: TranslationKey) => string, +): FormState { + // Prefer the clicked band's actual placed dates (the `startDate`/`endDate` + // props — see `PeriodPopoverProps`'s doc comment) over recomputing from + // the wish's static recurring window. `wish.firstYear` + the window only + // describes the *first* occurrence; clicking a later-year occurrence of a + // yearly wish (or any occurrence of a flexible wish, whose placement can + // drift inside its bounding range) needs the real placed dates, not the + // anchor year's. Fall back to the window-derived dates if the caller + // didn't supply real placed dates (defensive — callers are expected to + // always pass them). const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay) const endYear = wraps ? wish.firstYear + 1 : wish.firstYear return { name: resolveWishName(wish, t), - startDate: formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay), - endDate: formatISO(endYear, wish.windowEndMonth, wish.windowEndDay), + startDate: startDate || formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay), + endDate: endDate || formatISO(endYear, wish.windowEndMonth, wish.windowEndDay), parentMode: wish.parentMode, parentPersonalDataUuid: wish.parentPersonalDataUuid, recurrence: wish.recurrence, @@ -105,7 +119,7 @@ export function PeriodPopover({ useEffect(() => { if (wish) { - setForm(formStateFromWish(wish, t)) + setForm(formStateFromWish(wish, startDate, endDate, t)) } else { setForm({ name: '', diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 9dca834..44b4315 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -21,6 +21,7 @@ export const calendar = { 'calendar.partner': 'Partner', 'calendar.prevMonthAria': 'Previous month', 'calendar.saveError': 'Failed to save the planned days', + 'calendar.today': 'Today', 'calendar.weekdayFri': 'Fri', 'calendar.weekdayMon': 'Mon', 'calendar.weekdaySat': 'Sat', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index 8c45740..65fdd6f 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -21,6 +21,7 @@ export const calendar = { 'calendar.partner': 'Partner', 'calendar.prevMonthAria': 'Föregående månad', 'calendar.saveError': 'Det gick inte att spara de planerade dagarna', + 'calendar.today': 'Idag', 'calendar.weekdayFri': 'Fre', 'calendar.weekdayMon': 'Mån', 'calendar.weekdaySat': 'Lör', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 93def6e..025343d 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -134,6 +134,22 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({ > save + @@ -309,6 +325,23 @@ describe('Calendar', () => { expect(yearSelect.value).toBe('2035') }) + it('Today button jumps back to the current month/year after navigating away', () => { + render() + const title = screen.getByRole('heading', { level: 2 }) + const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement + const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement + + fireEvent.change(monthSelect, { target: { value: '5' } }) + fireEvent.change(yearSelect, { target: { value: '2035' } }) + expect(yearSelect.value).toBe('2035') + + fireEvent.click(screen.getByText('Idag')) + + const today = new Date() + expect(monthSelect.value).toBe(String(today.getMonth())) + expect(yearSelect.value).toBe(String(today.getFullYear())) + }) + it('year dropdown is derived from the calendar window\'s minYear/maxYear', () => { vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }), @@ -640,6 +673,58 @@ describe('Calendar', () => { expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument() }) + it('saving a later-year occurrence of a yearly wish preserves the original firstYear', async () => { + // The wish was first taken in 2026 (the fixture default); the band + // clicked here is its 2028 occurrence. Writing `firstYear: 2028` back + // would silently drop the 2026/2027 occurrences — see the comment on + // `handleSavePeriod` in Calendar.tsx. + const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2026, recurrence: 'yearly' }) + const update = vi.fn().mockResolvedValue(wish) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams]) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2028-06-15') + fireEvent.click(screen.getByTestId('period-save')) + + await waitFor(() => + expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2026 })), + ) + }) + + it('switching a later-year occurrence to "once" uses that occurrence\'s year as the single firstYear', async () => { + // Unlike the yearly case above, there is exactly one occurrence once + // recurrence becomes 'once' — its year is meaningful and must be the + // edited/clicked date's year, not the wish's original anchor year. + const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2026, recurrence: 'yearly' }) + const update = vi.fn().mockResolvedValue(wish) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams]) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-once')) + + await waitFor(() => + expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2028, recurrence: 'once' })), + ) + }) + it('deleting a wish-linked period removes the wish and re-syncs', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index b567794..38c0f15 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -591,6 +591,19 @@ export function Calendar(): ReactNode { const start = splitISODate(payload.startDate) const end = splitISODate(payload.endDate) + // The popover now prefills from the *clicked* occurrence's real placed + // dates (see PeriodPopover's `formStateFromWish`), so `payload.startDate` + // may carry a later year than the wish's `firstYear` — e.g. clicking a + // 2028 occurrence of a wish first taken in 2026. For a yearly wish, the + // anchor year must stay put: writing `firstYear: 2028` here would + // silently drop the 2026/2027 occurrences, and this popover has no + // "this occurrence only" concept (yet — a later change adds that choice + // explicitly). So only the recurring window's month/day come from the + // edited dates; the anchor year is preserved unless recurrence is being + // set to 'once', where there is exactly one occurrence and its year + // *is* the edited date's year. + const firstYear = payload.recurrence === 'yearly' ? previousWish.firstYear : start.year + try { setIsPeriodSaving(true) setPeriodError(null) @@ -601,7 +614,7 @@ export function Calendar(): ReactNode { windowStartDay: start.day, windowEndMonth: end.month, windowEndDay: end.day, - firstYear: start.year, + firstYear, recurrence: payload.recurrence, lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null, parentMode: payload.parentMode, @@ -799,6 +812,13 @@ export function Calendar(): ReactNode { ←

+