From 18371542256cb5f1b67450195692c7806d45cf64 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 14:18:14 +0200 Subject: [PATCH] Jump to today, and open a period on the dates it actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month can now be reached in one click instead of paging back through the year. The button sits left of the month dropdown and sizes to its label, so Idag and Today both fit. Opening a period showed the wish's first year rather than the occurrence that was clicked: a 2028 week of a yearly wish opened on 2026, and a flexible period opened on its bounding range instead of where the planner put it. The popover now prefills from the clicked band's own dates. Saving a yearly wish keeps its anchor year, since the edited dates describe the recurring pattern and not one occurrence — moving a single year is a separate choice that does not exist yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- .../calendar/PeriodPopover.test.tsx | 59 ++++++++++++- .../src/components/calendar/PeriodPopover.tsx | 22 ++++- frontend/src/i18n/en/calendar.ts | 1 + frontend/src/i18n/sv/calendar.ts | 1 + frontend/src/pages/Calendar.test.tsx | 85 +++++++++++++++++++ frontend/src/pages/Calendar.tsx | 22 ++++- frontend/src/pages/calendar.css | 8 ++ 7 files changed, 191 insertions(+), 7 deletions(-) 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 { ←

+