diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx
index a3f7894..f1a2718 100644
--- a/client/src/components/Vacay/VacayMonthCard.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.tsx
@@ -8,6 +8,13 @@ const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
+function hexToRgba(hex: string, alpha: number): string {
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `rgba(${r},${g},${b},${alpha})`
+}
+
interface VacayMonthCardProps {
year: number
month: number
@@ -86,7 +93,7 @@ export default function VacayMonthCard({
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
>
- {holiday &&
}
+ {holiday && }
{isCompany && }
{dayEntries.length === 1 && (
@@ -115,7 +122,7 @@ export default function VacayMonthCard({
)}
0 ? 700 : 500,
}}>
{day}
diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx
index c700eff..44a8788 100644
--- a/client/src/components/Vacay/VacaySettings.tsx
+++ b/client/src/components/Vacay/VacaySettings.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
-import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
+import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
+import type { VacayHolidayCalendar } from '../../types'
interface VacaySettingsProps {
onClose: () => void
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
- const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
- const [countries, setCountries] = useState([])
- const [regions, setRegions] = useState([])
- const [loadingRegions, setLoadingRegions] = useState(false)
+ const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
+ const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
+ const [showAddForm, setShowAddForm] = useState(false)
const { language } = useTranslation()
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}).catch(() => {})
}, [language])
- // When country changes, check if it has regions
- const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
- const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
-
- useEffect(() => {
- if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
- setLoadingRegions(true)
- const year = new Date().getFullYear()
- apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
- const allCounties = new Set()
- r.data.forEach(h => {
- if (h.counties) h.counties.forEach(c => allCounties.add(c))
- })
- if (allCounties.size > 0) {
- let subdivisionNames
- try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
- const regionList = [...allCounties].sort().map(c => {
- let label = c.split('-')[1] || c
- // Try Intl for full subdivision name (not all browsers support subdivision codes)
- // Fallback: use known mappings for DE
- if (c.startsWith('DE-')) {
- const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
- label = deRegions[c.split('-')[1]] || label
- } else if (c.startsWith('CH-')) {
- const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
- label = chRegions[c.split('-')[1]] || label
- }
- return { value: c, label }
- })
- setRegions(regionList)
- } else {
- setRegions([])
- // If no regions, just set country code as region
- if (plan.holidays_region !== selectedCountry) {
- updatePlan({ holidays_region: selectedCountry })
- }
- }
- }).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
- }, [selectedCountry, plan?.holidays_enabled])
-
if (!plan) return null
- const toggle = (key) => updatePlan({ [key]: !plan[key] })
-
- const handleCountryChange = (countryCode) => {
- updatePlan({ holidays_region: countryCode })
- }
-
- const handleRegionChange = (regionCode) => {
- updatePlan({ holidays_region: regionCode })
- }
+ const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
return (
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/>
{plan.holidays_enabled && (
-
- {regions.length > 0 && (
-
{t('vacay.noCalendars')}
+ )}
+ {(plan.holiday_calendars ?? []).map(cal => (
+ updateHolidayCalendar(cal.id, data)}
+ onDelete={() => deleteHolidayCalendar(cal.id)}
/>
+ ))}
+ {showAddForm ? (
+ { await addHolidayCalendar(data); setShowAddForm(false) }}
+ onCancel={() => setShowAddForm(false)}
+ />
+ ) : (
+
)}
)}
@@ -197,11 +163,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}
interface SettingToggleProps {
- icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
+ icon: LucideIcon
label: string
hint: string
value: boolean
- onChange: (value: boolean) => void
+ onChange: () => void
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
@@ -223,3 +189,184 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
+
+// ── shared region-loading helper ─────────────────────────────────────────────
+async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
+ try {
+ const year = new Date().getFullYear()
+ const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
+ const allCounties = new Set()
+ r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
+ if (allCounties.size === 0) return []
+ return [...allCounties].sort().map(c => {
+ let label = c.split('-')[1] || c
+ if (c.startsWith('DE-')) {
+ const m: Record = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
+ label = m[c.split('-')[1]] || label
+ } else if (c.startsWith('CH-')) {
+ const m: Record = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
+ label = m[c.split('-')[1]] || label
+ }
+ return { value: c, label }
+ })
+ } catch {
+ return []
+ }
+}
+
+// ── Existing calendar row (inline edit) ──────────────────────────────────────
+function CalendarRow({ cal, countries, onUpdate, onDelete }: {
+ cal: VacayHolidayCalendar
+ countries: { value: string; label: string }[]
+ language: string
+ onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
+ onDelete: () => void
+}) {
+ const { t } = useTranslation()
+ const [localColor, setLocalColor] = useState(cal.color)
+ 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 : ''
+
+ useEffect(() => { setLocalColor(cal.color) }, [cal.color])
+ useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
+
+ useEffect(() => {
+ if (!selectedCountry) { setRegions([]); return }
+ fetchRegionOptions(selectedCountry).then(setRegions)
+ }, [selectedCountry])
+
+ return (
+
+ )
+}
+
+// ── Add-new-calendar form ─────────────────────────────────────────────────────
+function AddCalendarForm({ countries, onAdd, onCancel }: {
+ countries: { value: string; label: string }[]
+ language: string
+ onAdd: (data: { region: string; color: string; label: string | null }) => void
+ onCancel: () => void
+}) {
+ const { t } = useTranslation()
+ const [region, setRegion] = useState('')
+ const [color, setColor] = useState('#fecaca')
+ 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 : ''
+
+ useEffect(() => {
+ if (!selectedCountry) { setRegions([]); return }
+ setLoadingRegions(true)
+ fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
+ }, [selectedCountry])
+
+ const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
+
+ return (
+
+
setColor(e.target.value)}
+ className="w-7 h-7 shrink-0 rounded cursor-pointer p-0"
+ style={{ border: 'none', background: 'transparent' }}
+ title={t('vacay.calendarColor')}
+ />
+
+
setLabel(e.target.value)}
+ placeholder={t('vacay.calendarLabel')}
+ className="w-full text-xs px-2 py-1 rounded"
+ style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
+ />
+
{ setRegion(v); setRegions([]) }}
+ options={countries}
+ placeholder={t('vacay.selectCountry')}
+ searchable
+ />
+ {regions.length > 0 && (
+ setRegion(v)}
+ options={regions}
+ placeholder={t('vacay.selectRegion')}
+ searchable
+ />
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index ffa7bdb..9596988 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -3,7 +3,7 @@ import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
-type TranslationStrings = Record
+type TranslationStrings = Record
const translations: Record = { de, en }
@@ -27,7 +27,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
const fallback = translations.de
function t(key: string, params?: Record): string {
- let val: string = strings[key] ?? fallback[key] ?? key
+ let val: string = (strings[key] ?? fallback[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 0421e1f..eb63601 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1,4 +1,4 @@
-const de: Record = {
+const de: Record = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -390,6 +390,10 @@ const de: Record = {
'vacay.publicHolidaysHint': 'Feiertage im Kalender 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.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 33c828a..e5e2f0d 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1,4 +1,4 @@
-const en: Record = {
+const en: Record = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -390,6 +390,10 @@ const en: Record = {
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'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.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx
index 93dff4b..693e27d 100644
--- a/client/src/pages/VacayPage.tsx
+++ b/client/src/pages/VacayPage.tsx
@@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
{t('vacay.legend')}
- {plan?.holidays_enabled && }
+ {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
+
+ )}
+ {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
+
+ ))}
{plan?.company_holidays_enabled && }
{plan?.block_weekends && }
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts
index c58e3a3..f1d7ef7 100644
--- a/client/src/store/vacayStore.ts
+++ b/client/src/store/vacayStore.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import apiClient from '../api/client'
import type { AxiosResponse } from 'axios'
-import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types'
+import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
const ax = apiClient
@@ -65,6 +65,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 }>
+ deleteHolidayCalendar: (id: number) => Promise
}
const api: VacayApi = {
@@ -87,6 +90,9 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).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),
+ 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),
}
interface VacayState {
@@ -124,6 +130,9 @@ 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
+ deleteHolidayCalendar: (id: number) => Promise
loadAll: () => Promise
}
@@ -247,29 +256,47 @@ export const useVacayStore = create((set, get) => ({
loadHolidays: async (year?: number) => {
const y = year || get().selectedYear
const plan = get().plan
- if (!plan?.holidays_enabled || !plan?.holidays_region) {
+ const calendars = plan?.holiday_calendars ?? []
+ if (!plan?.holidays_enabled || calendars.length === 0) {
set({ holidays: {} })
return
}
- const country = plan.holidays_region.split('-')[0]
- const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
- try {
- const data = await api.getHolidays(y, country)
- const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
- if (hasRegions && !region) {
- set({ holidays: {} })
- return
- }
- const map: HolidaysMap = {}
- data.forEach((h: VacayHolidayRaw) => {
- if (h.global || !h.counties || (region && h.counties.includes(region))) {
- map[h.date] = { name: h.name, localName: h.localName }
- }
- })
- set({ holidays: map })
- } catch {
- set({ holidays: {} })
+ const map: HolidaysMap = {}
+ for (const cal of calendars) {
+ const country = cal.region.split('-')[0]
+ const region = cal.region.includes('-') ? cal.region : null
+ 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 }
+ }
+ }
+ })
+ } catch { /* API error, skip */ }
}
+ set({ holidays: map })
+ },
+
+ addHolidayCalendar: async (data) => {
+ await api.addHolidayCalendar(data)
+ await get().loadPlan()
+ await get().loadHolidays()
+ },
+
+ updateHolidayCalendar: async (id, data) => {
+ await api.updateHolidayCalendar(id, data)
+ await get().loadPlan()
+ await get().loadHolidays()
+ },
+
+ deleteHolidayCalendar: async (id) => {
+ await api.deleteHolidayCalendar(id)
+ await get().loadPlan()
+ await get().loadHolidays()
},
loadAll: async () => {
diff --git a/client/src/types.ts b/client/src/types.ts
index 761ae47..b0c7a32 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -281,10 +281,23 @@ export interface WebSocketEvent {
}
// Vacay types
+export interface VacayHolidayCalendar {
+ id: number
+ plan_id: number
+ region: string
+ label: string | null
+ color: string
+ sort_order: number
+}
+
export interface VacayPlan {
id: number
holidays_enabled: boolean
holidays_region: string | null
+ holiday_calendars: VacayHolidayCalendar[]
+ block_weekends: boolean
+ carry_over_enabled: boolean
+ company_holidays_enabled: boolean
name?: string
year?: number
owner_id?: number
@@ -301,6 +314,9 @@ export interface VacayUser {
export interface VacayEntry {
date: string
user_id: number
+ plan_id?: number
+ person_color?: string
+ person_name?: string
}
export interface VacayStat {
@@ -312,6 +328,8 @@ export interface VacayStat {
export interface HolidayInfo {
name: string
localName: string
+ color: string
+ label: string | null
}
export interface HolidaysMap {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 40f3d44..9670ecb 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -281,6 +281,15 @@ function createTables(db: Database.Database): void {
UNIQUE(plan_id, date)
);
+ 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,
+ region TEXT NOT NULL,
+ label TEXT,
+ color TEXT NOT NULL DEFAULT '#fecaca',
+ sort_order INTEGER NOT NULL DEFAULT 0
+ );
+
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
index 02b13d9..ba0cbe3 100644
--- a/server/src/routes/vacay.ts
+++ b/server/src/routes/vacay.ts
@@ -43,9 +43,59 @@ interface Holiday {
counties?: string[] | null;
}
+interface VacayHolidayCalendar {
+ id: number;
+ plan_id: number;
+ region: string;
+ label: string | null;
+ color: string;
+ sort_order: number;
+}
+
const holidayCache = new Map();
const CACHE_TTL = 24 * 60 * 60 * 1000;
+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[];
+ 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) {
+ const country = cal.region.split('-')[0];
+ const region = cal.region.includes('-') ? cal.region : null;
+ for (const { year } of years) {
+ try {
+ const cacheKey = `${year}-${country}`;
+ let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
+ if (!holidays) {
+ const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
+ holidays = await resp.json() as Holiday[];
+ holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
+ }
+ const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0);
+ if (hasRegions && !region) continue;
+ for (const h of holidays) {
+ if (h.global || !h.counties || (region && h.counties.includes(region))) {
+ db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
+ db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
+ }
+ }
+ } catch { /* API error, skip */ }
+ }
+ }
+}
+
+async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise {
+ const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId);
+ if (existing) return;
+ if (plan.holidays_enabled && plan.holidays_region) {
+ db.prepare(
+ 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)'
+ ).run(planId, plan.holidays_region, '#fecaca');
+ }
+}
+
const router = express.Router();
router.use(authenticate);
@@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => {
WHERE m.user_id = ? AND m.status = 'pending'
`).all(authReq.user.id);
+ const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[];
+
res.json({
plan: {
...plan,
@@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => {
holidays_enabled: !!plan.holidays_enabled,
company_holidays_enabled: !!plan.company_holidays_enabled,
carry_over_enabled: !!plan.carry_over_enabled,
+ holiday_calendars: holidayCalendars,
},
users,
pendingInvites,
@@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => {
}
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
- if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
- const country = updatedPlan.holidays_region.split('-')[0];
- const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
- const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
- for (const { year } of years) {
- try {
- const cacheKey = `${year}-${country}`;
- let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
- if (!holidays) {
- const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
- holidays = await resp.json() as Holiday[];
- holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
- }
- const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0);
- if (hasRegions && !region) continue;
- for (const h of holidays) {
- if (h.global || !h.counties || (region && h.counties.includes(region))) {
- db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
- db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
- }
- }
- } catch { /* API error, skip */ }
- }
- }
+ await migrateHolidayCalendars(planId, updatedPlan);
+ await applyHolidayCalendars(planId);
if (carry_over_enabled === false) {
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
@@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => {
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
+ const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
res.json({
- plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
+ plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars }
});
});
+router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { region, label, color, sort_order } = req.body;
+ if (!region) return res.status(400).json({ error: 'region required' });
+ const planId = getActivePlanId(authReq.user.id);
+ const result = db.prepare(
+ 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
+ ).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ calendar: cal });
+});
+
+router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const id = parseInt(req.params.id);
+ const planId = getActivePlanId(authReq.user.id);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined;
+ if (!cal) return res.status(404).json({ error: 'Calendar not found' });
+ const { region, label, color, sort_order } = req.body;
+ const updates: string[] = [];
+ const params: (string | number | null)[] = [];
+ if (region !== undefined) { updates.push('region = ?'); params.push(region); }
+ 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); }
+ if (updates.length > 0) {
+ params.push(id);
+ db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ }
+ const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar;
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ calendar: updated });
+});
+
+router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const id = parseInt(req.params.id);
+ const planId = getActivePlanId(authReq.user.id);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId);
+ if (!cal) return res.status(404).json({ error: 'Calendar not found' });
+ db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id);
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ success: true });
+});
+
router.put('/color', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { color, target_user_id } = req.body;