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 {
←
+