Files
TREK/client/src/store/vacayStore.ts
T
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00

535 lines
22 KiB
TypeScript

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<VacayPlanResponse>
updatePlan: (data: Partial<VacayPlan>) => Promise<{ plan: VacayPlan }>
updateColor: (color: string, targetUserId?: number) => Promise<unknown>
invite: (userId: number) => Promise<unknown>
acceptInvite: (planId: number) => Promise<unknown>
declineInvite: (planId: number) => Promise<unknown>
cancelInvite: (userId: number) => Promise<unknown>
dissolve: () => Promise<unknown>
getYears: () => Promise<VacayYearsResponse>
addYear: (year: number) => Promise<VacayYearsResponse>
removeYear: (year: number) => Promise<VacayYearsResponse>
getEntries: (year: number) => Promise<VacayEntriesResponse>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise<unknown>
toggleCompanyHoliday: (date: string) => Promise<unknown>
getStats: (year: number) => Promise<VacayStatsResponse>
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
getSchoolHolidays: (year: number, country: string, subdivision?: string | null, group?: string | null) => Promise<VacaySchoolHolidayRaw[]>
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<unknown>
getShares: () => Promise<{ outgoing: VacayShareOutgoing[]; incoming: VacayShareIncoming[] }>
share: (userId: number) => Promise<unknown>
removeShare: (shareId: number) => Promise<unknown>
updateShare: (shareId: number, hidden: boolean) => Promise<unknown>
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),
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),
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),
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<void>
updatePlan: (updates: Partial<VacayPlan>) => Promise<void>
updateColor: (color: string, targetUserId?: number) => Promise<void>
invite: (userId: number) => Promise<void>
acceptInvite: (planId: number) => Promise<void>
declineInvite: (planId: number) => Promise<void>
cancelInvite: (userId: number) => Promise<void>
dissolve: () => Promise<void>
loadYears: () => Promise<void>
addYear: (year: number) => Promise<void>
removeYear: (year: number) => Promise<void>
loadEntries: (year?: number) => Promise<void>
toggleEntry: (date: string, targetUserId?: number, fraction?: 0.5 | 1, kind?: 'vacation' | 'comp') => Promise<void>
toggleCompanyHoliday: (date: string) => Promise<void>
loadStats: (year?: number) => Promise<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
loadHolidays: (year?: number) => Promise<void>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null; type?: 'public_holiday' | 'school_holiday' }) => Promise<void>
deleteHolidayCalendar: (id: number) => Promise<void>
loadShares: () => Promise<void>
loadSharedCalendars: (year?: number) => Promise<void>
shareWith: (userId: number) => Promise<void>
removeShare: (shareId: number) => Promise<void>
setShareHidden: (shareId: number, hidden: boolean) => Promise<void>
loadYearSettings: () => Promise<void>
updateYearSettings: (data: VacayYearSettingsRequest) => Promise<void>
loadAll: () => Promise<void>
}
export const useVacayStore = create<VacayState>((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<VacayPlan>) => {
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<VacayState> = { 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 })
}
},
}))