From e1c5daeccbf28552665427d9112d82ea5ea50224 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 25 Jul 2026 11:50:37 +0200 Subject: [PATCH] feat(vacay): mirror the leave-year window in the client (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server owns the arithmetic entitlements are computed with, but the grid has to render the same twelve months and the overlays have to filter against the same range, so the window resolves on both sides. yearWindow.ts is that mirror, including the Feb-29 clamp and the anniversary-without-a-hire-date fallback. The store loads the settings before the year list picks a default — with a shifted year the current period is not necessarily the current calendar year. Holidays are fetched per calendar year the window touches and clipped back to what the grid draws; a school break straddling New Year comes back from both fetches, so markers are de-duplicated per day. The label is part of that identity, otherwise two calendars left on the same colour collapse into one. --- client/src/store/vacayStore.ts | 95 +++++++++++---- client/src/types.ts | 18 +++ client/src/vacay/yearWindow.test.ts | 127 +++++++++++++++++++++ client/src/vacay/yearWindow.ts | 102 +++++++++++++++++ client/tests/helpers/msw/handlers/vacay.ts | 13 +++ 5 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 client/src/vacay/yearWindow.test.ts create mode 100644 client/src/vacay/yearWindow.ts 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] }); }),