Files
TREK/client/src/store/vacayStore.ts
T
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00

540 lines
23 KiB
TypeScript

import { manualSchoolRegionId } from '@trek/shared'
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, defaultPeriodYear, inGridWindow, windowCalendarYears } from '../vacay/yearWindow'
import type {
VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest,
VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest,
VacayUpdateStatsRequest, VacayShareRequest, VacayShareUpdateRequest,
VacayYearSettingsRequest,
} from '@trek/shared'
import { toggledCompanyHolidays, type CompanyHoliday } from '../components/Vacay/companyHolidays'
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: CompanyHoliday[]
}
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, fraction?: number) => 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, fraction) => ax.post('/addons/vacay/entries/company-holiday', { date, fraction: fraction === 0.5 ? 0.5 : 1 } 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 manualId = subdivision ? manualSchoolRegionId(subdivision) : null
if (manualId) return ax.get(`/school-holiday-catalog/regions/${manualId}/holidays/${year}`).then((r: AxiosResponse) => r.data)
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: CompanyHoliday[]
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>
/** Whole (1) or half (0.5) company holiday (#2439); the same size again clears it. */
toggleCompanyHoliday: (date: string, fraction?: number) => 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()
// Opening Vacay lands on the period we are in, not on whichever year sorts
// last. A reload (invite, live settings update) keeps the year the viewer is
// looking at as long as it still exists — only the first load picks one.
const previous = get().selectedYear
const keepSelection = get().years.length > 0 && data.years.includes(previous)
set({
years: data.years,
selectedYear: keepSelection ? previous : defaultPeriodYear(data.years, 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 = defaultPeriodYear(data.years, 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, fraction = 1) => {
// 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
set({ companyHolidays: toggledCompanyHolidays(prevCompany, date, fraction) })
try {
await api.toggleCompanyHoliday(date, fraction)
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) && !manualSchoolRegionId(cal.region)) 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 })
}
},
}))