diff --git a/client/src/store/vacayStore.test.ts b/client/src/store/vacayStore.test.ts index ef8536eb6..4c8b558ee 100644 --- a/client/src/store/vacayStore.test.ts +++ b/client/src/store/vacayStore.test.ts @@ -1,4 +1,4 @@ -// FE-STORE-VCY-001 to FE-STORE-VCY-018 +// FE-STORE-VCY-001 to FE-STORE-VCY-023 // Complements tests/unit/stores/vacayStore.test.ts: optimistic toggles and their // rollbacks, the leave-year window plumbing and the holiday-marker merge rules. import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -366,3 +366,60 @@ describe('vacayStore holiday markers', () => { expect(useVacayStore.getState().holidays).toEqual({}); }); }); + +describe('vacayStore year selection', () => { + it('FE-STORE-VCY-019: the first load opens on the current period, not on the highest year', async () => { + const current = new Date().getFullYear(); + server.use(http.get('/api/addons/vacay/years', () => + HttpResponse.json({ years: [current - 1, current, current + 4] }))); + + await useVacayStore.getState().loadYears(); + + expect(useVacayStore.getState().selectedYear).toBe(current); + }); + + it('FE-STORE-VCY-020: with no year for the current period the closest one is opened', async () => { + const current = new Date().getFullYear(); + server.use(http.get('/api/addons/vacay/years', () => + HttpResponse.json({ years: [current - 3, current + 1] }))); + + await useVacayStore.getState().loadYears(); + + expect(useVacayStore.getState().selectedYear).toBe(current + 1); + }); + + it('FE-STORE-VCY-021: a reload keeps the year the viewer is looking at', async () => { + const current = new Date().getFullYear(); + useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 }); + server.use(http.get('/api/addons/vacay/years', () => + HttpResponse.json({ years: [current, current + 1] }))); + + await useVacayStore.getState().loadYears(); + + expect(useVacayStore.getState().selectedYear).toBe(current + 1); + }); + + it('FE-STORE-VCY-022: a reload that lost the selected year falls back to the current period', async () => { + const current = new Date().getFullYear(); + useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 }); + server.use(http.get('/api/addons/vacay/years', () => + HttpResponse.json({ years: [current] }))); + + await useVacayStore.getState().loadYears(); + + expect(useVacayStore.getState().selectedYear).toBe(current); + }); + + it('FE-STORE-VCY-023: removing the open year drops back to the current period', async () => { + const current = new Date().getFullYear(); + useVacayStore.setState({ years: [current, current + 1], selectedYear: current + 1 }); + server.use( + http.delete('/api/addons/vacay/years/:year', () => HttpResponse.json({ years: [current] })), + http.get('/api/addons/vacay/stats/:year', () => HttpResponse.json({ stats: [] })), + ); + + await useVacayStore.getState().removeYear(current + 1); + + expect(useVacayStore.getState().selectedYear).toBe(current); + }); +}); diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index bebc647cd..3ed6161cd 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -7,7 +7,7 @@ import type { VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar, VacayYearSettings, } from '../types' import { isSchoolHolidayCountrySupported } from '../vacay/schoolHolidayCountries' -import { DEFAULT_YEAR_SETTINGS, currentPeriodYear, inGridWindow, windowCalendarYears } from '../vacay/yearWindow' +import { DEFAULT_YEAR_SETTINGS, defaultPeriodYear, inGridWindow, windowCalendarYears } from '../vacay/yearWindow' import type { VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest, VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest, @@ -302,12 +302,15 @@ export const useVacayStore = create((set, get) => ({ loadYears: async () => { const data = await api.getYears() - set({ years: data.years }) - if (data.years.length > 0) { - set({ selectedYear: data.years[data.years.length - 1] }) - } else { - set({ selectedYear: currentPeriodYear(get().yearSettings) }) - } + // Opening Vacay lands on the period we are in, not on whichever year sorts + // last. A reload (invite, live settings update) keeps the year the viewer is + // looking at as long as it still exists — only the first load picks one. + const previous = get().selectedYear + const keepSelection = get().years.length > 0 && data.years.includes(previous) + set({ + years: data.years, + selectedYear: keepSelection ? previous : defaultPeriodYear(data.years, get().yearSettings), + }) }, addYear: async (year: number) => { @@ -320,9 +323,7 @@ export const useVacayStore = create((set, get) => ({ const data = await api.removeYear(year) const updates: Partial = { years: data.years } if (get().selectedYear === year) { - updates.selectedYear = data.years.length > 0 - ? data.years[data.years.length - 1] - : currentPeriodYear(get().yearSettings) + updates.selectedYear = defaultPeriodYear(data.years, get().yearSettings) } set(updates) await get().loadStats() diff --git a/client/src/vacay/yearWindow.test.ts b/client/src/vacay/yearWindow.test.ts index 1e72ea724..d3a5cf6d4 100644 --- a/client/src/vacay/yearWindow.test.ts +++ b/client/src/vacay/yearWindow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { DEFAULT_YEAR_SETTINGS, currentPeriodYear, + defaultPeriodYear, gridWindow, inGridWindow, resolveYearWindow, @@ -125,3 +126,31 @@ describe('currentPeriodYear', () => { expect(currentPeriodYear(fiscal(7), new Date(2026, 6, 1))).toBe(2026) }) }) + +describe('defaultPeriodYear', () => { + // FE-VACAY-YEARWINDOW-014 + it('picks the current period when the plan has it', () => { + const today = new Date(2026, 2, 15) + expect(defaultPeriodYear([2024, 2025, 2026, 2030], DEFAULT_YEAR_SETTINGS, today)).toBe(2026) + }) + + // FE-VACAY-YEARWINDOW-015 + it('falls back to the closest year and prefers the coming one on a tie', () => { + const today = new Date(2026, 2, 15) + expect(defaultPeriodYear([2022, 2024], DEFAULT_YEAR_SETTINGS, today)).toBe(2024) + expect(defaultPeriodYear([2028, 2030], DEFAULT_YEAR_SETTINGS, today)).toBe(2028) + expect(defaultPeriodYear([2024, 2028], DEFAULT_YEAR_SETTINGS, today)).toBe(2028) + }) + + // FE-VACAY-YEARWINDOW-016 + it('is the current period for an empty plan', () => { + expect(defaultPeriodYear([], DEFAULT_YEAR_SETTINGS, new Date(2026, 2, 15))).toBe(2026) + }) + + // FE-VACAY-YEARWINDOW-017 + it('follows a shifted leave year rather than the calendar year', () => { + const today = new Date(2026, 2, 15) + expect(defaultPeriodYear([2025, 2026], fiscal(7), today)).toBe(2025) + expect(defaultPeriodYear([2026], fiscal(7), today)).toBe(2026) + }) +}) diff --git a/client/src/vacay/yearWindow.ts b/client/src/vacay/yearWindow.ts index 5d4bf770a..bad8e071d 100644 --- a/client/src/vacay/yearWindow.ts +++ b/client/src/vacay/yearWindow.ts @@ -100,3 +100,19 @@ export function currentPeriodYear(s: VacayYearSettings = DEFAULT_YEAR_SETTINGS, const iso = `${year}-${pad2(today.getMonth() + 1)}-${pad2(today.getDate())}` return iso < resolveYearWindow(year, s).start ? year - 1 : year } + +/** + * The year Vacay opens on. The period we are actually in wins; adding 2030 to plan + * ahead should not make 2030 the year the page lands on from then on. When that + * period has no year yet, the closest one the plan does have is used, and a tie + * goes to the later year because planning looks forward. + */ +export function defaultPeriodYear(years: number[], s: VacayYearSettings = DEFAULT_YEAR_SETTINGS, today = new Date()): number { + const current = currentPeriodYear(s, today) + if (years.length === 0 || years.includes(current)) return current + return years.reduce((best, year) => { + const distance = Math.abs(year - current) + const bestDistance = Math.abs(best - current) + return distance < bestDistance || (distance === bestDistance && year > best) ? year : best + }) +}