diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 035322fa1..78e84e44b 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -4,13 +4,15 @@ import { useAuthStore } from './authStore' import type { AxiosResponse } from 'axios' import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar, - VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar, + VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar, VacayYearSettings, } from '../types' import { isSchoolHolidayCountrySupported } from '../vacay/schoolHolidayCountries' +import { DEFAULT_YEAR_SETTINGS, currentPeriodYear, inGridWindow, windowCalendarYears } from '../vacay/yearWindow' import type { VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest, VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest, VacayUpdateStatsRequest, VacayShareRequest, VacayShareUpdateRequest, + VacayYearSettingsRequest, } from '@trek/shared' const ax = apiClient @@ -91,6 +93,8 @@ interface VacayApi { updateShare: (shareId: number, hidden: boolean) => Promise shareAvailableUsers: () => Promise<{ users: VacayUser[] }> getSharedCalendars: (year: number) => Promise<{ calendars: SharedVacayCalendar[] }> + getYearSettings: () => Promise<{ settings: VacayYearSettings }> + updateYearSettings: (data: VacayYearSettingsRequest) => Promise<{ settings: VacayYearSettings }> } const api: VacayApi = { @@ -128,6 +132,8 @@ const api: VacayApi = { updateShare: (shareId, hidden) => ax.put(`/addons/vacay/shares/${shareId}`, { hidden } satisfies VacayShareUpdateRequest).then((r: AxiosResponse) => r.data), shareAvailableUsers: () => ax.get('/addons/vacay/shares/available-users').then((r: AxiosResponse) => r.data), getSharedCalendars: (year) => ax.get(`/addons/vacay/shares/calendars/${year}`).then((r: AxiosResponse) => r.data), + getYearSettings: () => ax.get('/addons/vacay/year-settings').then((r: AxiosResponse) => r.data), + updateYearSettings: (data) => ax.put('/addons/vacay/year-settings', data satisfies VacayYearSettingsRequest).then((r: AxiosResponse) => r.data), } function pushHolidayMarker(map: HolidaysMap, date: string, marker: HolidayInfo) { @@ -136,7 +142,13 @@ function pushHolidayMarker(map: HolidaysMap, date: string, marker: HolidayInfo) map[date] = [marker] return } - map[date] = Array.isArray(existing) ? [...existing, marker] : [existing, marker] + const markers = Array.isArray(existing) ? existing : [existing] + // A shifted leave year (#737) loads two calendar years, and a school break that + // straddles New Year comes back in both — keep one marker per day. The label is + // part of the identity: two calendars left on the same colour are still two + // calendars, and collapsing them would hide one from the tooltip. + if (markers.some(m => m.name === marker.name && m.color === marker.color && m.type === marker.type && m.label === marker.label)) return + map[date] = [...markers, marker] } function parseCalendarRegion(region: string): { country: string; subdivision: string | null; group: string | null } { @@ -184,6 +196,9 @@ interface VacayState { outgoingShares: VacayShareOutgoing[] incomingShares: VacayShareIncoming[] sharedCalendars: SharedVacayCalendar[] + // The viewer's leave-year configuration (#737). Everything the grid renders and + // filters runs through this, so it loads before the first year is picked. + yearSettings: VacayYearSettings setSelectedYear: (year: number) => void setSelectedUserId: (id: number | null) => void @@ -212,6 +227,8 @@ interface VacayState { shareWith: (userId: number) => Promise removeShare: (shareId: number) => Promise setShareHidden: (shareId: number, hidden: boolean) => Promise + loadYearSettings: () => Promise + updateYearSettings: (data: VacayYearSettingsRequest) => Promise loadAll: () => Promise } @@ -233,6 +250,7 @@ export const useVacayStore = create((set, get) => ({ outgoingShares: [], incomingShares: [], sharedCalendars: [], + yearSettings: DEFAULT_YEAR_SETTINGS, setSelectedYear: (year: number) => set({ selectedYear: year }), setSelectedUserId: (id: number | null) => set({ selectedUserId: id }), @@ -293,6 +311,8 @@ export const useVacayStore = create((set, get) => ({ set({ years: data.years }) if (data.years.length > 0) { set({ selectedYear: data.years[data.years.length - 1] }) + } else { + set({ selectedYear: currentPeriodYear(get().yearSettings) }) } }, @@ -308,7 +328,7 @@ export const useVacayStore = create((set, get) => ({ if (get().selectedYear === year) { updates.selectedYear = data.years.length > 0 ? data.years[data.years.length - 1] - : new Date().getFullYear() + : currentPeriodYear(get().yearSettings) } set(updates) await get().loadStats() @@ -391,30 +411,38 @@ export const useVacayStore = create((set, get) => ({ return } const map: HolidaysMap = {} + const settings = get().yearSettings + // A shifted leave year (#737) spans two calendar years and the holiday APIs are + // per calendar year, so each one the window touches is fetched and the result + // clipped back to what the grid actually renders. + const calendarYears = windowCalendarYears(y, settings) for (const cal of enabledCalendars) { const { country, subdivision, group } = parseCalendarRegion(cal.region) - try { - if ((cal.type ?? 'public_holiday') === 'school_holiday') { - if (!isSchoolHolidayCountrySupported(country)) continue - const data = await api.getSchoolHolidays(y, country, subdivision, group) - data.forEach((h: VacaySchoolHolidayRaw) => { - if (!h.startDate) return - const name = schoolHolidayName(h) - datesBetween(h.startDate, h.endDate || h.startDate).filter(date => date.startsWith(`${y}-`)).forEach(date => { - pushHolidayMarker(map, date, { name, localName: name, color: cal.color, label: cal.label, type: 'school_holiday' }) + for (const cy of calendarYears) { + try { + if ((cal.type ?? 'public_holiday') === 'school_holiday') { + if (!isSchoolHolidayCountrySupported(country)) continue + const data = await api.getSchoolHolidays(cy, country, subdivision, group) + data.forEach((h: VacaySchoolHolidayRaw) => { + if (!h.startDate) return + const name = schoolHolidayName(h) + datesBetween(h.startDate, h.endDate || h.startDate).filter(date => inGridWindow(date, y, settings)).forEach(date => { + pushHolidayMarker(map, date, { name, localName: name, color: cal.color, label: cal.label, type: 'school_holiday' }) + }) }) - }) - } else { - const data = await api.getHolidays(y, country) - const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) - if (hasRegions && !subdivision) continue - data.forEach((h: VacayHolidayRaw) => { - if (h.global || !h.counties || (subdivision && h.counties.includes(subdivision))) { - pushHolidayMarker(map, h.date, { name: h.name, localName: h.localName, color: cal.color, label: cal.label, type: 'public_holiday' }) - } - }) - } - } catch { /* API error, skip */ } + } else { + const data = await api.getHolidays(cy, country) + const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) + if (hasRegions && !subdivision) continue + data.forEach((h: VacayHolidayRaw) => { + if (!inGridWindow(h.date, y, settings)) return + if (h.global || !h.counties || (subdivision && h.counties.includes(subdivision))) { + pushHolidayMarker(map, h.date, { name: h.name, localName: h.localName, color: cal.color, label: cal.label, type: 'public_holiday' }) + } + }) + } + } catch { /* API error, skip */ } + } } set({ holidays: map }) }, @@ -475,10 +503,29 @@ export const useVacayStore = create((set, get) => ({ } }, + loadYearSettings: async () => { + const data = await api.getYearSettings() + set({ yearSettings: data.settings ?? DEFAULT_YEAR_SETTINGS }) + }, + + updateYearSettings: async (data: VacayYearSettingsRequest) => { + const res = await api.updateYearSettings(data) + set({ yearSettings: res.settings ?? DEFAULT_YEAR_SETTINGS }) + // The window moved, so entries, holidays and the counted usage all shift with it. + const year = get().selectedYear + await get().loadEntries(year) + await get().loadStats(year) + await get().loadHolidays(year) + await get().loadSharedCalendars(year) + }, + loadAll: async () => { set({ loading: true }) try { await get().loadPlan() + // The leave-year window decides which period is current, so it is resolved + // before the year list picks a default (#737). + await get().loadYearSettings() await get().loadYears() const year = get().selectedYear await get().loadEntries(year) diff --git a/client/src/types.ts b/client/src/types.ts index c34ed7ac3..d8976283f 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -327,6 +327,24 @@ export interface VacayStat { // Comp/flex days used this year (#1074) — informational, not deducted from the // entitlement. Absent on older server builds. comp_used?: number + // The leave-year window this row was computed over (#737), as YYYY-MM-DD. + // `window_end` is exclusive. Absent on older server builds. + window_start?: string + window_end?: string +} + +export type VacayYearType = 'calendar' | 'fiscal' | 'anniversary' + +/** + * Per-user leave-year configuration (#737). 'calendar' is the unchanged Jan–Dec + * default, 'fiscal' starts on a fixed month/day, 'anniversary' on the month/day + * of the hire date. + */ +export interface VacayYearSettings { + year_type: VacayYearType + year_start_month: number + year_start_day: number + hire_date: string | null } export interface HolidayInfo { diff --git a/client/src/vacay/yearWindow.test.ts b/client/src/vacay/yearWindow.test.ts new file mode 100644 index 000000000..1e72ea724 --- /dev/null +++ b/client/src/vacay/yearWindow.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest' +import { + DEFAULT_YEAR_SETTINGS, + currentPeriodYear, + gridWindow, + inGridWindow, + resolveYearWindow, + windowCalendarYears, + windowMonths, +} from './yearWindow' +import type { VacayYearSettings } from '../types' + +const fiscal = (month: number, day = 1): VacayYearSettings => ({ + year_type: 'fiscal', year_start_month: month, year_start_day: day, hire_date: null, +}) + +const anniversary = (hire: string | null): VacayYearSettings => ({ + year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: hire, +}) + +describe('resolveYearWindow', () => { + // FE-VACAY-YEARWINDOW-001 + it('defaults to the plain calendar year', () => { + expect(resolveYearWindow(2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }) + expect(resolveYearWindow(2026, DEFAULT_YEAR_SETTINGS)).toEqual({ start: '2026-01-01', end: '2027-01-01' }) + }) + + // FE-VACAY-YEARWINDOW-002 + it('starts a fiscal year on the configured month and day', () => { + expect(resolveYearWindow(2026, fiscal(7))).toEqual({ start: '2026-07-01', end: '2027-07-01' }) + expect(resolveYearWindow(2026, fiscal(4, 6))).toEqual({ start: '2026-04-06', end: '2027-04-06' }) + }) + + // FE-VACAY-YEARWINDOW-003 + it('follows the hire date for an anniversary year, falling back to January 1 without one', () => { + expect(resolveYearWindow(2026, anniversary('2019-09-16'))).toEqual({ start: '2026-09-16', end: '2027-09-16' }) + expect(resolveYearWindow(2026, anniversary(null))).toEqual({ start: '2026-01-01', end: '2027-01-01' }) + }) + + // FE-VACAY-YEARWINDOW-004 + it('matches the server: consecutive periods meet exactly', () => { + expect(resolveYearWindow(2025, fiscal(7)).end).toBe(resolveYearWindow(2026, fiscal(7)).start) + }) + + // FE-VACAY-YEARWINDOW-004a + it('ignores a month left behind by a previous fiscal setting when there is no hire date', () => { + // The settings UI sends the whole object, so switching to anniversary carries + // the old fiscal month along until a hire date is entered. + const stale = { year_type: 'anniversary' as const, year_start_month: 4, year_start_day: 1, hire_date: null } + expect(resolveYearWindow(2026, stale)).toEqual({ start: '2026-01-01', end: '2027-01-01' }) + expect(windowMonths(2026, stale)[0]).toEqual({ year: 2026, month: 0 }) + }) + + // FE-VACAY-YEARWINDOW-004b + it('clamps a start day the month cannot have, matching the server', () => { + expect(resolveYearWindow(2026, anniversary('2024-02-29'))).toEqual({ start: '2026-02-28', end: '2027-02-28' }) + expect(resolveYearWindow(2026, fiscal(2, 31))).toEqual({ start: '2026-02-28', end: '2027-02-28' }) + }) +}) + +describe('windowMonths', () => { + // FE-VACAY-YEARWINDOW-005 + it('lists January to December of the year for a calendar window', () => { + const months = windowMonths(2026) + expect(months).toHaveLength(12) + expect(months[0]).toEqual({ year: 2026, month: 0 }) + expect(months[11]).toEqual({ year: 2026, month: 11 }) + }) + + // FE-VACAY-YEARWINDOW-006 + it('rolls over the calendar year for a fiscal window', () => { + const months = windowMonths(2026, fiscal(7)) + expect(months[0]).toEqual({ year: 2026, month: 6 }) // Jul 2026 + expect(months[5]).toEqual({ year: 2026, month: 11 }) // Dec 2026 + expect(months[6]).toEqual({ year: 2027, month: 0 }) // Jan 2027 + expect(months[11]).toEqual({ year: 2027, month: 5 }) // Jun 2027 + }) + + // FE-VACAY-YEARWINDOW-007 + it('ignores the start day — the grid always renders whole months', () => { + expect(windowMonths(2026, fiscal(4, 6))[0]).toEqual({ year: 2026, month: 3 }) + }) +}) + +describe('gridWindow / inGridWindow', () => { + // FE-VACAY-YEARWINDOW-008 + it('is the window itself whenever it starts on the first of a month', () => { + expect(gridWindow(2026)).toEqual(resolveYearWindow(2026)) + expect(gridWindow(2026, fiscal(7))).toEqual(resolveYearWindow(2026, fiscal(7))) + }) + + // FE-VACAY-YEARWINDOW-009 + it('widens to whole months when the window starts later in the month', () => { + expect(gridWindow(2026, fiscal(4, 6))).toEqual({ start: '2026-04-01', end: '2027-04-01' }) + }) + + // FE-VACAY-YEARWINDOW-010 + it('accepts the days the grid shows and rejects the neighbouring periods', () => { + expect(inGridWindow('2026-12-31', 2026, fiscal(7))).toBe(true) + expect(inGridWindow('2027-01-01', 2026, fiscal(7))).toBe(true) + expect(inGridWindow('2027-06-30', 2026, fiscal(7))).toBe(true) + expect(inGridWindow('2026-06-30', 2026, fiscal(7))).toBe(false) + expect(inGridWindow('2027-07-01', 2026, fiscal(7))).toBe(false) + }) +}) + +describe('windowCalendarYears', () => { + // FE-VACAY-YEARWINDOW-011 + it('is one year for a calendar window and two for a shifted one', () => { + expect(windowCalendarYears(2026)).toEqual([2026]) + expect(windowCalendarYears(2026, fiscal(7))).toEqual([2026, 2027]) + }) +}) + +describe('currentPeriodYear', () => { + // FE-VACAY-YEARWINDOW-012 + it('is today’s year for a calendar user', () => { + expect(currentPeriodYear(DEFAULT_YEAR_SETTINGS, new Date(2026, 2, 15))).toBe(2026) + }) + + // FE-VACAY-YEARWINDOW-013 + it('is the previous year while a shifted period has not started yet', () => { + expect(currentPeriodYear(fiscal(7), new Date(2026, 2, 15))).toBe(2025) + expect(currentPeriodYear(fiscal(7), new Date(2026, 7, 15))).toBe(2026) + expect(currentPeriodYear(fiscal(7), new Date(2026, 6, 1))).toBe(2026) + }) +}) diff --git a/client/src/vacay/yearWindow.ts b/client/src/vacay/yearWindow.ts new file mode 100644 index 000000000..e83a00319 --- /dev/null +++ b/client/src/vacay/yearWindow.ts @@ -0,0 +1,102 @@ +import type { VacayYearSettings } from '../types' + +/** + * Client-side mirror of the server's leave-year window (#737). The server owns + * the arithmetic that entitlements are computed with; this copy is what the grid + * renders and what the trip/holiday overlays filter against, so both sides have + * to agree on where a period starts. + * + * A window is [start, end) — start inclusive, end exclusive — and the `year` + * integer names the period, not necessarily the calendar year it ends in. + */ + +export const DEFAULT_YEAR_SETTINGS: VacayYearSettings = { + year_type: 'calendar', + year_start_month: 1, + year_start_day: 1, + hire_date: null, +} + +const pad2 = (n: number) => String(n).padStart(2, '0') + +/** Days a month has in every year — February caps at 28, matching the server. */ +export function daysAlwaysInMonth(month: number): number { + if (month === 2) return 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + +/** + * Month (1-12) and day the leave year starts on. 'anniversary' without a hire + * date has nothing to anchor to and reads as the calendar default rather than + * borrowing a month a previous 'fiscal' setting left behind — the server's + * resolveYearWindow resolves it identically. + */ +function startMonthDay(s: VacayYearSettings): { month: number; day: number } { + if (s.year_type === 'calendar') return { month: 1, day: 1 } + let month = s.year_start_month || 1 + let day = s.year_start_day || 1 + if (s.year_type === 'anniversary') { + if (!s.hire_date) return { month: 1, day: 1 } + const parts = s.hire_date.split('-') + month = parseInt(parts[1], 10) || 1 + day = parseInt(parts[2], 10) || 1 + } + month = Math.min(12, Math.max(1, month)) + return { month, day: Math.min(day, daysAlwaysInMonth(month)) } +} + +/** The period's exact [start, end) — this is what used/remaining are counted over. */ +export function resolveYearWindow(year: number, s: VacayYearSettings = DEFAULT_YEAR_SETTINGS): { start: string; end: string } { + const { month, day } = startMonthDay(s) + return { start: `${year}-${pad2(month)}-${pad2(day)}`, end: `${year + 1}-${pad2(month)}-${pad2(day)}` } +} + +/** + * The range the grid actually covers: the twelve whole months the window starts + * in, since the calendar is built from month cards. For every window that begins + * on the 1st — all of 'calendar' — this is exactly the counting window. + * + * Known limitation for a start day past the 1st: month-aligned at both ends, so + * the range is shifted rather than widened — the first days of the start month + * are drawn but count toward the previous period, and the last days of the period + * count here but have no card. Mirrors the server's viewerGridWindow. + */ +export function gridWindow(year: number, s: VacayYearSettings = DEFAULT_YEAR_SETTINGS): { start: string; end: string } { + const { month } = startMonthDay(s) + return { start: `${year}-${pad2(month)}-01`, end: `${year + 1}-${pad2(month)}-01` } +} + +/** True when `date` (YYYY-MM-DD) falls inside the grid's range for this period. */ +export function inGridWindow(date: string, year: number, s: VacayYearSettings = DEFAULT_YEAR_SETTINGS): boolean { + const { start, end } = gridWindow(year, s) + return date >= start && date < end +} + +/** + * The twelve (year, month) pairs the grid renders, in display order. For + * 'calendar' that is Jan–Dec of `year`; a fiscal year starting in July gives + * Jul `year` – Jun `year + 1`. + */ +export function windowMonths(year: number, s: VacayYearSettings = DEFAULT_YEAR_SETTINGS): { year: number; month: number }[] { + const startIndex = startMonthDay(s).month - 1 + return Array.from({ length: 12 }, (_, i) => { + const absolute = startIndex + i + return { year: year + Math.floor(absolute / 12), month: absolute % 12 } + }) +} + +/** The calendar years the grid's range touches — one for 'calendar', two for a shifted window. */ +export function windowCalendarYears(year: number, s: VacayYearSettings = DEFAULT_YEAR_SETTINGS): number[] { + const years = new Set(windowMonths(year, s).map(m => m.year)) + return [...years].sort((a, b) => a - b) +} + +/** + * The period today falls into. With a window that starts later in the year, + * today still belongs to the period named after the previous calendar year. + */ +export function currentPeriodYear(s: VacayYearSettings = DEFAULT_YEAR_SETTINGS, today = new Date()): number { + const year = today.getFullYear() + const iso = `${year}-${pad2(today.getMonth() + 1)}-${pad2(today.getDate())}` + return iso < resolveYearWindow(year, s).start ? year - 1 : year +} diff --git a/client/tests/helpers/msw/handlers/vacay.ts b/client/tests/helpers/msw/handlers/vacay.ts index e25e44f59..0f261d46f 100644 --- a/client/tests/helpers/msw/handlers/vacay.ts +++ b/client/tests/helpers/msw/handlers/vacay.ts @@ -34,6 +34,19 @@ export const vacayHandlers = [ }); }), + http.get('/api/addons/vacay/year-settings', () => { + return HttpResponse.json({ + settings: { year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }, + }); + }), + + http.put('/api/addons/vacay/year-settings', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + settings: { year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null, ...body }, + }); + }), + http.get('/api/addons/vacay/years', () => { return HttpResponse.json({ years: [2025, 2026] }); }),