diff --git a/README.md b/README.md index 65477543f..e93912052 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa - **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency - **Documents** — file attachments on trips, places, and reservations - **Collab** — chat, notes, polls, day-by-day attendance -- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking +- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods - **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations @@ -472,4 +472,3 @@ for full third-party attributions. ## License TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence. - diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 693f018b1..971cd4da0 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -105,6 +105,8 @@ export default function VacayCalendar() { const selectedUser = users.find(u => u.id === selectedUserId) const tipEntries = tip ? entryMap[tip.date] : undefined const tipShared = tip ? sharedMap[tip.date] : undefined + const tipHolidayRaw = tip ? holidays[tip.date] : undefined + const tipSchool = (Array.isArray(tipHolidayRaw) ? tipHolidayRaw : tipHolidayRaw ? [tipHolidayRaw] : []).filter(h => h.type === 'school_holiday') const tipDate = tip ? new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(new Date(tip.date + 'T00:00:00')) : '' return ( @@ -135,7 +137,7 @@ export default function VacayCalendar() { with half days, plus shared read-only calendars). Rendered fixed at the root (not inside a month card) so backdrop-filter stacking contexts can't clip or occlude it. */} - {tip && ((tipEntries && tipEntries.length > 0) || (tipShared && tipShared.length > 0)) && ( + {tip && ((tipEntries && tipEntries.length > 0) || (tipShared && tipShared.length > 0) || tipSchool.length > 0) && (
))} + {/* School holidays fold into this tooltip under a divider instead of a + separate native title, so a half/full day and the school break read together. */} + {tipSchool.length > 0 && ( + <> + {((tipEntries?.length ?? 0) > 0 || (tipShared?.length ?? 0) > 0) && ( +
+ )} +
+ {t('vacay.schoolHolidays')} +
+ {tipSchool.map((h, i) => ( +
+ + {h.label ? `${h.label}: ${h.localName}` : h.localName} +
+ ))} + + )}
)} diff --git a/client/src/components/Vacay/VacayMonthCard.test.tsx b/client/src/components/Vacay/VacayMonthCard.test.tsx index a9b634b93..2bfcfe931 100644 --- a/client/src/components/Vacay/VacayMonthCard.test.tsx +++ b/client/src/components/Vacay/VacayMonthCard.test.tsx @@ -67,6 +67,16 @@ describe('VacayMonthCard', () => { expect(cell).toBeInTheDocument() }) + it('FE-COMP-VACAYMONTHCARD-014: School holiday markers do not recolor the day number', () => { + const props = { + ...baseProps, + holidays: { '2025-01-02': { name: 'School holiday', localName: 'School holiday', label: 'NL', color: '#22c55e', type: 'school_holiday' as const } }, + } + render() + const daySpan = screen.getByText('2') + expect(daySpan.style.color).toBe('var(--vg-ink2)') + }) + it('FE-COMP-VACAYMONTHCARD-006: Weekend cell has default cursor (blocked)', () => { render() // January 5, 2025 is a Sunday (getDay() === 0), which is in weekendDays [0, 6] diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index 10cbfa718..4934dafd6 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useTranslation } from '../../i18n' import type { HolidaysMap, VacayEntry } from '../../types' +import { schoolHolidayBand, schoolHolidayWash } from './holidayVisual' const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const @@ -96,13 +97,16 @@ export default function VacayMonthCard({ const dateStr = `${year}-${pad(month + 1)}-${pad(day)}` const dayOfWeek = new Date(year, month, day).getDay() const weekend = weekendDays.includes(dayOfWeek) - const holiday = holidays[dateStr] + const rawHolidayMarkers = holidays[dateStr] + const holidayMarkers = Array.isArray(rawHolidayMarkers) ? rawHolidayMarkers : rawHolidayMarkers ? [rawHolidayMarkers] : [] + const publicHoliday = holidayMarkers.find(h => (h.type ?? 'public_holiday') === 'public_holiday') + const schoolHolidayMarkers = holidayMarkers.filter(h => h.type === 'school_holiday') const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr) const dayEntries = entryMap[dateStr] || [] const hasEntries = dayEntries.length > 0 const isBlocked = (weekend && blockWeekends) || (isCompany && !companyMode) const isToday = dateStr === todayStr - const plain = !hasEntries && !holiday && !isCompany + const plain = !hasEntries && holidayMarkers.length === 0 && !isCompany // The fill always shows WHO is off (person colour, split for several) — half // days keep that fill and get a small corner ½ badge instead, so a half day @@ -119,7 +123,10 @@ export default function VacayMonthCard({ if (dayEntries.length === 1) background = fill(dayEntries[0].person_color) else if (dayEntries.length === 2) background = `linear-gradient(135deg, ${fill(dayEntries[0].person_color)} 50%, ${fill(dayEntries[1].person_color)} 50%)` else if (dayEntries.length === 0 && isCompany) background = 'rgba(245,158,11,0.22)' - else if (dayEntries.length === 0 && holiday) background = `color-mix(in srgb, ${holiday.color} 22%, transparent)` + else if (dayEntries.length === 0 && publicHoliday) background = `color-mix(in srgb, ${publicHoliday.color} 22%, transparent)` + // A plain school-break day gets a soft wash of its calendar colour so a run of + // days reads as one gentle stretch; the rounded band below names the calendar. + else if (dayEntries.length === 0 && schoolHolidayMarkers.length > 0) background = schoolHolidayWash(schoolHolidayMarkers[0].color) // Weekend / settings-blocked days read as inactive: subtle grey fill, like before the facelift. else if (weekend && blockWeekends) background = 'color-mix(in srgb, var(--vg-ink3) 7%, transparent)' @@ -133,13 +140,13 @@ export default function VacayMonthCard({ let numColor = 'var(--vg-ink2)' if (hasEntries) numColor = '#fff' - else if (holiday) numColor = holiday.color + else if (publicHoliday) numColor = publicHoliday.color else if (weekend) numColor = 'var(--vg-ink3)' return (
onCellClick(dateStr)} onMouseEnter={e => { if (!isBlocked && plain) e.currentTarget.style.background = 'var(--vg-surf2)' - if (anyHalf || sharedColors.length > 0) onCellHover?.(dateStr, e.currentTarget) + if (hasEntries || sharedColors.length > 0 || schoolHolidayMarkers.length > 0) onCellHover?.(dateStr, e.currentTarget) }} onMouseLeave={e => { if (!isBlocked && plain) e.currentTarget.style.background = background - if (anyHalf || sharedColors.length > 0) onCellHover?.(null, null) + if (hasEntries || sharedColors.length > 0 || schoolHolidayMarkers.length > 0) onCellHover?.(null, null) }} > {/* 3+ people: quadrant overlay at full colour (1 & 2 use the cell background). */} @@ -185,6 +192,20 @@ export default function VacayMonthCard({ )} + {schoolHolidayMarkers.length > 0 && ( + h.color)), + }} + aria-hidden + /> + )} + { expect(await fetchRegionOptions('JP')).toEqual([]) }) + it('FE-COMP-VACAYSETTINGS-021: school holiday whitelist contains only approved green/yellow countries', () => { + expect(Object.values(SCHOOL_HOLIDAY_COUNTRY_CONFIG).every(config => ['green', 'yellow'].includes(config.status))).toBe(true) + expect(SCHOOL_HOLIDAY_COUNTRY_CONFIG.SE).toBeUndefined() + }) + + it('FE-COMP-VACAYSETTINGS-022: German school holidays use subdivisions even when OpenHolidays exposes groups', async () => { + server.use( + http.get('/api/addons/vacay/school-holidays/regions/:country', () => + HttpResponse.json({ + groups: [ + { code: 'DE-MV-ABS', name: [{ language: 'EN', text: 'General Schools' }] }, + ], + subdivisions: [ + { code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }, + { code: 'DE-NW', name: [{ language: 'EN', text: 'North Rhine-Westphalia' }] }, + ], + }) + ), + ) + + expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([ + { value: 'DE-BY', label: 'Bavaria' }, + { value: 'DE-NW', label: 'North Rhine-Westphalia' }, + ]) + }) + + it('FE-COMP-VACAYSETTINGS-023: Dutch school holidays use OpenHolidays groups', async () => { + server.use( + http.get('/api/addons/vacay/school-holidays/regions/:country', () => + HttpResponse.json({ + groups: [ + { code: 'NL-NO', name: [{ language: 'EN', text: 'North Region' }] }, + { code: 'NL-MI', name: [{ language: 'EN', text: 'Central Region' }] }, + ], + subdivisions: [ + { code: 'NL-DR', name: [{ language: 'EN', text: 'Drenthe' }] }, + ], + }) + ), + ) + + expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([ + { value: 'NL|group:NL-MI', label: 'Central Region' }, + { value: 'NL|group:NL-NO', label: 'North Region' }, + ]) + }) + it('FE-COMP-VACAYSETTINGS-018: adding weekend day calls updatePlan with day added', async () => { const user = userEvent.setup() const updatePlan = vi.fn().mockResolvedValue(undefined) diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index b3a4ac58e..b45cb2a67 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react' -import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react' +import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, GraduationCap } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { getIntlLanguage, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' -import { fetchRegionOptions } from './holidayRegions' +import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions' +import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' import type { VacayHolidayCalendar } from '../../types' interface VacaySettingsProps { @@ -18,6 +19,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore() const [countries, setCountries] = useState<{ value: string; label: string }[]>([]) const [showAddForm, setShowAddForm] = useState(false) + const [showAddSchoolForm, setShowAddSchoolForm] = useState(false) const { language } = useTranslation() @@ -38,6 +40,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { if (!plan) return null const toggle = (key: string) => updatePlan({ [key]: !plan[key] }) + 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) return (
@@ -162,15 +167,16 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { /> {plan.holidays_enabled && (
- {(plan.holiday_calendars ?? []).length === 0 && ( + {publicHolidayCalendars.length === 0 && (

{t('vacay.noCalendars')}

)} - {(plan.holiday_calendars ?? []).map(cal => ( + {publicHolidayCalendars.map(cal => ( updateHolidayCalendar(cal.id, data)} onDelete={() => deleteHolidayCalendar(cal.id)} /> @@ -179,7 +185,8 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { { await addHolidayCalendar(data); setShowAddForm(false) }} + calendarType="public_holiday" + onAdd={async (data) => { await addHolidayCalendar({ ...data, type: 'public_holiday' }); setShowAddForm(false) }} onCancel={() => setShowAddForm(false)} /> ) : ( @@ -195,6 +202,53 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
)}
+ + {/* School holidays — sits on the right, directly under Public holidays */} +
+ toggle('school_holidays_enabled')} + /> + {plan.school_holidays_enabled && ( +
+ {schoolHolidayCalendars.length === 0 && ( +

{t('vacay.noSchoolCalendars')}

+ )} + {schoolHolidayCalendars.map(cal => ( + updateHolidayCalendar(cal.id, data)} + onDelete={() => deleteHolidayCalendar(cal.id)} + /> + ))} + {showAddSchoolForm ? ( + { await addHolidayCalendar({ ...data, type: 'school_holiday' }); setShowAddSchoolForm(false) }} + onCancel={() => setShowAddSchoolForm(false)} + /> + ) : ( + + )} +
+ )} +
@@ -266,10 +320,11 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg } // ── Existing calendar row (inline edit) ────────────────────────────────────── -function CalendarRow({ cal, countries, onUpdate, onDelete }: { +function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: { cal: VacayHolidayCalendar countries: { value: string; label: string }[] language: string + calendarType: 'public_holiday' | 'school_holiday' onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void onDelete: () => void }) { @@ -278,16 +333,18 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: { const [localLabel, setLocalLabel] = useState(cal.label || '') const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) - const selectedCountry = cal.region.split('-')[0] - const selectedRegion = cal.region.includes('-') ? cal.region : '' + const [baseRegion] = cal.region.split('|') + const selectedCountry = baseRegion.split('-')[0] + const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' useEffect(() => { setLocalColor(cal.color) }, [cal.color]) useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label]) useEffect(() => { if (!selectedCountry) { setRegions([]); return } - fetchRegionOptions(selectedCountry).then(setRegions) - }, [selectedCountry]) + const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions + load(selectedCountry).then(setRegions) + }, [calendarType, selectedCountry]) const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6'] const [showColorPicker, setShowColorPicker] = useState(false) @@ -349,27 +406,31 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: { } // ── Add-new-calendar form ───────────────────────────────────────────────────── -function AddCalendarForm({ countries, onAdd, onCancel }: { +function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColor = '#fecaca' }: { countries: { value: string; label: string }[] language: string + calendarType: 'public_holiday' | 'school_holiday' onAdd: (data: { region: string; color: string; label: string | null }) => void onCancel: () => void + defaultColor?: string }) { const { t } = useTranslation() const [region, setRegion] = useState('') - const [color, setColor] = useState('#fecaca') + const [color, setColor] = useState(defaultColor) const [label, setLabel] = useState('') const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) const [loadingRegions, setLoadingRegions] = useState(false) - const selectedCountry = region.split('-')[0] || '' - const selectedRegion = region.includes('-') ? region : '' + const [baseRegion] = region.split('|') + const selectedCountry = baseRegion.split('-')[0] || '' + const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' useEffect(() => { if (!selectedCountry) { setRegions([]); return } setLoadingRegions(true) - fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false)) - }, [selectedCountry]) + const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions + load(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false)) + }, [calendarType, selectedCountry]) const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '') diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts index be06271a1..46d85f1bb 100644 --- a/client/src/components/Vacay/holidayRegions.ts +++ b/client/src/components/Vacay/holidayRegions.ts @@ -1,5 +1,6 @@ import apiClient from '../../api/client' import iso31662 from 'iso-3166-2' +import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' // Loads the subdivision (state/region) options for a holiday-calendar country. // @@ -32,3 +33,51 @@ export async function fetchRegionOptions(country: string): Promise<{ value: stri return [] } } + +type LocalizedName = { language: string; text: string } +type OpenHolidayOption = { + code: string + shortName?: string + name?: LocalizedName[] + children?: OpenHolidayOption[] | null +} + +function localizedName(names: LocalizedName[] | undefined, fallback: string): string { + return names?.find(n => n.language?.toUpperCase() === 'DE')?.text + || names?.find(n => n.language?.toUpperCase() === 'EN')?.text + || names?.[0]?.text + || fallback +} + +function flattenOptions(items: OpenHolidayOption[] | undefined, prefix = ''): { value: string; label: string }[] { + const result: { value: string; label: string }[] = [] + for (const item of items ?? []) { + const label = localizedName(item.name, item.shortName || item.code) + result.push({ value: item.code, label: prefix ? `${prefix} / ${label}` : label }) + result.push(...flattenOptions(item.children ?? undefined, prefix ? `${prefix} / ${label}` : label)) + } + return result +} + +export async function fetchSchoolHolidayRegionOptions(country: string): Promise<{ value: string; label: string }[]> { + const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country] + if (!config || config.strategy === 'country') return [] + + try { + const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`) + if (config.strategy === 'groups') { + return flattenOptions(r.data.groups) + .map(opt => ({ + value: `${country}|group:${opt.value}`, + label: opt.label, + })) + .sort((a, b) => a.label.localeCompare(b.label)) + } + + return flattenOptions(r.data.subdivisions) + .map(opt => ({ value: opt.value, label: opt.label })) + .sort((a, b) => a.label.localeCompare(b.label)) + } catch { + return [] + } +} diff --git a/client/src/components/Vacay/holidayVisual.ts b/client/src/components/Vacay/holidayVisual.ts new file mode 100644 index 000000000..30aad9fc6 --- /dev/null +++ b/client/src/components/Vacay/holidayVisual.ts @@ -0,0 +1,27 @@ +// Shared visual treatment for school-holiday days, used by the desktop month card +// (VacayMonthCard) and the mobile month grid (vacayDayModel/MVacayMonth) so both +// surfaces render the layer identically. + +/** + * Bottom accent band for a school-holiday day: a single rounded bar in the + * calendar colour, split into equal segments when several school calendars + * cover the same day (capped at three). + */ +export function schoolHolidayBand(colors: string[]): string { + if (colors.length === 0) return 'transparent' + if (colors.length === 1) return colors[0] + const seg = colors.slice(0, 3) + const stops = seg + .map((c, i) => `${c} ${Math.round((i * 100) / seg.length)}% ${Math.round(((i + 1) * 100) / seg.length)}%`) + .join(', ') + return `linear-gradient(90deg, ${stops})` +} + +/** + * Soft background wash for a plain day that falls inside a school break — light + * enough to read as a gentle range highlight without competing with the stronger + * public-holiday / person fills. + */ +export function schoolHolidayWash(color: string): string { + return `color-mix(in srgb, ${color} 15%, transparent)` +} diff --git a/client/src/mobile/screens/vacay/MVacayMonth.tsx b/client/src/mobile/screens/vacay/MVacayMonth.tsx index 063dc3578..576eb6fb1 100644 --- a/client/src/mobile/screens/vacay/MVacayMonth.tsx +++ b/client/src/mobile/screens/vacay/MVacayMonth.tsx @@ -1,3 +1,4 @@ +import { schoolHolidayBand } from '../../../components/Vacay/holidayVisual' import { dayVisual, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel' interface MVacayMonthProps { @@ -63,6 +64,15 @@ export default function MVacayMonth({ style={{ background: '#f97316' }} /> )} + {visual.school && visual.school.length > 0 && ( + + )} ) })} diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx index 2f253157f..47ef59cf2 100644 --- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ArrowRightLeft, Building2, Calendar, CalendarX, ChevronDown, Globe, Plus, Trash2, Unlink, X } from 'lucide-react' +import { ArrowRightLeft, Building2, Calendar, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import MToggle from '../../components/MToggle' @@ -7,7 +7,8 @@ import { useVacayStore } from '../../../store/vacayStore' import { getIntlLanguage, useTranslation } from '../../../i18n' import { useToast } from '../../../components/shared/Toast' import apiClient from '../../../api/client' -import { fetchRegionOptions } from '../../../components/Vacay/holidayRegions' +import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions' +import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries' import { FALLBACK_PERSON_COLOR } from './vacayDayModel' import type { VacayHolidayCalendar } from '../../../types' @@ -31,6 +32,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore() const [countries, setCountries] = useState([]) const [showAddForm, setShowAddForm] = useState(false) + const [showAddSchoolForm, setShowAddSchoolForm] = useState(false) useEffect(() => { if (!open) return @@ -48,6 +50,12 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe 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 = [ { day: 1, label: t('vacay.mon') }, @@ -167,11 +175,12 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe {plan.holidays_enabled && ( <> - {(plan.holiday_calendars ?? []).map(cal => ( + {publicHolidayCalendars.map(cal => ( updateHolidayCalendar(cal.id, data)} onDelete={() => deleteHolidayCalendar(cal.id)} /> @@ -179,7 +188,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe {showAddForm ? ( { await addHolidayCalendar(data); setShowAddForm(false) }} + calendarType="public_holiday" + onAdd={async data => { await addHolidayCalendar({ ...data, type: 'public_holiday' }); setShowAddForm(false) }} onCancel={() => setShowAddForm(false)} /> ) : ( @@ -194,6 +204,48 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe )} )} +
+ + {/* School holidays + calendar editor */} +
+ +
+
{t('vacay.schoolHolidays')}
+
{t('vacay.schoolHolidaysHint')}
+
+ updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} /> +
+ {plan.school_holidays_enabled && ( + <> + {schoolHolidayCalendars.map(cal => ( + updateHolidayCalendar(cal.id, data)} + onDelete={() => deleteHolidayCalendar(cal.id)} + /> + ))} + {showAddSchoolForm ? ( + { await addHolidayCalendar({ ...data, type: 'school_holiday' }); setShowAddSchoolForm(false) }} + onCancel={() => setShowAddSchoolForm(false)} + /> + ) : ( + + )} + + )} {/* Dissolve Fusion */} {isFused && ( @@ -282,9 +334,10 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string) ) } -function CalendarEditor({ cal, countries, onUpdate, onDelete }: { +function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: { cal: VacayHolidayCalendar countries: Option[] + calendarType?: 'public_holiday' | 'school_holiday' onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void onDelete: () => void }) { @@ -297,8 +350,9 @@ function CalendarEditor({ cal, countries, onUpdate, onDelete }: { useEffect(() => { setLabel(cal.label || '') }, [cal.label]) useEffect(() => { if (!country) { setRegions([]); return } - fetchRegionOptions(country).then(setRegions) - }, [country]) + const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions + load(country).then(setRegions) + }, [country, calendarType]) return (
@@ -324,8 +378,9 @@ function CalendarEditor({ cal, countries, onUpdate, onDelete }: { ) } -function AddCalendarDraft({ countries, onAdd, onCancel }: { +function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, onCancel }: { countries: Option[] + calendarType?: 'public_holiday' | 'school_holiday' onAdd: (data: { region: string; color: string; label: string | null }) => void onCancel: () => void }) { @@ -340,8 +395,9 @@ function AddCalendarDraft({ countries, onAdd, onCancel }: { useEffect(() => { if (!country) { setRegions([]); return } - fetchRegionOptions(country).then(setRegions) - }, [country]) + const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions + load(country).then(setRegions) + }, [country, calendarType]) const canAdd = Boolean(country) && (regions.length === 0 || selectedRegion !== '') diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts index 624a631b8..3d4d9951b 100644 --- a/client/src/mobile/screens/vacay/vacayDayModel.ts +++ b/client/src/mobile/screens/vacay/vacayDayModel.ts @@ -1,3 +1,4 @@ +import { schoolHolidayWash } from '../../../components/Vacay/holidayVisual' import type { HolidaysMap, VacayEntry } from '../../../types' // Users created before picking a color have color=null; same fallback the @@ -10,6 +11,9 @@ export interface DayVisual { boxShadow?: string // At least one person logged this day as a half day (#552) — the cell shows a ½ badge. half?: boolean + // Colours of the school-holiday calendars covering this day — drawn as a rounded + // accent band under the number, on top of whatever fill the cell already has. + school?: string[] } export interface DayVisualContext { @@ -84,11 +88,19 @@ export function splitBackground(colors: string[]): string { * theme-independent surfaces. */ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual { + const holidayMarkers = ctx.holidays[dateStr] + const holidays = Array.isArray(holidayMarkers) ? holidayMarkers : holidayMarkers ? [holidayMarkers] : [] + const publicHoliday = holidays.find(holiday => (holiday.type ?? 'public_holiday') === 'public_holiday') + const schoolColors = holidays.filter(holiday => holiday.type === 'school_holiday').map(holiday => holiday.color) + // The school band sits on top of any fill (person / company / public), so tag it + // onto whatever visual this day resolves to rather than picking a single winner. + const withSchool = (v: DayVisual): DayVisual => (schoolColors.length > 0 ? { ...v, school: schoolColors } : v) + if (dateStr === ctx.todayStr) { - return { background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' } + return withSchool({ background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' }) } if (ctx.companyHolidaysEnabled && ctx.companyHolidaySet.has(dateStr)) { - return { background: '#F5D9A6', numColor: '#8A5A00' } + return withSchool({ background: '#F5D9A6', numColor: '#8A5A00' }) } const entries = ctx.entryMap[dateStr] if (entries && entries.length > 0) { @@ -98,11 +110,14 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext // true so full days keep their exact { background, numColor } shape. const visual: DayVisual = { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' } if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true - return visual + return withSchool(visual) } - const holiday = ctx.holidays[dateStr] - if (holiday) { - return { background: holiday.color, numColor: holidayInk(holiday.color) } + if (publicHoliday) { + return withSchool({ background: publicHoliday.color, numColor: holidayInk(publicHoliday.color) }) + } + // Plain school-break day: soft wash + readable tinted ink, matching the desktop card. + if (schoolColors.length > 0) { + return { background: schoolHolidayWash(schoolColors[0]), numColor: holidayInk(schoolColors[0]), school: schoolColors } } if (ctx.weekendDays.includes(dayOfWeek)) { return { background: 'var(--m-ic)', numColor: 'var(--m-faint)' } diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 5b7337346..09e05660b 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -94,14 +94,17 @@ function VacayPageDesktop(): React.ReactElement { {/* Legend */} - {(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends || hasVisibleShared) && ( + {(plan?.holidays_enabled || plan?.school_holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends || hasVisibleShared) && (
{t('vacay.legend')}
- {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && ( + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday').length === 0 && ( )} - {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => ( + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday').map(cal => ( + + ))} + {plan?.school_holidays_enabled && (plan?.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday').map(cal => ( ))} {plan?.company_holidays_enabled && } diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 1a83a18c8..fcae85df7 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -6,6 +6,7 @@ import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar, VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar, } from '../types' +import { isSchoolHolidayCountrySupported } from '../vacay/schoolHolidayCountries' import type { VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest, VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest, @@ -54,6 +55,12 @@ interface VacayHolidayRaw { counties: string[] | null } +interface VacaySchoolHolidayRaw { + startDate?: string + endDate?: string + name?: string | { language?: string; text?: string }[] +} + interface VacayApi { getPlan: () => Promise updatePlan: (data: Partial) => Promise<{ plan: VacayPlan }> @@ -74,8 +81,9 @@ interface VacayApi { updateStats: (year: number, days: number, targetUserId?: number) => Promise getCountries: () => Promise<{ countries: string[] }> getHolidays: (year: number, country: string) => Promise - addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> - updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> + getSchoolHolidays: (year: number, country: string, subdivision?: string | null, group?: string | null) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<{ calendar: VacayHolidayCalendar }> + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<{ calendar: VacayHolidayCalendar }> deleteHolidayCalendar: (id: number) => Promise getShares: () => Promise<{ outgoing: VacayShareOutgoing[]; incoming: VacayShareIncoming[] }> share: (userId: number) => Promise @@ -105,6 +113,12 @@ const api: VacayApi = { updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data), getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data), getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data), + getSchoolHolidays: (year, country, subdivision, group) => { + const params = new URLSearchParams() + if (group) params.set('group', group) + const qs = params.toString() + return ax.get(`/addons/vacay/school-holidays/${year}/${country}${subdivision ? `/${subdivision}` : ''}${qs ? `?${qs}` : ''}`).then((r: AxiosResponse) => r.data) + }, addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data), updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data), deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data), @@ -116,6 +130,42 @@ const api: VacayApi = { getSharedCalendars: (year) => ax.get(`/addons/vacay/shares/calendars/${year}`).then((r: AxiosResponse) => r.data), } +function pushHolidayMarker(map: HolidaysMap, date: string, marker: HolidayInfo) { + const existing = map[date] + if (!existing) { + map[date] = [marker] + return + } + map[date] = Array.isArray(existing) ? [...existing, marker] : [existing, marker] +} + +function parseCalendarRegion(region: string): { country: string; subdivision: string | null; group: string | null } { + const [base, meta] = region.split('|') + const group = meta?.startsWith('group:') ? meta.slice('group:'.length) : null + return { + country: base.split('-')[0], + subdivision: group ? null : base.includes('-') ? base : null, + group, + } +} + +function schoolHolidayName(h: VacaySchoolHolidayRaw): string { + if (typeof h.name === 'string') return h.name + const names = Array.isArray(h.name) ? h.name : [] + return names.find(n => n.language?.toUpperCase() === 'DE')?.text || names[0]?.text || 'School holidays' +} + +function datesBetween(startStr: string, endStr: string): string[] { + const dates: string[] = [] + const start = new Date(startStr + 'T00:00:00') + const end = new Date(endStr + 'T00:00:00') + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return dates + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`) + } + return dates +} + interface VacayState { plan: VacayPlan | null users: VacayUser[] @@ -154,8 +204,8 @@ interface VacayState { loadStats: (year?: number) => Promise updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise loadHolidays: (year?: number) => Promise - addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise - updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise deleteHolidayCalendar: (id: number) => Promise loadShares: () => Promise loadSharedCalendars: (year?: number) => Promise @@ -331,25 +381,38 @@ export const useVacayStore = create((set, get) => ({ const y = year || get().selectedYear const plan = get().plan const calendars = plan?.holiday_calendars ?? [] - if (!plan?.holidays_enabled || calendars.length === 0) { + const enabledCalendars = calendars.filter(cal => + (cal.type === 'school_holiday' && plan?.school_holidays_enabled) || + ((cal.type ?? 'public_holiday') === 'public_holiday' && plan?.holidays_enabled) + ) + if (enabledCalendars.length === 0) { set({ holidays: {} }) return } const map: HolidaysMap = {} - for (const cal of calendars) { - const country = cal.region.split('-')[0] - const region = cal.region.includes('-') ? cal.region : null + for (const cal of enabledCalendars) { + const { country, subdivision, group } = parseCalendarRegion(cal.region) try { - const data = await api.getHolidays(y, country) - const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) - if (hasRegions && !region) continue - data.forEach((h: VacayHolidayRaw) => { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - if (!map[h.date]) { - map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label } + 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' }) + }) + }) + } 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 */ } } set({ holidays: map }) diff --git a/client/src/types.ts b/client/src/types.ts index c9b689916..e3cea8172 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -266,6 +266,7 @@ export interface WebSocketEvent { export interface VacayHolidayCalendar { id: number plan_id: number + type?: 'public_holiday' | 'school_holiday' region: string label: string | null color: string @@ -275,6 +276,7 @@ export interface VacayHolidayCalendar { export interface VacayPlan { id: number holidays_enabled: boolean + school_holidays_enabled?: boolean holidays_region: string | null holiday_calendars: VacayHolidayCalendar[] block_weekends: boolean @@ -326,10 +328,11 @@ export interface HolidayInfo { localName: string color: string label: string | null + type?: 'public_holiday' | 'school_holiday' } export interface HolidaysMap { - [date: string]: HolidayInfo + [date: string]: HolidayInfo | HolidayInfo[] } // Read-only calendar shares (#444/#667) diff --git a/client/src/vacay/schoolHolidayCountries.ts b/client/src/vacay/schoolHolidayCountries.ts new file mode 100644 index 000000000..a64979a8b --- /dev/null +++ b/client/src/vacay/schoolHolidayCountries.ts @@ -0,0 +1,26 @@ +export type SchoolHolidayStrategy = 'country' | 'groups' | 'subdivisions' + +// OpenHolidays models school holidays differently per country. Keep this list +// explicit so new countries are only exposed after source coverage was checked. +export const SCHOOL_HOLIDAY_COUNTRY_CONFIG: Record = { + AT: { strategy: 'subdivisions', status: 'green' }, + BE: { strategy: 'groups', status: 'yellow' }, + CH: { strategy: 'subdivisions', status: 'yellow' }, + CZ: { strategy: 'subdivisions', status: 'green' }, + DE: { strategy: 'subdivisions', status: 'yellow' }, + EE: { strategy: 'country', status: 'green' }, + ES: { strategy: 'subdivisions', status: 'green' }, + FR: { strategy: 'subdivisions', status: 'green' }, + IE: { strategy: 'country', status: 'yellow' }, + IT: { strategy: 'subdivisions', status: 'yellow' }, + NL: { strategy: 'groups', status: 'yellow' }, + PL: { strategy: 'subdivisions', status: 'yellow' }, + PT: { strategy: 'subdivisions', status: 'green' }, + RS: { strategy: 'country', status: 'yellow' }, + SI: { strategy: 'subdivisions', status: 'yellow' }, + SK: { strategy: 'subdivisions', status: 'yellow' }, +} + +export function isSchoolHolidayCountrySupported(country: string): boolean { + return country in SCHOOL_HOLIDAY_COUNTRY_CONFIG +} diff --git a/client/tests/helpers/msw/handlers/vacay.ts b/client/tests/helpers/msw/handlers/vacay.ts index 1b305820f..e25e44f59 100644 --- a/client/tests/helpers/msw/handlers/vacay.ts +++ b/client/tests/helpers/msw/handlers/vacay.ts @@ -85,6 +85,17 @@ export const vacayHandlers = [ ]); }), + http.get('/api/addons/vacay/school-holidays/regions/:country', () => { + return HttpResponse.json({ + groups: [ + { code: 'NL-NO', shortName: 'NO', name: [{ language: 'EN', text: 'Northern Region' }] }, + { code: 'NL-MI', shortName: 'MI', name: [{ language: 'EN', text: 'Central Region' }] }, + { code: 'NL-ZU', shortName: 'ZU', name: [{ language: 'EN', text: 'Southern Region' }] }, + ], + subdivisions: [], + }); + }), + http.put('/api/addons/vacay/color', () => { return HttpResponse.json({ success: true }); }), diff --git a/client/tests/unit/stores/vacayStore.test.ts b/client/tests/unit/stores/vacayStore.test.ts index f8efc3eb1..dd1a1b492 100644 --- a/client/tests/unit/stores/vacayStore.test.ts +++ b/client/tests/unit/stores/vacayStore.test.ts @@ -77,7 +77,8 @@ describe('vacayStore', () => { expect(Object.keys(state.holidays).length).toBeGreaterThan(0); expect(state.holidays['2025-12-25']).toBeDefined(); - expect(state.holidays['2025-12-25'].name).toBe('Christmas'); + const christmas = state.holidays['2025-12-25']; + expect(Array.isArray(christmas) ? christmas[0].name : christmas.name).toBe('Christmas'); }); }); @@ -537,4 +538,121 @@ describe('vacayStore', () => { expect(state.sharedCalendars[0].hidden).toBe(false); }); }); + + describe('FE-STORE-VACAY-027: loadHolidays() with national school holiday calendar', () => { + it('loads school holidays when the calendar has no subdivision', async () => { + useVacayStore.setState({ + selectedYear: 2026, + plan: { + id: 1, + holidays_enabled: false, + school_holidays_enabled: true, + holidays_region: null, + holiday_calendars: [ + { id: 1, plan_id: 1, region: 'NL', label: 'Niederlande', color: '#22c55e', sort_order: 0, type: 'school_holiday' }, + ], + block_weekends: false, + carry_over_enabled: false, + company_holidays_enabled: false, + }, + }); + + let requestedUrl = ''; + server.use( + http.get('/api/addons/vacay/school-holidays/:year/:country', ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json([ + { + id: 'nl-summer-2026', + startDate: '2026-07-18', + endDate: '2026-08-30', + name: [{ language: 'EN', text: 'Summer holidays' }], + }, + ]); + }) + ); + + await useVacayStore.getState().loadHolidays(2026); + const holidays = useVacayStore.getState().holidays; + + expect(requestedUrl).toContain('/school-holidays/2026/NL'); + expect(holidays['2026-07-18']).toBeDefined(); + const summer = holidays['2026-07-18']; + expect(Array.isArray(summer) ? summer[0].type : summer.type).toBe('school_holiday'); + }); + }); + + describe('FE-STORE-VACAY-028: loadHolidays() with school holiday group calendar', () => { + it('passes the OpenHolidays group code for regional school holiday zones', async () => { + useVacayStore.setState({ + selectedYear: 2026, + plan: { + id: 1, + holidays_enabled: false, + school_holidays_enabled: true, + holidays_region: null, + holiday_calendars: [ + { id: 1, plan_id: 1, region: 'NL|group:NL-NO', label: 'Noord', color: '#22c55e', sort_order: 0, type: 'school_holiday' }, + ], + block_weekends: false, + carry_over_enabled: false, + company_holidays_enabled: false, + }, + }); + + let requestedUrl = ''; + server.use( + http.get('/api/addons/vacay/school-holidays/:year/:country', ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json([ + { + id: 'nl-north-2026', + startDate: '2026-07-04', + endDate: '2026-08-16', + name: [{ language: 'EN', text: 'Summer holidays' }], + }, + ]); + }) + ); + + await useVacayStore.getState().loadHolidays(2026); + + expect(requestedUrl).toContain('/school-holidays/2026/NL'); + expect(requestedUrl).toContain('group=NL-NO'); + expect(useVacayStore.getState().holidays['2026-07-04']).toBeDefined(); + }); + }); + + describe('FE-STORE-VACAY-029: loadHolidays() with unsupported school holiday country', () => { + it('skips school holiday calendars that are not in the approved whitelist', async () => { + useVacayStore.setState({ + selectedYear: 2026, + plan: { + id: 1, + holidays_enabled: false, + school_holidays_enabled: true, + holidays_region: null, + holiday_calendars: [ + { id: 1, plan_id: 1, region: 'SE', label: 'Sweden', color: '#22c55e', sort_order: 0, type: 'school_holiday' }, + ], + block_weekends: false, + carry_over_enabled: false, + company_holidays_enabled: false, + }, + }); + + let requested = false; + server.use( + http.get('/api/addons/vacay/school-holidays/:year/:country', () => { + requested = true; + return HttpResponse.json([]); + }) + ); + + await useVacayStore.getState().loadHolidays(2026); + + expect(requested).toBe(false); + expect(useVacayStore.getState().holidays).toEqual({}); + }); + }); }); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 13efe0f49..9a6cd18cb 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3771,6 +3771,21 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, + // School holidays are a visual Vacay calendar layer. Keep them separate from + // public holidays so applyHolidayCalendars never removes vacation entries for + // school-break dates. Appended LAST: this branch was cut before #1435/#1281 + // landed on dev, so an existing DB is already past their slots — only a + // trailing migration re-runs on upgrade and actually adds these columns. + () => { + const planCols = db.prepare("PRAGMA table_info('vacay_plans')").all() as Array<{ name: string }>; + if (!planCols.some(col => col.name === 'school_holidays_enabled')) { + db.exec('ALTER TABLE vacay_plans ADD COLUMN school_holidays_enabled INTEGER DEFAULT 0'); + } + const calendarCols = db.prepare("PRAGMA table_info('vacay_holiday_calendars')").all() as Array<{ name: string }>; + if (!calendarCols.some(col => col.name === 'type')) { + db.exec("ALTER TABLE vacay_holiday_calendars ADD COLUMN type TEXT NOT NULL DEFAULT 'public_holiday'"); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 96fa363ed..c00f4af53 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -314,6 +314,7 @@ function createTables(db: Database.Database): void { block_weekends INTEGER DEFAULT 1, holidays_enabled INTEGER DEFAULT 0, holidays_region TEXT DEFAULT '', + school_holidays_enabled INTEGER DEFAULT 0, company_holidays_enabled INTEGER DEFAULT 1, carry_over_enabled INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -374,6 +375,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT, color TEXT NOT NULL DEFAULT '#fecaca', diff --git a/server/src/nest/vacay/vacay.controller.ts b/server/src/nest/vacay/vacay.controller.ts index 36423d331..59042cedf 100644 --- a/server/src/nest/vacay/vacay.controller.ts +++ b/server/src/nest/vacay/vacay.controller.ts @@ -9,6 +9,7 @@ import { Param, Post, Put, + Query, UseGuards, } from '@nestjs/common'; import { vacayShareUpdateRequestSchema, type VacayShareUpdateRequest } from '@trek/shared'; @@ -47,14 +48,14 @@ export class VacayController { @HttpCode(200) addHolidayCalendar( @CurrentUser() user: User, - @Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number }, + @Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' }, @Headers('x-socket-id') socketId?: string, ) { if (!body.region) { throw new HttpException({ error: 'region required' }, 400); } const planId = this.vacay.getActivePlanId(user.id); - const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId); + const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type); return { calendar }; } @@ -307,4 +308,40 @@ export class VacayController { } return result.data; } + + @Get('school-holidays/regions/:country') + async schoolHolidayRegions(@Param('country') country: string) { + const result = await this.vacay.getSchoolHolidayRegions(country, country.toUpperCase() === 'DE' ? 'DE' : 'EN'); + if (result.error) { + throw new HttpException({ error: result.error }, 502); + } + return result.data; + } + + @Get('school-holidays/:year/:country') + async schoolHolidaysForCountry( + @Param('year') year: string, + @Param('country') country: string, + @Query('group') group?: string, + ) { + return this.schoolHolidays(year, country, undefined, group); + } + + @Get('school-holidays/:year/:country/:subdivision') + async schoolHolidaysForSubdivision( + @Param('year') year: string, + @Param('country') country: string, + @Param('subdivision') subdivision: string, + @Query('group') group?: string, + ) { + return this.schoolHolidays(year, country, subdivision, group); + } + + private async schoolHolidays(year: string, country: string, subdivision?: string, group?: string) { + const result = await this.vacay.getSchoolHolidays(year, country, subdivision, country.toUpperCase() === 'DE' ? 'DE' : 'EN', group); + if (result.error) { + throw new HttpException({ error: result.error }, 502); + } + return result.data; + } } diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 58ca54745..98de80ace 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -26,8 +26,8 @@ export class VacayService { return svc.updatePlan(planId, body, socketId); } - addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) { - return svc.addHolidayCalendar(planId, region, label, color, sortOrder, socketId); + addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type?: 'public_holiday' | 'school_holiday') { + return svc.addHolidayCalendar(planId, region, label, color, sortOrder, socketId, type); } updateHolidayCalendar(id: number, planId: number, body: Parameters[2], socketId: string | undefined) { @@ -106,6 +106,10 @@ export class VacayService { return svc.getCountries(); } + getSchoolHolidayRegions(country: string, language?: string) { + return svc.getSchoolHolidayRegions(country, language); + } + getHolidays(year: string, country: string) { return svc.getHolidays(year, country); } @@ -133,4 +137,8 @@ export class VacayService { getSharedCalendars(viewerId: number, year: string) { return svc.getSharedCalendars(viewerId, year); } + + getSchoolHolidays(year: string, country: string, subdivision?: string | null, language?: string, group?: string | null) { + return svc.getSchoolHolidays(year, country, subdivision, language, group); + } } diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts index 7df1a54ed..07e36ff08 100644 --- a/server/src/services/vacayService.ts +++ b/server/src/services/vacayService.ts @@ -10,6 +10,7 @@ export interface VacayPlan { block_weekends: number; holidays_enabled: number; holidays_region: string | null; + school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; } @@ -44,9 +45,16 @@ export interface Holiday { counties?: string[] | null; } +export interface SchoolHoliday { + startDate?: string; + endDate?: string; + name?: Array<{ language?: string; text?: string }> | string; +} + export interface VacayHolidayCalendar { id: number; plan_id: number; + type: 'public_holiday' | 'school_holiday'; region: string; label: string | null; color: string; @@ -196,7 +204,7 @@ export function notifyShareViewers(ownerIds: number[], excludeSid?: string): voi export async function applyHolidayCalendars(planId: number): Promise { const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; if (!plan?.holidays_enabled) return; - const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + const calendars = db.prepare("SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id").all(planId) as VacayHolidayCalendar[]; if (calendars.length === 0) return; const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; for (const cal of calendars) { @@ -242,6 +250,7 @@ export interface UpdatePlanBody { block_weekends?: boolean; holidays_enabled?: boolean; holidays_region?: string; + school_holidays_enabled?: boolean; company_holidays_enabled?: boolean; carry_over_enabled?: boolean; weekend_days?: string; @@ -249,13 +258,14 @@ export interface UpdatePlanBody { } export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) { - const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body; + const { block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body; const updates: string[] = []; const params: (string | number)[] = []; if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); } if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); } if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); } + if (school_holidays_enabled !== undefined) { updates.push('school_holidays_enabled = ?'); params.push(school_holidays_enabled ? 1 : 0); } if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); } if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); } if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); } @@ -309,6 +319,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, + school_holidays_enabled: !!updated.school_holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars, @@ -320,10 +331,10 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: // Holiday calendars CRUD // --------------------------------------------------------------------------- -export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) { +export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') { const result = db.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' - ).run(planId, region, label || null, color || '#fecaca', sortOrder ?? 0); + 'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run(planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0); const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; notifyPlanUsers(planId, socketId, 'vacay:settings'); return cal; @@ -332,15 +343,16 @@ export function addHolidayCalendar(planId: number, region: string, label: string export function updateHolidayCalendar( calId: number, planId: number, - body: { region?: string; label?: string | null; color?: string; sort_order?: number }, + body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' }, socketId: string | undefined, ): VacayHolidayCalendar | null { const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId) as VacayHolidayCalendar | undefined; if (!cal) return null; - const { region, label, color, sort_order } = body; + const { region, label, color, sort_order, type } = body; const updates: string[] = []; const params: (string | number | null)[] = []; if (region !== undefined) { updates.push('region = ?'); params.push(region); } + if (type !== undefined) { updates.push('type = ?'); params.push(type); } if (label !== undefined) { updates.push('label = ?'); params.push(label); } if (color !== undefined) { updates.push('color = ?'); params.push(color); } if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } @@ -904,6 +916,7 @@ export function getPlanData(userId: number) { ...plan, block_weekends: !!plan.block_weekends, holidays_enabled: !!plan.holidays_enabled, + school_holidays_enabled: !!plan.school_holidays_enabled, company_holidays_enabled: !!plan.company_holidays_enabled, carry_over_enabled: !!plan.carry_over_enabled, holiday_calendars: holidayCalendars, @@ -947,3 +960,53 @@ export async function getHolidays(year: string, country: string): Promise<{ data return { error: 'Failed to fetch holidays' }; } } + +export async function getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> { + const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); + const cacheKey = `school-regions-${country}-${normalizedLanguage}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const [groupsResp, subdivisionsResp] = await Promise.all([ + fetch(`https://openholidaysapi.org/Groups?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), + fetch(`https://openholidaysapi.org/Subdivisions?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), + ]); + if (!groupsResp.ok || !subdivisionsResp.ok) return { error: 'Failed to fetch school holiday regions' }; + const data = { + groups: await groupsResp.json(), + subdivisions: await subdivisionsResp.json(), + }; + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch school holiday regions' }; + } +} + +export async function getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> { + const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); + const normalizedSubdivision = subdivision || ''; + const normalizedGroup = group || ''; + const cacheKey = `school-${year}-${country}-${normalizedSubdivision || 'all'}-${normalizedGroup || 'all'}-${normalizedLanguage}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const params = new URLSearchParams({ + countryIsoCode: country, + languageIsoCode: normalizedLanguage, + validFrom: `${year}-01-01`, + validTo: `${year}-12-31`, + }); + if (normalizedSubdivision) params.set('subdivisionCode', normalizedSubdivision); + if (normalizedGroup) params.set('groupCode', normalizedGroup); + const resp = await fetch(`https://openholidaysapi.org/SchoolHolidays?${params.toString()}`, { + headers: { accept: 'text/json' }, + }); + if (!resp.ok) return { error: 'Failed to fetch school holidays' }; + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch school holidays' }; + } +} diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index daa91f8ef..44855d120 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -46,7 +46,7 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' }); const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock'); expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } }); - expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock'); + expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock', undefined); }); it('404 on update of a missing calendar', () => { diff --git a/shared/src/i18n/ar/vacay.ts b/shared/src/i18n/ar/vacay.ts index e6c16de87..15c63cc70 100644 --- a/shared/src/i18n/ar/vacay.ts +++ b/shared/src/i18n/ar/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'السبت', 'vacay.sun': 'الأحد', 'vacay.publicHolidays': 'العطل الرسمية', - 'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'اختر الدولة', 'vacay.selectRegion': 'اختر المنطقة (اختياري)', 'vacay.addCalendar': 'إضافة تقويم', 'vacay.calendarLabel': 'التسمية', 'vacay.calendarColor': 'اللون', - 'vacay.noCalendars': 'لا توجد تقويمات', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'عطل الشركة', 'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة', 'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.', @@ -106,5 +107,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'إخفاء من التقويم', 'vacay.sharedEmpty': 'اعرضا تقويميكما لبعضكما البعض دون دمجهما.', 'vacay.sharedLegend': 'مشترك (عرض فقط)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/br/vacay.ts b/shared/src/i18n/br/vacay.ts index aec9d25ee..08535a363 100644 --- a/shared/src/i18n/br/vacay.ts +++ b/shared/src/i18n/br/vacay.ts @@ -47,13 +47,14 @@ const vacay: TranslationStrings = { 'vacay.sun': 'Dom', 'vacay.blockWeekendsHint': 'Impedir entradas de férias aos sábados e domingos', 'vacay.publicHolidays': 'Feriados nacionais', - 'vacay.publicHolidaysHint': 'Marcar feriados nacionais no calendário', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Selecione o país', 'vacay.selectRegion': 'Selecione a região (opcional)', 'vacay.addCalendar': 'Adicionar calendário', 'vacay.calendarLabel': 'Rótulo (opcional)', 'vacay.calendarColor': 'Cor', - 'vacay.noCalendars': 'Nenhum calendário de feriados adicionado ainda', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Feriados da empresa', 'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa', 'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.', @@ -109,5 +110,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Ocultar do calendário', 'vacay.sharedEmpty': 'Mostrem seus calendários um ao outro sem fundi-los.', 'vacay.sharedLegend': 'Compartilhado (somente visualização)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/ca/vacay.ts b/shared/src/i18n/ca/vacay.ts index 467b637e9..5e7fc8385 100644 --- a/shared/src/i18n/ca/vacay.ts +++ b/shared/src/i18n/ca/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Ds', 'vacay.sun': 'Dg', 'vacay.publicHolidays': 'Festius', - 'vacay.publicHolidaysHint': 'Marca festius al calendari', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Selecciona un país', 'vacay.selectRegion': 'Selecciona una regió (opcional)', 'vacay.companyHolidays': "Festius de l'empresa", @@ -107,6 +109,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Afegeix un calendari', 'vacay.calendarColor': 'Color del calendari', 'vacay.calendarLabel': 'Etiqueta', - 'vacay.noCalendars': 'Sense calendaris', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/cs/vacay.ts b/shared/src/i18n/cs/vacay.ts index c95043764..caf5867e4 100644 --- a/shared/src/i18n/cs/vacay.ts +++ b/shared/src/i18n/cs/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sun': 'Ne', 'vacay.weekendDays': 'Víkendové dny', 'vacay.publicHolidays': 'Státní svátky', - 'vacay.publicHolidaysHint': 'Zobrazit státní svátky v kalendáři', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Vyberte zemi', 'vacay.selectRegion': 'Vyberte region (volitelné)', 'vacay.addCalendar': 'Přidat kalendář', 'vacay.calendarLabel': 'Popisek (volitelné)', 'vacay.calendarColor': 'Barva', - 'vacay.noCalendars': 'Zatím nebyly přidány žádné svátkové kalendáře', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Firemní volno', 'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna', 'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Skrýt z kalendáře', 'vacay.sharedEmpty': 'Ukažte si navzájem své kalendáře, aniž byste je propojili.', 'vacay.sharedLegend': 'Sdílené (pouze pro čtení)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/de/vacay.ts b/shared/src/i18n/de/vacay.ts index 46d49a91d..f0c522539 100644 --- a/shared/src/i18n/de/vacay.ts +++ b/shared/src/i18n/de/vacay.ts @@ -47,12 +47,15 @@ const vacay: TranslationStrings = { 'vacay.sun': 'So', 'vacay.publicHolidays': 'Feiertage', 'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren', + 'vacay.schoolHolidays': 'Schulferien', + 'vacay.schoolHolidaysHint': 'Schulferien als visuelle Kalender-Ebene markieren', 'vacay.selectCountry': 'Land wählen', 'vacay.selectRegion': 'Region wählen (optional)', 'vacay.addCalendar': 'Kalender hinzufügen', 'vacay.calendarLabel': 'Bezeichnung (optional)', 'vacay.calendarColor': 'Farbe', 'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt', + 'vacay.noSchoolCalendars': 'Noch keine Schulferienkalender angelegt', 'vacay.companyHolidays': 'Betriebsferien', 'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen', 'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.', diff --git a/shared/src/i18n/en/vacay.ts b/shared/src/i18n/en/vacay.ts index 6fcd65753..2212bfde5 100644 --- a/shared/src/i18n/en/vacay.ts +++ b/shared/src/i18n/en/vacay.ts @@ -47,12 +47,15 @@ const vacay: TranslationStrings = { 'vacay.sun': 'Sun', 'vacay.publicHolidays': 'Public Holidays', 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Select country', 'vacay.selectRegion': 'Select region (optional)', 'vacay.addCalendar': 'Add calendar', 'vacay.calendarLabel': 'Label (optional)', 'vacay.calendarColor': 'Color', 'vacay.noCalendars': 'No holiday calendars added yet', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Company Holidays', 'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days', 'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.', diff --git a/shared/src/i18n/es/vacay.ts b/shared/src/i18n/es/vacay.ts index 01fd6cd31..15fdfb033 100644 --- a/shared/src/i18n/es/vacay.ts +++ b/shared/src/i18n/es/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Sáb', 'vacay.sun': 'Dom', 'vacay.publicHolidays': 'Festivos', - 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Seleccionar país', 'vacay.selectRegion': 'Seleccionar región (opcional)', 'vacay.companyHolidays': 'Festivos de empresa', @@ -107,6 +109,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Añadir calendario', 'vacay.calendarColor': 'Color del calendario', 'vacay.calendarLabel': 'Etiqueta', - 'vacay.noCalendars': 'Sin calendarios', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/fr/vacay.ts b/shared/src/i18n/fr/vacay.ts index 45c2644ae..0a9aab91c 100644 --- a/shared/src/i18n/fr/vacay.ts +++ b/shared/src/i18n/fr/vacay.ts @@ -47,7 +47,9 @@ const vacay: TranslationStrings = { 'vacay.blockWeekends': 'Bloquer les week-ends', 'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches', 'vacay.publicHolidays': 'Jours fériés', - 'vacay.publicHolidaysHint': 'Marquer les jours fériés dans le calendrier', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Sélectionner un pays', 'vacay.selectRegion': 'Sélectionner une région (facultatif)', 'vacay.companyHolidays': "Jours fériés d'entreprise", @@ -108,6 +110,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Ajouter un calendrier', 'vacay.calendarColor': 'Couleur', 'vacay.calendarLabel': 'Libellé', - 'vacay.noCalendars': 'Aucun calendrier', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/gr/vacay.ts b/shared/src/i18n/gr/vacay.ts index 377c2f3bd..26e20959e 100644 --- a/shared/src/i18n/gr/vacay.ts +++ b/shared/src/i18n/gr/vacay.ts @@ -47,13 +47,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Σαβ', 'vacay.sun': 'Κυρ', 'vacay.publicHolidays': 'Δημόσιες Αργίες', - 'vacay.publicHolidaysHint': 'Σήμανση δημόσιων αργιών στο ημερολόγιο', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Επιλογή χώρας', 'vacay.selectRegion': 'Επιλογή περιοχής (προαιρετικό)', 'vacay.addCalendar': 'Προσθήκη ημερολογίου', 'vacay.calendarLabel': 'Ετικέτα (προαιρετικό)', 'vacay.calendarColor': 'Χρώμα', - 'vacay.noCalendars': 'Δεν έχουν προστεθεί ημερολόγια αργιών ακόμη', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Αργίες Εταιρείας', 'vacay.companyHolidaysHint': 'Επιτρέψτε τη σήμανση αργιών για όλη την εταιρεία', 'vacay.companyHolidaysNoDeduct': 'Οι αργίες της εταιρείας δεν προσμετρώνται στις ημέρες διακοπών.', @@ -109,5 +110,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Απόκρυψη από το ημερολόγιο', 'vacay.sharedEmpty': 'Δείξτε ο ένας στον άλλο τα ημερολόγιά σας χωρίς να τα συγχωνεύσετε.', 'vacay.sharedLegend': 'Κοινόχρηστο (μόνο προβολή)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/hu/vacay.ts b/shared/src/i18n/hu/vacay.ts index 624413389..19c5b04a9 100644 --- a/shared/src/i18n/hu/vacay.ts +++ b/shared/src/i18n/hu/vacay.ts @@ -38,13 +38,14 @@ const vacay: TranslationStrings = { 'vacay.blockWeekends': 'Hétvégék zárolása', 'vacay.blockWeekendsHint': 'Szabadság-bejegyzések megakadályozása szombaton és vasárnap', 'vacay.publicHolidays': 'Ünnepnapok', - 'vacay.publicHolidaysHint': 'Ünnepnapok megjelölése a naptárban', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Ország kiválasztása', 'vacay.selectRegion': 'Régió kiválasztása (opcionális)', 'vacay.addCalendar': 'Naptár hozzáadása', 'vacay.calendarLabel': 'Címke (opcionális)', 'vacay.calendarColor': 'Szín', - 'vacay.noCalendars': 'Még nincsenek ünnepnap-naptárak hozzáadva', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.weekendDays': 'Hétvégi napok', 'vacay.mon': 'Hé', 'vacay.tue': 'Ke', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Elrejtés a naptárból', 'vacay.sharedEmpty': 'Mutassátok meg egymásnak a naptáraitokat összevonás nélkül.', 'vacay.sharedLegend': 'Megosztott (csak megtekintés)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/id/vacay.ts b/shared/src/i18n/id/vacay.ts index dccc23245..e7271728b 100644 --- a/shared/src/i18n/id/vacay.ts +++ b/shared/src/i18n/id/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Sab', 'vacay.sun': 'Min', 'vacay.publicHolidays': 'Hari Libur Nasional', - 'vacay.publicHolidaysHint': 'Tandai hari libur nasional di kalender', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Pilih negara', 'vacay.selectRegion': 'Pilih wilayah (opsional)', 'vacay.addCalendar': 'Tambah kalender', 'vacay.calendarLabel': 'Label (opsional)', 'vacay.calendarColor': 'Warna', - 'vacay.noCalendars': 'Belum ada kalender hari libur ditambahkan', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Hari Libur Perusahaan', 'vacay.companyHolidaysHint': 'Izinkan penandaan hari libur seluruh perusahaan', 'vacay.companyHolidaysNoDeduct': 'Hari libur perusahaan tidak dipotong dari jatah cuti.', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Sembunyikan dari kalender', 'vacay.sharedEmpty': 'Saling tunjukkan kalender kalian tanpa menggabungkannya.', 'vacay.sharedLegend': 'Dibagikan (hanya lihat)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/it/vacay.ts b/shared/src/i18n/it/vacay.ts index 5d83833c9..2e14898e6 100644 --- a/shared/src/i18n/it/vacay.ts +++ b/shared/src/i18n/it/vacay.ts @@ -47,13 +47,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Sab', 'vacay.sun': 'Dom', 'vacay.publicHolidays': 'Festività pubbliche', - 'vacay.publicHolidaysHint': 'Segna le festività pubbliche nel calendario', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Seleziona paese', 'vacay.selectRegion': 'Seleziona regione (opzionale)', 'vacay.addCalendar': 'Aggiungi calendario', 'vacay.calendarLabel': 'Etichetta (opzionale)', 'vacay.calendarColor': 'Colore', - 'vacay.noCalendars': 'Ancora nessun calendario delle festività aggiunto', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Ferie aziendali', 'vacay.companyHolidaysHint': 'Consenti di segnare giorni di ferie aziendali', 'vacay.companyHolidaysNoDeduct': 'Le ferie aziendali non vengono conteggiate nei giorni di ferie.', @@ -109,5 +110,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Nascondi dal calendario', 'vacay.sharedEmpty': 'Mostratevi i calendari a vicenda senza unirli.', 'vacay.sharedLegend': 'Condiviso (sola visualizzazione)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/ja/vacay.ts b/shared/src/i18n/ja/vacay.ts index a0418a9c2..230bc8849 100644 --- a/shared/src/i18n/ja/vacay.ts +++ b/shared/src/i18n/ja/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': '土', 'vacay.sun': '日', 'vacay.publicHolidays': '祝日', - 'vacay.publicHolidaysHint': 'カレンダーに祝日を表示', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': '国を選択', 'vacay.selectRegion': '地域を選択(任意)', 'vacay.addCalendar': 'カレンダーを追加', 'vacay.calendarLabel': 'ラベル(任意)', 'vacay.calendarColor': '色', - 'vacay.noCalendars': '祝日カレンダーはまだありません', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': '会社休日', 'vacay.companyHolidaysHint': '会社全体の休日を設定できます', 'vacay.companyHolidaysNoDeduct': '会社休日は休暇日数に含まれません。', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'カレンダーから非表示', 'vacay.sharedEmpty': '統合せずにお互いのカレンダーを見せ合えます。', 'vacay.sharedLegend': '共有(閲覧のみ)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/ko/vacay.ts b/shared/src/i18n/ko/vacay.ts index 5183b464e..ee33d64f4 100644 --- a/shared/src/i18n/ko/vacay.ts +++ b/shared/src/i18n/ko/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': '토', 'vacay.sun': '일', 'vacay.publicHolidays': '공휴일', - 'vacay.publicHolidaysHint': '캘린더에 공휴일을 표시합니다', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': '국가 선택', 'vacay.selectRegion': '지역 선택 (선택)', 'vacay.addCalendar': '캘린더 추가', 'vacay.calendarLabel': '레이블 (선택)', 'vacay.calendarColor': '색상', - 'vacay.noCalendars': '아직 공휴일 캘린더가 없습니다', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': '회사 휴일', 'vacay.companyHolidaysHint': '회사 전체 휴일 표시를 허용합니다', 'vacay.companyHolidaysNoDeduct': '회사 휴일은 휴가 일수에서 차감되지 않습니다.', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': '캘린더에서 숨기기', 'vacay.sharedEmpty': '캘린더를 합치지 않고 서로에게 보여주세요.', 'vacay.sharedLegend': '공유됨 (보기 전용)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/nl/vacay.ts b/shared/src/i18n/nl/vacay.ts index 21122c2ae..e73191eb8 100644 --- a/shared/src/i18n/nl/vacay.ts +++ b/shared/src/i18n/nl/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Za', 'vacay.sun': 'Zo', 'vacay.publicHolidays': 'Feestdagen', - 'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Selecteer land', 'vacay.selectRegion': 'Selecteer regio (optioneel)', 'vacay.companyHolidays': 'Bedrijfsvakanties', @@ -106,6 +108,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Kalender toevoegen', 'vacay.calendarColor': 'Kleur', 'vacay.calendarLabel': 'Label', - 'vacay.noCalendars': 'Geen kalenders', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/pl/vacay.ts b/shared/src/i18n/pl/vacay.ts index 10dfa1157..131a83902 100644 --- a/shared/src/i18n/pl/vacay.ts +++ b/shared/src/i18n/pl/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Sob', 'vacay.sun': 'Nd', 'vacay.publicHolidays': 'Święta państwowe', - 'vacay.publicHolidaysHint': 'Oznacz święta państwowe w kalendarzu', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Wybierz kraj', 'vacay.selectRegion': 'Wybierz region (opcjonalnie)', 'vacay.addCalendar': 'Dodaj kalendarz', 'vacay.calendarLabel': 'Etykieta (opcjonalnie)', 'vacay.calendarColor': 'Kolor', - 'vacay.noCalendars': 'Nie dodano jeszcze kalendarzy świąt', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Urlopy firmowe', 'vacay.companyHolidaysHint': 'Pozwala oznaczać dni wolne od pracy w kalendarzu', 'vacay.companyHolidaysNoDeduct': 'Urlopy firmowe nie są odejmowane od puli dni urlopowych.', @@ -108,5 +109,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Ukryj w kalendarzu', 'vacay.sharedEmpty': 'Pokażcie sobie nawzajem swoje kalendarze bez ich łączenia.', 'vacay.sharedLegend': 'Udostępnione (tylko podgląd)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/ru/vacay.ts b/shared/src/i18n/ru/vacay.ts index 65eb266e0..89e1b4dc9 100644 --- a/shared/src/i18n/ru/vacay.ts +++ b/shared/src/i18n/ru/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Сб', 'vacay.sun': 'Вс', 'vacay.publicHolidays': 'Государственные праздники', - 'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Выберите страну', 'vacay.selectRegion': 'Выберите регион (необязательно)', 'vacay.companyHolidays': 'Корпоративные выходные', @@ -106,6 +108,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Добавить календарь', 'vacay.calendarColor': 'Цвет', 'vacay.calendarLabel': 'Название', - 'vacay.noCalendars': 'Нет календарей', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/sv/vacay.ts b/shared/src/i18n/sv/vacay.ts index 2e100e102..92a087047 100644 --- a/shared/src/i18n/sv/vacay.ts +++ b/shared/src/i18n/sv/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Lör', 'vacay.sun': 'Sön', 'vacay.publicHolidays': 'Allmänna helgdagar', - 'vacay.publicHolidaysHint': 'Markera allmäna helgdagar i kalendern', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Välj land', 'vacay.selectRegion': 'Välj region (valfritt)', 'vacay.addCalendar': 'Lägg till kalender', 'vacay.calendarLabel': 'Etikett (valfritt)', 'vacay.calendarColor': 'Färg', - 'vacay.noCalendars': 'Inga helgdagskalendrar har ännu tillagts', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Företagshelger', 'vacay.companyHolidaysHint': 'Tillåt markering av helgdagar som gäller för hela företaget', 'vacay.companyHolidaysNoDeduct': 'Företagets helgdagar räknas inte in i semesterdagarna.', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Dölj från kalendern', 'vacay.sharedEmpty': 'Visa varandra era kalendrar utan att slå samman dem.', 'vacay.sharedLegend': 'Delad (endast visning)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/tr/vacay.ts b/shared/src/i18n/tr/vacay.ts index af33922d0..faffeeee9 100644 --- a/shared/src/i18n/tr/vacay.ts +++ b/shared/src/i18n/tr/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Doygunluk', 'vacay.sun': 'Güneş', 'vacay.publicHolidays': 'Resmi Tatiller', - 'vacay.publicHolidaysHint': 'Resmi tatil günlerini takvimde işaretleyin', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Ülke seçin', 'vacay.selectRegion': 'Bölge seçin (isteğe bağlı)', 'vacay.addCalendar': 'Takvim ekle', 'vacay.calendarLabel': 'Etiket (isteğe bağlı)', 'vacay.calendarColor': 'Renk', - 'vacay.noCalendars': 'Henüz tatil takvimi eklenmedi', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Şirket Tatilleri', 'vacay.companyHolidaysHint': 'Şirket çapında tatil günlerinin işaretlenmesine izin ver', 'vacay.companyHolidaysNoDeduct': 'Şirket tatilleri tatil günlerinden sayılmaz.', @@ -107,5 +108,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Takvimden gizle', 'vacay.sharedEmpty': 'Takvimlerinizi birleştirmeden birbirinize gösterin.', 'vacay.sharedLegend': 'Paylaşılan (yalnızca görüntüleme)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/uk/vacay.ts b/shared/src/i18n/uk/vacay.ts index 55c204c15..2036a07bd 100644 --- a/shared/src/i18n/uk/vacay.ts +++ b/shared/src/i18n/uk/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Сб', 'vacay.sun': 'Нд', 'vacay.publicHolidays': 'Державні свята', - 'vacay.publicHolidaysHint': 'Позначати державні свята в календарі', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Виберіть країну', 'vacay.selectRegion': 'Виберіть регіон (необов’язково)', 'vacay.companyHolidays': 'Корпоративні вихідні', @@ -106,6 +108,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': 'Добавить календарь', 'vacay.calendarColor': 'Колір', 'vacay.calendarLabel': 'Назва', - 'vacay.noCalendars': 'Немає календарів', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/vi/vacay.ts b/shared/src/i18n/vi/vacay.ts index b3b13e7cd..4011e0e05 100644 --- a/shared/src/i18n/vi/vacay.ts +++ b/shared/src/i18n/vi/vacay.ts @@ -46,13 +46,14 @@ const vacay: TranslationStrings = { 'vacay.sat': 'Thứ bảy', 'vacay.sun': 'Chủ nhật', 'vacay.publicHolidays': 'Ngày lễ', - 'vacay.publicHolidaysHint': 'Đánh dấu các ngày lễ trong lịch', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': 'Chọn quốc gia', 'vacay.selectRegion': 'Chọn khu vực (tùy chọn)', 'vacay.addCalendar': 'Thêm lịch', 'vacay.calendarLabel': 'Nhãn (tùy chọn)', 'vacay.calendarColor': 'Màu sắc', - 'vacay.noCalendars': 'Chưa có lịch nghỉ lễ nào được thêm vào', + 'vacay.noSchoolCalendars': 'No school holiday calendars added yet', 'vacay.companyHolidays': 'Ngày lễ của công ty', 'vacay.companyHolidaysHint': 'Cho phép đánh dấu ngày nghỉ lễ toàn công ty', 'vacay.companyHolidaysNoDeduct': 'Ngày nghỉ của công ty không được tính vào ngày nghỉ.', @@ -108,5 +109,7 @@ const vacay: TranslationStrings = { 'vacay.hideFromCalendar': 'Ẩn khỏi lịch', 'vacay.sharedEmpty': 'Cho nhau xem lịch của mình mà không cần hợp nhất chúng.', 'vacay.sharedLegend': 'Được chia sẻ (chỉ xem)', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.noCalendars': 'No holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/zh-TW/vacay.ts b/shared/src/i18n/zh-TW/vacay.ts index 12b999eac..6a26ed7a7 100644 --- a/shared/src/i18n/zh-TW/vacay.ts +++ b/shared/src/i18n/zh-TW/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': '週六', 'vacay.sun': '週日', 'vacay.publicHolidays': '公共假日', - 'vacay.publicHolidaysHint': '在日曆中標記公共假日', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': '選擇國家', 'vacay.selectRegion': '選擇地區(可選)', 'vacay.companyHolidays': '公司假日', @@ -105,6 +107,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': '新增日曆', 'vacay.calendarColor': '顏色', 'vacay.calendarLabel': '標籤', - 'vacay.noCalendars': '無日曆', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/i18n/zh/vacay.ts b/shared/src/i18n/zh/vacay.ts index ee9b20858..b24bab432 100644 --- a/shared/src/i18n/zh/vacay.ts +++ b/shared/src/i18n/zh/vacay.ts @@ -46,7 +46,9 @@ const vacay: TranslationStrings = { 'vacay.sat': '周六', 'vacay.sun': '周日', 'vacay.publicHolidays': '公共假日', - 'vacay.publicHolidaysHint': '在日历中标记公共假日', + 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', + 'vacay.schoolHolidays': 'School Holidays', + 'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer', 'vacay.selectCountry': '选择国家', 'vacay.selectRegion': '选择地区(可选)', 'vacay.companyHolidays': '公司假日', @@ -105,6 +107,7 @@ const vacay: TranslationStrings = { 'vacay.addCalendar': '添加日历', 'vacay.calendarColor': '颜色', 'vacay.calendarLabel': '标签', - 'vacay.noCalendars': '无日历', + 'vacay.noCalendars': 'No holiday calendars added yet', +'vacay.noSchoolCalendars': 'No school holiday calendars added yet', }; export default vacay; diff --git a/shared/src/vacay/vacay.schema.ts b/shared/src/vacay/vacay.schema.ts index 75bfb8bb8..327b5c872 100644 --- a/shared/src/vacay/vacay.schema.ts +++ b/shared/src/vacay/vacay.schema.ts @@ -18,6 +18,7 @@ const open = z.record(z.string(), z.unknown()); export const vacayAddHolidayCalendarRequestSchema = z.object({ region: z.string().min(1), + type: z.enum(['public_holiday', 'school_holiday']).optional(), label: z.string().nullable().optional(), color: z.string().optional(), sort_order: z.number().optional(),