@@ -216,6 +217,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
value={plan.school_holidays_enabled}
onChange={() => toggle('school_holidays_enabled')}
/>
+ {schoolCountryError &&
{schoolHolidayCalendars.length === 0 && (
@@ -444,18 +446,18 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
* 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 }[] }>({ country: '', options: [] })
+ 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 }) })
+ 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 : [], loadingRegions: Boolean(country) && !ready }
+ return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
@@ -474,7 +476,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
- const { regions } = useRegionOptions(selectedCountry, calendarType)
+ const { regions, regionError } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
@@ -516,9 +518,10 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
placeholder={t('vacay.selectCountry')}
searchable
/>
+ {regionError &&
{t('schoolCatalog.loadError')}
}
{regions.length > 0 && (
option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => onUpdate({ region: String(v) })}
options={regions}
placeholder={t('vacay.selectRegion')}
@@ -555,12 +558,12 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
- const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType)
+ const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType)
// 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 && (regions.length === 0 || selectedRegion !== '')
+ const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -597,9 +600,10 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
placeholder={t('vacay.selectCountry')}
searchable
/>
+ {regionError && {t('schoolCatalog.loadError')}
}
{regions.length > 0 && (
option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => setRegion(String(v))}
options={regions}
placeholder={t('vacay.selectRegion')}
diff --git a/client/src/components/Vacay/holidayRegions.test.ts b/client/src/components/Vacay/holidayRegions.test.ts
index 4a9ad6b19..7311f8b6e 100644
--- a/client/src/components/Vacay/holidayRegions.test.ts
+++ b/client/src/components/Vacay/holidayRegions.test.ts
@@ -112,10 +112,10 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
- it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => {
+ it('FE-COMP-VCYREG-008: a failing request cannot masquerade as a country without regions', async () => {
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({ error: 'down' }, { status: 500 })))
- expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([])
+ await expect(fetchSchoolHolidayRegionOptions('NL')).rejects.toThrow('could not be loaded')
})
})
diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts
index 67d9122c1..8be378baf 100644
--- a/client/src/components/Vacay/holidayRegions.ts
+++ b/client/src/components/Vacay/holidayRegions.ts
@@ -1,3 +1,4 @@
+import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import apiClient from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
@@ -77,23 +78,32 @@ function activeLanguage(): string {
export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
- if (!config || config.strategy === 'country') return []
+ const catalog = await schoolHolidayCatalogRepo.catalog().catch(error => {
+ if (!config) throw error
+ return { countries: [], regions: [] }
+ })
+ const manual = catalog.regions.filter(region => region.country === country).map(region => ({ value: region.code, label: region.name }))
+ if (!config) return manual
+ if (config.strategy === 'country') {
+ return manual.length ? [{ value: country, label: country }, ...manual] : []
+ }
try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') {
- return flattenOptions(r.data.groups, lang)
+ return [...manual, ...flattenOptions(r.data.groups, lang)
.map(opt => ({
value: `${country}|group:${opt.value}`,
label: opt.label,
- }))
+ }))]
.sort((a, b) => a.label.localeCompare(b.label))
}
- return flattenOptions(r.data.subdivisions, lang)
- .map(opt => ({ value: opt.value, label: opt.label }))
+ return [...manual, ...flattenOptions(r.data.subdivisions, lang)
+ .map(opt => ({ value: opt.value, label: opt.label }))]
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
- return []
+ if (manual.length) return manual
+ throw new Error('School holiday regions could not be loaded')
}
}
diff --git a/client/src/components/Vacay/useSchoolHolidayCountries.ts b/client/src/components/Vacay/useSchoolHolidayCountries.ts
new file mode 100644
index 000000000..1f65a6e28
--- /dev/null
+++ b/client/src/components/Vacay/useSchoolHolidayCountries.ts
@@ -0,0 +1,25 @@
+import { useEffect, useState } from 'react'
+import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
+import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
+import { getIntlLanguage, useTranslation } from '../../i18n'
+
+export function useSchoolHolidayCountries(enabled = true) {
+ const { language, t } = useTranslation()
+ const [manual, setManual] = useState<{ code: string; name: string }[]>([])
+ const [error, setError] = useState('')
+ useEffect(() => {
+ if (!enabled) return
+ let stale = false
+ schoolHolidayCatalogRepo.catalog().then(catalog => {
+ if (!stale) {
+ setManual(catalog.countries.filter(country => catalog.regions.some(region => region.country === country.code)))
+ setError('')
+ }
+ }).catch(() => { if (!stale) setError(t('schoolCatalog.loadError')) })
+ return () => { stale = true }
+ }, [enabled, t])
+ const names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
+ const countries = new Map(Object.keys(SCHOOL_HOLIDAY_COUNTRY_CONFIG).map(code => [code, names.of(code) || code]))
+ for (const country of manual) if (!countries.has(country.code)) countries.set(country.code, country.name)
+ return { countries: [...countries].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)), error }
+}
diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx
index 5130ba5f2..1d6c82323 100644
--- a/client/src/mobile/screens/admin/MAdmin.tsx
+++ b/client/src/mobile/screens/admin/MAdmin.tsx
@@ -1,3 +1,4 @@
+import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog'
import { useState } from 'react'
import {
ArrowUp,
@@ -214,6 +215,7 @@ export default function MAdmin() {
+
)}
{activeTab === 'addons' && (
diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
index 32390b574..4a74013d4 100644
--- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
+++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx
@@ -8,7 +8,7 @@ import { getIntlLanguage, useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import apiClient from '../../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions'
-import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries'
+import { useSchoolHolidayCountries } from '../../../components/Vacay/useSchoolHolidayCountries'
import { windowMonths } from '../../../vacay/yearWindow'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types'
@@ -49,13 +49,14 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
}).catch(() => {})
}, [open, language])
+ const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries(open)
+
if (!plan) return null
// 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 publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
- const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
const weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const weekdayChips = [
@@ -220,6 +221,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe