import { create } from 'zustand' import apiClient from '../api/client' import { useAuthStore } from './authStore' import type { AxiosResponse } from 'axios' import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar, VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar, VacayYearSettings, } from '../types' import { isSchoolHolidayCountrySupported } from '../vacay/schoolHolidayCountries' import { DEFAULT_YEAR_SETTINGS, currentPeriodYear, inGridWindow, windowCalendarYears } from '../vacay/yearWindow' import type { VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest, VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest, VacayUpdateStatsRequest, VacayShareRequest, VacayShareUpdateRequest, VacayYearSettingsRequest, } from '@trek/shared' const ax = apiClient interface PendingInvite { user_id: number username: string } interface IncomingInvite { plan_id: number owner_username: string } interface VacayPlanResponse { plan: VacayPlan users: VacayUser[] pendingInvites: PendingInvite[] incomingInvites: IncomingInvite[] isOwner: boolean isFused: boolean } interface VacayYearsResponse { years: number[] } interface VacayEntriesResponse { entries: VacayEntry[] companyHolidays: { date: string; note?: string }[] } interface VacayStatsResponse { stats: VacayStat[] } interface VacayHolidayRaw { date: string name: string localName: string global: boolean 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 }> updateColor: (color: string, targetUserId?: number) => Promise invite: (userId: number) => Promise acceptInvite: (planId: number) => Promise declineInvite: (planId: number) => Promise cancelInvite: (userId: number) => Promise dissolve: () => Promise availableUsers: () => Promise<{ users: VacayUser[] }> getYears: () => Promise addYear: (year: number) => Promise removeYear: (year: number) => Promise getEntries: (year: number) => Promise toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise toggleCompanyHoliday: (date: string) => Promise getStats: (year: number) => Promise updateStats: (year: number, days: number, targetUserId?: number) => Promise getCountries: () => Promise<{ countries: string[] }> getHolidays: (year: number, country: string) => Promise 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 removeShare: (shareId: number) => Promise updateShare: (shareId: number, hidden: boolean) => Promise shareAvailableUsers: () => Promise<{ users: VacayUser[] }> getSharedCalendars: (year: number) => Promise<{ calendars: SharedVacayCalendar[] }> getYearSettings: () => Promise<{ settings: VacayYearSettings }> updateYearSettings: (data: VacayYearSettingsRequest) => Promise<{ settings: VacayYearSettings }> } const api: VacayApi = { getPlan: () => ax.get('/addons/vacay/plan').then((r: AxiosResponse) => r.data), updatePlan: (data) => ax.put('/addons/vacay/plan', data).then((r: AxiosResponse) => r.data), updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId } satisfies VacaySetColorRequest).then((r: AxiosResponse) => r.data), invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId } satisfies VacayInviteRequest).then((r: AxiosResponse) => r.data), acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId } satisfies VacayInviteActionRequest).then((r: AxiosResponse) => r.data), declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId } satisfies VacayInviteActionRequest).then((r: AxiosResponse) => r.data), cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then((r: AxiosResponse) => r.data), dissolve: () => ax.post('/addons/vacay/dissolve').then((r: AxiosResponse) => r.data), availableUsers: () => ax.get('/addons/vacay/available-users').then((r: AxiosResponse) => r.data), getYears: () => ax.get('/addons/vacay/years').then((r: AxiosResponse) => r.data), addYear: (year) => ax.post('/addons/vacay/years', { year } satisfies VacayAddYearRequest).then((r: AxiosResponse) => r.data), removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data), getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data), toggleEntry: (date, targetUserId, fraction, kind) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId, fraction, kind } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data), toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date } satisfies VacayCompanyHolidayRequest).then((r: AxiosResponse) => r.data), getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data), 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), getShares: () => ax.get('/addons/vacay/shares').then((r: AxiosResponse) => r.data), share: (userId) => ax.post('/addons/vacay/shares', { user_id: userId } satisfies VacayShareRequest).then((r: AxiosResponse) => r.data), removeShare: (shareId) => ax.delete(`/addons/vacay/shares/${shareId}`).then((r: AxiosResponse) => r.data), updateShare: (shareId, hidden) => ax.put(`/addons/vacay/shares/${shareId}`, { hidden } satisfies VacayShareUpdateRequest).then((r: AxiosResponse) => r.data), shareAvailableUsers: () => ax.get('/addons/vacay/shares/available-users').then((r: AxiosResponse) => r.data), getSharedCalendars: (year) => ax.get(`/addons/vacay/shares/calendars/${year}`).then((r: AxiosResponse) => r.data), getYearSettings: () => ax.get('/addons/vacay/year-settings').then((r: AxiosResponse) => r.data), updateYearSettings: (data) => ax.put('/addons/vacay/year-settings', data satisfies VacayYearSettingsRequest).then((r: AxiosResponse) => r.data), } function pushHolidayMarker(map: HolidaysMap, date: string, marker: HolidayInfo) { const existing = map[date] if (!existing) { map[date] = [marker] return } const markers = Array.isArray(existing) ? existing : [existing] // A shifted leave year (#737) loads two calendar years, and a school break that // straddles New Year comes back in both — keep one marker per day. The label is // part of the identity: two calendars left on the same colour are still two // calendars, and collapsing them would hide one from the tooltip. if (markers.some(m => m.name === marker.name && m.color === marker.color && m.type === marker.type && m.label === marker.label)) return map[date] = [...markers, marker] } function parseCalendarRegion(region: string): { country: string; subdivision: string | null; group: string | null } { 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[] pendingInvites: PendingInvite[] incomingInvites: IncomingInvite[] isOwner: boolean isFused: boolean years: number[] entries: VacayEntry[] companyHolidays: { date: string; note?: string }[] stats: VacayStat[] selectedYear: number selectedUserId: number | null holidays: HolidaysMap loading: boolean outgoingShares: VacayShareOutgoing[] incomingShares: VacayShareIncoming[] sharedCalendars: SharedVacayCalendar[] // The viewer's leave-year configuration (#737). Everything the grid renders and // filters runs through this, so it loads before the first year is picked. yearSettings: VacayYearSettings setSelectedYear: (year: number) => void setSelectedUserId: (id: number | null) => void loadPlan: () => Promise updatePlan: (updates: Partial) => Promise updateColor: (color: string, targetUserId?: number) => Promise invite: (userId: number) => Promise acceptInvite: (planId: number) => Promise declineInvite: (planId: number) => Promise cancelInvite: (userId: number) => Promise dissolve: () => Promise loadYears: () => Promise addYear: (year: number) => Promise removeYear: (year: number) => Promise loadEntries: (year?: number) => Promise toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise toggleCompanyHoliday: (date: string) => Promise 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; 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 shareWith: (userId: number) => Promise removeShare: (shareId: number) => Promise setShareHidden: (shareId: number, hidden: boolean) => Promise loadYearSettings: () => Promise updateYearSettings: (data: VacayYearSettingsRequest) => Promise loadAll: () => Promise } export const useVacayStore = create((set, get) => ({ plan: null, users: [], pendingInvites: [], incomingInvites: [], isOwner: true, isFused: false, years: [], entries: [], companyHolidays: [], stats: [], selectedYear: new Date().getFullYear(), selectedUserId: null, holidays: {}, loading: false, outgoingShares: [], incomingShares: [], sharedCalendars: [], yearSettings: DEFAULT_YEAR_SETTINGS, setSelectedYear: (year: number) => set({ selectedYear: year }), setSelectedUserId: (id: number | null) => set({ selectedUserId: id }), loadPlan: async () => { const data = await api.getPlan() set({ plan: data.plan, users: data.users, pendingInvites: data.pendingInvites, incomingInvites: data.incomingInvites, isOwner: data.isOwner, isFused: data.isFused, }) }, updatePlan: async (updates: Partial) => { const data = await api.updatePlan(updates) set({ plan: data.plan }) await get().loadEntries() await get().loadStats() await get().loadHolidays() }, updateColor: async (color: string, targetUserId?: number) => { await api.updateColor(color, targetUserId) await get().loadPlan() await get().loadEntries() }, invite: async (userId: number) => { await api.invite(userId) await get().loadPlan() }, acceptInvite: async (planId: number) => { await api.acceptInvite(planId) await get().loadAll() }, declineInvite: async (planId: number) => { await api.declineInvite(planId) await get().loadPlan() }, cancelInvite: async (userId: number) => { await api.cancelInvite(userId) await get().loadPlan() }, dissolve: async () => { await api.dissolve() await get().loadAll() }, loadYears: async () => { const data = await api.getYears() set({ years: data.years }) if (data.years.length > 0) { set({ selectedYear: data.years[data.years.length - 1] }) } else { set({ selectedYear: currentPeriodYear(get().yearSettings) }) } }, addYear: async (year: number) => { const data = await api.addYear(year) set({ years: data.years }) await get().loadStats(year) }, removeYear: async (year: number) => { const data = await api.removeYear(year) const updates: Partial = { years: data.years } if (get().selectedYear === year) { updates.selectedYear = data.years.length > 0 ? data.years[data.years.length - 1] : currentPeriodYear(get().yearSettings) } set(updates) await get().loadStats() }, loadEntries: async (year?: number) => { const y = year || get().selectedYear const data = await api.getEntries(y) set({ entries: data.entries, companyHolidays: data.companyHolidays }) }, toggleEntry: async (date: string, targetUserId?: number, fraction: 0.5 | 1 = 1, kind: 'vacation' | 'comp' = 'vacation') => { // Optimistic: mirror the server's toggle locally so the cell reacts instantly, // then reconcile (stats are server-computed). Same type AND fraction clears the // day, a different type or fraction converts it (#552/#1074); an empty day gets // the new entry. const userId = targetUserId ?? useAuthStore.getState().user?.id const prevEntries = get().entries const prevStats = get().stats if (userId != null) { const existing = prevEntries.find(e => e.date === date && e.user_id === userId) if (existing && (existing.fraction ?? 1) === fraction && (existing.kind ?? 'vacation') === kind) { set({ entries: prevEntries.filter(e => !(e.date === date && e.user_id === userId)) }) } else if (existing) { set({ entries: prevEntries.map(e => (e.date === date && e.user_id === userId ? { ...e, fraction, kind } : e)) }) } else { const u = get().users.find(x => x.id === userId) set({ entries: [...prevEntries, { date, user_id: userId, fraction, kind, person_color: u?.color || undefined, person_name: u?.username }] }) } } try { await api.toggleEntry(date, targetUserId, fraction, kind) await get().loadEntries() await get().loadStats() } catch (e) { set({ entries: prevEntries, stats: prevStats }) throw e } }, toggleCompanyHoliday: async (date: string) => { // Optimistic like toggleEntry; a company holiday also overrides personal // entries on that day, so refetch entries afterwards to reconcile. const prevEntries = get().entries const prevCompany = get().companyHolidays const prevStats = get().stats const has = prevCompany.some(h => h.date === date) set({ companyHolidays: has ? prevCompany.filter(h => h.date !== date) : [...prevCompany, { date }] }) try { await api.toggleCompanyHoliday(date) await get().loadEntries() await get().loadStats() } catch (e) { set({ entries: prevEntries, companyHolidays: prevCompany, stats: prevStats }) throw e } }, loadStats: async (year?: number) => { const y = year || get().selectedYear const data = await api.getStats(y) set({ stats: data.stats }) }, updateVacationDays: async (year: number, days: number, targetUserId?: number) => { await api.updateStats(year, days, targetUserId) await get().loadStats(year) }, loadHolidays: async (year?: number) => { const y = year || get().selectedYear const plan = get().plan const calendars = plan?.holiday_calendars ?? [] 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 = {} const settings = get().yearSettings // A shifted leave year (#737) spans two calendar years and the holiday APIs are // per calendar year, so each one the window touches is fetched and the result // clipped back to what the grid actually renders. const calendarYears = windowCalendarYears(y, settings) for (const cal of enabledCalendars) { const { country, subdivision, group } = parseCalendarRegion(cal.region) for (const cy of calendarYears) { try { if ((cal.type ?? 'public_holiday') === 'school_holiday') { if (!isSchoolHolidayCountrySupported(country)) continue const data = await api.getSchoolHolidays(cy, country, subdivision, group) data.forEach((h: VacaySchoolHolidayRaw) => { if (!h.startDate) return const name = schoolHolidayName(h) datesBetween(h.startDate, h.endDate || h.startDate).filter(date => inGridWindow(date, y, settings)).forEach(date => { pushHolidayMarker(map, date, { name, localName: name, color: cal.color, label: cal.label, type: 'school_holiday' }) }) }) } else { const data = await api.getHolidays(cy, country) const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) if (hasRegions && !subdivision) continue data.forEach((h: VacayHolidayRaw) => { if (!inGridWindow(h.date, y, settings)) return if (h.global || !h.counties || (subdivision && h.counties.includes(subdivision))) { pushHolidayMarker(map, h.date, { name: h.name, localName: h.localName, color: cal.color, label: cal.label, type: 'public_holiday' }) } }) } } catch { /* API error, skip */ } } } set({ holidays: map }) }, 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() }, loadShares: async () => { const data = await api.getShares() set({ outgoingShares: data.outgoing, incomingShares: data.incoming }) }, loadSharedCalendars: async (year?: number) => { const y = year || get().selectedYear const data = await api.getSharedCalendars(y) set({ sharedCalendars: data.calendars }) }, shareWith: async (userId: number) => { await api.share(userId) await get().loadShares() }, removeShare: async (shareId: number) => { await api.removeShare(shareId) await get().loadShares() await get().loadSharedCalendars() }, setShareHidden: async (shareId: number, hidden: boolean) => { // Optimistic: the eye toggle should feel instant; reconcile via refetch. const prevIncoming = get().incomingShares const prevCalendars = get().sharedCalendars set({ incomingShares: prevIncoming.map(s => (s.id === shareId ? { ...s, hidden } : s)), sharedCalendars: prevCalendars.map(c => (c.share_id === shareId ? { ...c, hidden } : c)), }) try { await api.updateShare(shareId, hidden) } catch (e) { set({ incomingShares: prevIncoming, sharedCalendars: prevCalendars }) throw e } }, loadYearSettings: async () => { const data = await api.getYearSettings() set({ yearSettings: data.settings ?? DEFAULT_YEAR_SETTINGS }) }, updateYearSettings: async (data: VacayYearSettingsRequest) => { const res = await api.updateYearSettings(data) set({ yearSettings: res.settings ?? DEFAULT_YEAR_SETTINGS }) // The window moved, so entries, holidays and the counted usage all shift with it. const year = get().selectedYear await get().loadEntries(year) await get().loadStats(year) await get().loadHolidays(year) await get().loadSharedCalendars(year) }, loadAll: async () => { set({ loading: true }) try { await get().loadPlan() // The leave-year window decides which period is current, so it is resolved // before the year list picks a default (#737). await get().loadYearSettings() await get().loadYears() const year = get().selectedYear await get().loadEntries(year) await get().loadStats(year) await get().loadHolidays(year) await get().loadShares() await get().loadSharedCalendars(year) } finally { set({ loading: false }) } }, }))