) => {
- const next = { ...yearSettings, ...patch }
- updateYearSettings({
- year_type: next.year_type,
- year_start_month: next.year_start_month,
- year_start_day: Math.min(next.year_start_day, daysInMonth(next.year_start_month)),
- hire_date: next.hire_date,
- })
- }
-
- const months = windowMonths(selectedYear, yearSettings)
- const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' })
- const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}`
+ const maxDay = monthDayCap(yearSettings.year_start_month)
return (
@@ -403,12 +366,6 @@ function YearTypePicker() {
)
}
-/** Days a month can always offer — February caps at 28 so no window start is skipped in a common year. */
-function daysInMonth(month: number): number {
- if (month === 2) return 28
- return [4, 6, 9, 11].includes(month) ? 30 : 31
-}
-
interface SettingToggleProps {
icon: LucideIcon
label: string
@@ -436,30 +393,6 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
-/**
- * Region options for a holiday-calendar country.
- *
- * The loaded list is tagged with the country it belongs to, so switching country
- * drops the previous list in the same render instead of leaving it selectable while
- * the next request is still running (#1813: picking a German region for the
- * Netherlands was possible that way). `loadingRegions` tells "not answered yet"
- * apart from "this country has no regions"; on the list alone both are empty.
- */
-function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
- const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[]; failed?: boolean }>({ country: '', options: [] })
-
- useEffect(() => {
- if (!country) return
- const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
- let stale = false
- load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
- return () => { stale = true }
- }, [calendarType, country])
-
- const ready = loaded.country === country
- return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
-}
-
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
@@ -473,9 +406,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const [localColor, setLocalColor] = useState(cal.color)
const [localLabel, setLocalLabel] = useState(cal.label || '')
- const [baseRegion] = cal.region.split('|')
- const selectedCountry = baseRegion.split('-')[0]
- const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
+ const { country: selectedCountry, region: selectedRegion } = parseHolidayRegion(cal.region)
const { regions, regionError } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
@@ -555,15 +486,14 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [color, setColor] = useState(defaultColor)
const [label, setLabel] = useState('')
- const [baseRegion] = region.split('|')
- const selectedCountry = baseRegion.split('-')[0] || ''
- const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
- const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType)
+ const { country: selectedCountry, region: selectedRegion } = parseHolidayRegion(region)
+ const regionOptions = useRegionOptions(selectedCountry, calendarType)
+ const { regions, loadingRegions, regionError } = regionOptions
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
- const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
+ const canAdd = canAddHolidayCalendar(selectedCountry, region, regionOptions)
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
diff --git a/client/src/components/Vacay/useVacaySettings.test.ts b/client/src/components/Vacay/useVacaySettings.test.ts
new file mode 100644
index 000000000..10053512c
--- /dev/null
+++ b/client/src/components/Vacay/useVacaySettings.test.ts
@@ -0,0 +1,196 @@
+// FE-COMP-VACAYSET-001 to -011: the vacay settings logic the desktop panel and the phone sheet share.
+import { act, renderHook, waitFor } from '@testing-library/react';
+
+import apiClient from '../../api/client';
+import { useVacayStore } from '../../store/vacayStore';
+import type { VacayPlan, VacayYearSettings } from '../../types';
+import {
+ canAddHolidayCalendar,
+ monthDayCap,
+ parseHolidayRegion,
+ useLeaveYear,
+ useRegionOptions,
+ useVacaySettings,
+} from './useVacaySettings';
+
+const toast = { success: vi.fn(), error: vi.fn() };
+vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
+vi.mock('../../i18n', () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+ getIntlLanguage: (language: string) => language,
+}));
+const regionsApi = vi.hoisted(() => ({
+ fetchRegionOptions: vi.fn<(country: string) => Promise<{ value: string; label: string }[]>>(),
+ fetchSchoolHolidayRegionOptions: vi.fn<(country: string) => Promise<{ value: string; label: string }[]>>(),
+}));
+vi.mock('./holidayRegions', () => regionsApi);
+
+const updatePlan = vi.fn(async () => {});
+const dissolve = vi.fn(async () => {});
+const updateYearSettings = vi.fn(async () => {});
+
+const CALENDAR: VacayYearSettings = { year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
+
+function plan(over: Partial = {}): VacayPlan {
+ return { id: 1, block_weekends: true, holiday_calendars: [], ...over } as VacayPlan;
+}
+
+beforeEach(() => {
+ updatePlan.mockClear();
+ dissolve.mockClear();
+ updateYearSettings.mockClear();
+ toast.success.mockReset();
+ useVacayStore.setState({
+ plan: plan(),
+ updatePlan,
+ dissolve,
+ updateYearSettings,
+ yearSettings: CALENDAR,
+ selectedYear: 2026,
+ } as never);
+});
+afterEach(() => vi.restoreAllMocks());
+
+describe('useVacaySettings', () => {
+ it('FE-COMP-VACAYSET-001: loads the countries sorted by their localized name', async () => {
+ const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
+ data: [
+ { countryCode: 'DE', name: 'Germany' },
+ { countryCode: 'AT', name: 'Austria' },
+ ],
+ });
+ const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: true }));
+ await waitFor(() => expect(result.current.countries).toHaveLength(2));
+ expect(get).toHaveBeenCalledWith('/addons/vacay/holidays/countries');
+ expect(result.current.countries).toEqual([
+ { value: 'AT', label: 'Austria' },
+ { value: 'DE', label: 'Germany' },
+ ]);
+ });
+
+ it('FE-COMP-VACAYSET-002: holds the country request back until the sheet opens', async () => {
+ const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] });
+ const { rerender } = renderHook(
+ (p: { open: boolean }) => useVacaySettings({ language: 'en', loadCountries: p.open }),
+ {
+ initialProps: { open: false },
+ }
+ );
+ expect(get).not.toHaveBeenCalled();
+ rerender({ open: true });
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(1));
+ });
+
+ it('FE-COMP-VACAYSET-003: splits the calendars by type, an untyped one counting as public', () => {
+ vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] });
+ useVacayStore.setState({
+ plan: plan({
+ holiday_calendars: [
+ { id: 1, region: 'DE' },
+ { id: 2, region: 'FR', type: 'public_holiday' },
+ { id: 3, region: 'DE-BY', type: 'school_holiday' },
+ ],
+ } as never),
+ });
+ const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
+ expect(result.current.publicHolidayCalendars.map((c) => c.id)).toEqual([1, 2]);
+ expect(result.current.schoolHolidayCalendars.map((c) => c.id)).toEqual([3]);
+ });
+
+ it('FE-COMP-VACAYSET-004: toggles a weekend day in and out of the default Saturday and Sunday', () => {
+ const { result, rerender } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
+ expect(result.current.weekendDays).toEqual([0, 6]);
+ act(() => result.current.toggleWeekendDay(5));
+ expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '0,6,5' });
+ act(() => result.current.toggleWeekendDay(0));
+ expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '6' });
+
+ useVacayStore.setState({ plan: plan({ weekend_days: '5,6' }) });
+ rerender();
+ expect(result.current.weekendDays).toEqual([5, 6]);
+ });
+
+ it('FE-COMP-VACAYSET-005: dissolving toasts and then hands over', async () => {
+ const order: string[] = [];
+ toast.success.mockImplementation(() => order.push('toast'));
+ const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
+ await act(() => result.current.dissolveFusion(() => order.push('done')));
+ expect(dissolve).toHaveBeenCalled();
+ expect(toast.success).toHaveBeenCalledWith('vacay.dissolved');
+ expect(order).toEqual(['toast', 'done']);
+ });
+
+ it('FE-COMP-VACAYSET-006: works without a plan loaded yet', () => {
+ useVacayStore.setState({ plan: null });
+ const { result } = renderHook(() => useVacaySettings({ language: 'en', loadCountries: false }));
+ expect(result.current.publicHolidayCalendars).toEqual([]);
+ expect(result.current.weekendDays).toEqual([0, 6]);
+ });
+});
+
+describe('useLeaveYear', () => {
+ it('FE-COMP-VACAYSET-007: saves the whole year settings with the start day clamped to the month', () => {
+ useVacayStore.setState({ yearSettings: { ...CALENDAR, year_type: 'fiscal', year_start_day: 31 } });
+ const { result } = renderHook(() => useLeaveYear('en-GB'));
+ expect(result.current.type).toBe('fiscal');
+ act(() => result.current.save({ year_start_month: 2 }));
+ expect(updateYearSettings).toHaveBeenCalledWith({
+ year_type: 'fiscal',
+ year_start_month: 2,
+ year_start_day: 28,
+ hire_date: null,
+ });
+ });
+
+ it('FE-COMP-VACAYSET-008: labels the window the selected year spans', () => {
+ useVacayStore.setState({ yearSettings: { ...CALENDAR, year_type: 'fiscal', year_start_month: 7 } });
+ const { result } = renderHook(() => useLeaveYear('en-GB'));
+ expect(result.current.windowLabel).toBe('Jul 2026 – Jun 2027');
+ expect(result.current.selectedYear).toBe(2026);
+ });
+});
+
+describe('useRegionOptions', () => {
+ it('FE-COMP-VACAYSET-009: loads the regions per country and type and flags a failure', async () => {
+ const pub = regionsApi.fetchRegionOptions.mockResolvedValue([{ value: 'DE-BY', label: 'Bavaria' }]);
+ const school = regionsApi.fetchSchoolHolidayRegionOptions.mockRejectedValue(new Error('down'));
+
+ const empty = renderHook(() => useRegionOptions('', 'public_holiday'));
+ expect(empty.result.current).toMatchObject({ regions: [], loadingRegions: false });
+ expect(empty.result.current.regionError).toBeFalsy();
+ expect(pub).not.toHaveBeenCalled();
+
+ const publicHook = renderHook(() => useRegionOptions('DE', 'public_holiday'));
+ expect(publicHook.result.current.loadingRegions).toBe(true);
+ await waitFor(() => expect(publicHook.result.current.regions).toEqual([{ value: 'DE-BY', label: 'Bavaria' }]));
+ expect(pub).toHaveBeenCalledWith('DE');
+
+ const schoolHook = renderHook(() => useRegionOptions('DE', 'school_holiday'));
+ await waitFor(() => expect(schoolHook.result.current.regionError).toBe(true));
+ expect(school).toHaveBeenCalledWith('DE');
+ expect(schoolHook.result.current.loadingRegions).toBe(false);
+ });
+});
+
+describe('settings helpers', () => {
+ it('FE-COMP-VACAYSET-010: reads the country and region off a stored region', () => {
+ expect(parseHolidayRegion('')).toEqual({ country: '', region: '' });
+ expect(parseHolidayRegion('DE')).toEqual({ country: 'DE', region: '' });
+ expect(parseHolidayRegion('DE-BY')).toEqual({ country: 'DE', region: 'DE-BY' });
+ expect(parseHolidayRegion('NL|group:north')).toEqual({ country: 'NL', region: 'NL|group:north' });
+ expect(monthDayCap(2)).toBe(28);
+ expect(monthDayCap(4)).toBe(30);
+ expect(monthDayCap(12)).toBe(31);
+ });
+
+ it('FE-COMP-VACAYSET-011: a calendar can be added once the regions answered and the pick fits them', () => {
+ const regions = [{ value: 'DE-BY', label: 'Bavaria' }];
+ const ready = { regions, loadingRegions: false, regionError: false };
+ expect(canAddHolidayCalendar('', '', { regions: [], loadingRegions: false, regionError: false })).toBe(false);
+ expect(canAddHolidayCalendar('DE', 'DE', { ...ready, loadingRegions: true })).toBe(false);
+ expect(canAddHolidayCalendar('DE', 'DE', { regions: [], loadingRegions: false, regionError: true })).toBe(false);
+ expect(canAddHolidayCalendar('DE', 'DE', ready)).toBe(false);
+ expect(canAddHolidayCalendar('DE', 'DE-BY', ready)).toBe(true);
+ expect(canAddHolidayCalendar('FR', 'FR', { regions: [], loadingRegions: false, regionError: false })).toBe(true);
+ });
+});
diff --git a/client/src/components/Vacay/useVacaySettings.ts b/client/src/components/Vacay/useVacaySettings.ts
new file mode 100644
index 000000000..be20cfa23
--- /dev/null
+++ b/client/src/components/Vacay/useVacaySettings.ts
@@ -0,0 +1,167 @@
+import { useEffect, useState } from 'react';
+
+import apiClient from '../../api/client';
+import { getIntlLanguage, useTranslation } from '../../i18n';
+import { useVacayStore } from '../../store/vacayStore';
+import type { VacayYearSettings } from '../../types';
+import { windowMonths } from '../../vacay/yearWindow';
+import { useToast } from '../shared/Toast';
+import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions';
+
+interface RegionOption {
+ value: string;
+ label: string;
+}
+
+type CalendarType = 'public_holiday' | 'school_holiday';
+
+/**
+ * The plan side of the vacay settings, shared by the desktop panel and the phone
+ * sheet: the public-holiday countries, the plan's calendars split by type, the
+ * weekend days and dissolving a fusion. `loadCountries` holds the country request
+ * back while the phone sheet is closed.
+ */
+export function useVacaySettings({ language, loadCountries }: { language: string; loadCountries: boolean }) {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const plan = useVacayStore((s) => s.plan);
+ const updatePlan = useVacayStore((s) => s.updatePlan);
+ const dissolve = useVacayStore((s) => s.dissolve);
+ const [countries, setCountries] = useState([]);
+
+ // Available countries with localized names
+ useEffect(() => {
+ if (!loadCountries) return;
+ apiClient
+ .get('/addons/vacay/holidays/countries')
+ .then((r) => {
+ let displayNames: Intl.DisplayNames | undefined;
+ try {
+ displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' });
+ } catch {
+ /* */
+ }
+ const list: RegionOption[] = r.data.map((c: { countryCode: string; name: string }) => ({
+ value: c.countryCode,
+ label: displayNames ? displayNames.of(c.countryCode) || c.name : c.name,
+ }));
+ list.sort((a, b) => a.label.localeCompare(b.label));
+ setCountries(list);
+ })
+ .catch(() => {});
+ }, [loadCountries, language]);
+
+ // Public and school calendars live in the same holiday_calendars list, split by type
+ // (a null type predates the school-holiday feature, so it counts as a public holiday).
+ const calendars = plan?.holiday_calendars ?? [];
+ const publicHolidayCalendars = calendars.filter((cal) => (cal.type ?? 'public_holiday') === 'public_holiday');
+ const schoolHolidayCalendars = calendars.filter((cal) => cal.type === 'school_holiday');
+
+ const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6];
+ const toggleWeekendDay = (day: number) => {
+ const next = weekendDays.includes(day) ? weekendDays.filter((d) => d !== day) : [...weekendDays, day];
+ updatePlan({ weekend_days: next.join(',') });
+ };
+
+ const dissolveFusion = async (onDone: () => void) => {
+ await dissolve();
+ toast.success(t('vacay.dissolved'));
+ onDone();
+ };
+
+ return { countries, publicHolidayCalendars, schoolHolidayCalendars, weekendDays, toggleWeekendDay, dissolveFusion };
+}
+
+/** Days a month can always offer: February caps at 28 so no window start is skipped in a common year. */
+export function monthDayCap(month: number): number {
+ if (month === 2) return 28;
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
+}
+
+/**
+ * The viewer's own leave year (#737): calendar, fiscal or anniversary, saved with
+ * the start day clamped to the month, and the window it spans for `selectedYear`.
+ */
+export function useLeaveYear(locale: string) {
+ const yearSettings = useVacayStore((s) => s.yearSettings);
+ const updateYearSettings = useVacayStore((s) => s.updateYearSettings);
+ const selectedYear = useVacayStore((s) => s.selectedYear);
+
+ const save = (patch: Partial) => {
+ const next = { ...yearSettings, ...patch };
+ updateYearSettings({
+ year_type: next.year_type,
+ year_start_month: next.year_start_month,
+ year_start_day: Math.min(next.year_start_day, monthDayCap(next.year_start_month)),
+ hire_date: next.hire_date,
+ });
+ };
+
+ const months = windowMonths(selectedYear, yearSettings);
+ const shortMonth = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' });
+ const windowLabel = `${shortMonth.format(new Date(months[0].year, months[0].month, 1))} – ${shortMonth.format(new Date(months[11].year, months[11].month, 1))}`;
+
+ return { yearSettings, type: yearSettings.year_type, selectedYear, save, windowLabel };
+}
+
+/** A calendar's stored region split into the country and the region within it (or ''). */
+export function parseHolidayRegion(stored: string): { country: string; region: string } {
+ const [baseRegion] = stored.split('|');
+ return {
+ country: baseRegion.split('-')[0] || '',
+ region: stored.includes('|group:') || baseRegion.includes('-') ? stored : '',
+ };
+}
+
+/**
+ * Region options for a holiday-calendar country.
+ *
+ * The loaded list is tagged with the country it belongs to, so switching country
+ * drops the previous list in the same render instead of leaving it selectable while
+ * the next request is still running (#1813: picking a German region for the
+ * Netherlands was possible that way). `loadingRegions` tells "not answered yet"
+ * apart from "this country has no regions"; on the list alone both are empty.
+ */
+export function useRegionOptions(country: string, calendarType: CalendarType) {
+ const [loaded, setLoaded] = useState<{ country: string; options: RegionOption[]; failed?: boolean }>({
+ country: '',
+ options: [],
+ });
+
+ useEffect(() => {
+ if (!country) return;
+ const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions;
+ let stale = false;
+ load(country)
+ .then((options) => {
+ if (!stale) setLoaded({ country, options });
+ })
+ .catch(() => {
+ if (!stale) setLoaded({ country, options: [], failed: true });
+ });
+ return () => {
+ stale = true;
+ };
+ }, [calendarType, country]);
+
+ const ready = loaded.country === country;
+ return {
+ regions: ready ? loaded.options : [],
+ regionError: ready && loaded.failed,
+ loadingRegions: Boolean(country) && !ready,
+ };
+}
+
+/** A new calendar can be added once its country's regions answered and the pick is one of them (or none exist). */
+export function canAddHolidayCalendar(
+ country: string,
+ picked: string,
+ { regions, loadingRegions, regionError }: ReturnType
+): boolean {
+ return (
+ Boolean(country) &&
+ !loadingRegions &&
+ !regionError &&
+ (regions.length === 0 || regions.some((option) => option.value === picked))
+ );
+}
diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
index 087f3b0e1..18985dede 100644
--- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
+++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
@@ -4,14 +4,13 @@ import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { useVacayStore } from '../../../store/vacayStore'
-import { getIntlLanguage, useTranslation } from '../../../i18n'
-import { useToast } from '../../../components/shared/Toast'
-import apiClient from '../../../api/client'
-import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions'
+import { useTranslation } from '../../../i18n'
import { useSchoolHolidayCountries } from '../../../components/Vacay/useSchoolHolidayCountries'
-import { windowMonths } from '../../../vacay/yearWindow'
+import {
+ canAddHolidayCalendar, monthDayCap, parseHolidayRegion, useLeaveYear, useRegionOptions, useVacaySettings,
+} from '../../../components/Vacay/useVacaySettings'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
-import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types'
+import type { VacayHolidayCalendar } from '../../../types'
const CALENDAR_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af']
@@ -29,36 +28,17 @@ interface MVacaySettingsSheetProps {
*/
export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsSheetProps) {
const { t, language } = useTranslation()
- const toast = useToast()
- const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
- const [countries, setCountries] = useState