-
- {u.username.charAt(0).toUpperCase()}
+
+
+ {u.username.charAt(0).toUpperCase()}
+
+
{u.username}
@@ -376,6 +384,8 @@ export default function AdminPage() {
{activeTab === 'categories' && }
+ {activeTab === 'addons' && }
+
{activeTab === 'settings' && (
{/* Registration Toggle */}
@@ -409,6 +419,7 @@ export default function AdminPage() {
{t('admin.apiKeys')}
+ {t('admin.apiKeysHint')}
{/* Google Maps Key */}
@@ -529,7 +540,7 @@ export default function AdminPage() {
{t('admin.oidcTitle')}
- {t('admin.oidcSubtitle')}
+ {t('admin.oidcSubtitle')}
diff --git a/client/src/pages/AtlasPage.jsx b/client/src/pages/AtlasPage.jsx
new file mode 100644
index 0000000..8c17f4f
--- /dev/null
+++ b/client/src/pages/AtlasPage.jsx
@@ -0,0 +1,482 @@
+import React, { useEffect, useState, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useTranslation } from '../i18n'
+import { useSettingsStore } from '../store/settingsStore'
+import Navbar from '../components/Layout/Navbar'
+import apiClient from '../api/client'
+import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
+import L from 'leaflet'
+
+// Convert country code to flag emoji
+function MobileStats({ data, stats, countries, resolveName, t, dark }) {
+ const tp = dark ? '#f1f5f9' : '#0f172a'
+ const tf = dark ? '#475569' : '#94a3b8'
+ const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
+ const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
+ const thisYear = new Date().getFullYear()
+
+ return (
+
+ {/* Stats grid */}
+
+ {[[stats.totalCountries, t('atlas.countries')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
+
+ ))}
+
+ {/* Continents */}
+
+ {['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map(cont => {
+ const count = continents?.[cont] || 0
+ return (
+
+ 0 ? tp : (dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)') }}>{count}
+ 0 ? tf : (dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)') }}>{CL[cont]}
+
+ )
+ })}
+
+ {/* Highlights */}
+
+ {streak > 0 && (
+
+ {streak}
+ {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
+
+ )}
+ {tripsThisYear > 0 && (
+
+ {tripsThisYear}
+ {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
+
+ )}
+
+
+ )
+}
+
+function countryCodeToFlag(code) {
+ if (!code || code.length !== 2) return ''
+ return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
+}
+
+function useCountryNames(language) {
+ const [resolver, setResolver] = useState(() => (code) => code)
+ useEffect(() => {
+ try {
+ const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
+ setResolver(() => (code) => { try { return dn.of(code) } catch { return code } })
+ } catch { /* */ }
+ }, [language])
+ return resolver
+}
+
+// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
+const A2_TO_A3 = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
+
+export default function AtlasPage() {
+ const { t, language } = useTranslation()
+ const { settings } = useSettingsStore()
+ const navigate = useNavigate()
+ const resolveName = useCountryNames(language)
+ const dark = settings.dark_mode
+ const mapRef = useRef(null)
+ const mapInstance = useRef(null)
+ const geoLayerRef = useRef(null)
+ const glareRef = useRef(null)
+ const borderGlareRef = useRef(null)
+ const panelRef = useRef(null)
+
+ const handlePanelMouseMove = (e) => {
+ if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
+ const rect = panelRef.current.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+ // Subtle inner glow
+ glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
+ glareRef.current.style.opacity = '1'
+ // Border glow that follows cursor
+ borderGlareRef.current.style.opacity = '1'
+ borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
+ borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
+ }
+ const handlePanelMouseLeave = () => {
+ if (glareRef.current) glareRef.current.style.opacity = '0'
+ if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
+ }
+
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [sidebarOpen, setSidebarOpen] = useState(true)
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
+ const [selectedCountry, setSelectedCountry] = useState(null)
+ const [countryDetail, setCountryDetail] = useState(null)
+ const [geoData, setGeoData] = useState(null)
+
+ // Load atlas data
+ useEffect(() => {
+ apiClient.get('/addons/atlas/stats').then(r => {
+ setData(r.data)
+ setLoading(false)
+ }).catch(() => setLoading(false))
+ }, [])
+
+ // Load GeoJSON world data (direct GeoJSON, no conversion needed)
+ useEffect(() => {
+ fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
+ .then(r => r.json())
+ .then(geo => setGeoData(geo))
+ .catch(() => {})
+ }, [])
+
+ // Initialize map — runs after loading is done and mapRef is available
+ useEffect(() => {
+ if (loading || !mapRef.current) return
+ if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
+
+ const map = L.map(mapRef.current, {
+ center: [25, 0],
+ zoom: 3,
+ minZoom: 3,
+ maxZoom: 7,
+ zoomControl: false,
+ attributionControl: false,
+ maxBounds: [[-90, -220], [90, 220]],
+ maxBoundsViscosity: 1.0,
+ fadeAnimation: false,
+ preferCanvas: true,
+ })
+
+ L.control.zoom({ position: 'bottomright' }).addTo(map)
+
+ const tileUrl = dark
+ ? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
+ : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
+
+ L.tileLayer(tileUrl, {
+ maxZoom: 8,
+ keepBuffer: 25,
+ updateWhenZooming: true,
+ updateWhenIdle: false,
+ tileSize: 256,
+ zoomOffset: 0,
+ crossOrigin: true,
+ loading: true,
+ }).addTo(map)
+
+ // Preload adjacent zoom level tiles
+ L.tileLayer(tileUrl, {
+ maxZoom: 8,
+ keepBuffer: 10,
+ opacity: 0,
+ tileSize: 256,
+ crossOrigin: true,
+ }).addTo(map)
+
+ mapInstance.current = map
+ return () => { map.remove(); mapInstance.current = null }
+ }, [dark, loading])
+
+ // Render GeoJSON countries
+ useEffect(() => {
+ if (!mapInstance.current || !geoData || !data) return
+
+ const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
+ const countryMap = {}
+ data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
+
+ if (geoLayerRef.current) {
+ mapInstance.current.removeLayer(geoLayerRef.current)
+ }
+
+ // Generate deterministic color per country code
+ const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
+ // Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
+ const visitedA3List = [...visitedA3]
+ const colorMap = {}
+ visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
+ const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
+
+ const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
+
+ geoLayerRef.current = L.geoJSON(geoData, {
+ renderer: canvasRenderer,
+ interactive: true,
+ bubblingMouseEvents: false,
+ style: (feature) => {
+ const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
+ const visited = visitedA3.has(a3)
+ return {
+ fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
+ fillOpacity: visited ? 0.7 : 0.3,
+ color: dark ? '#333' : '#cbd5e1',
+ weight: 0.5,
+ }
+ },
+ onEachFeature: (feature, layer) => {
+ const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
+ const c = countryMap[a3]
+ if (c) {
+ const name = resolveName(c.code)
+ const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
+ const tooltipHtml = `
+
+ ${name}
+
+ ${c.tripCount} ${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}
+ ${c.placeCount} ${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}
+
+
+
+ ${t('atlas.firstVisit')}
+ ${formatDate(c.firstVisit)}
+
+
+ ${t('atlas.lastVisitLabel')}
+ ${formatDate(c.lastVisit)}
+
+
+
+ `
+ layer.bindTooltip(tooltipHtml, {
+ sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
+ })
+ layer.on('click', () => loadCountryDetail(c.code))
+ layer.on('mouseover', (e) => {
+ e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
+ })
+ layer.on('mouseout', (e) => {
+ geoLayerRef.current.resetStyle(e.target)
+ })
+ }
+ }
+ }).addTo(mapInstance.current)
+ }, [geoData, data, dark])
+
+ const loadCountryDetail = async (code) => {
+ setSelectedCountry(code)
+ try {
+ const r = await apiClient.get(`/addons/atlas/country/${code}`)
+ setCountryDetail(r.data)
+ } catch { /* */ }
+ }
+
+ const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
+ const countries = data?.countries || []
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Map */}
+
+
+ {/* Mobile: Bottom bar */}
+
+
+ {/* Countries highlighted */}
+
+ {stats.totalCountries}
+ {t('atlas.countries')}
+
+ {[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
+
+ ))}
+
+
+
+ {/* Desktop Panel — bottom center, glass effect */}
+
+ {/* Liquid glass glare effect */}
+
+ {/* Border glow that follows cursor */}
+
+ navigate(`/trips/${id}`)}
+ t={t} dark={dark}
+ />
+
+
+
+
+
+ )
+}
+
+function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }) {
+ const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
+ const tp = dark ? '#f1f5f9' : '#0f172a'
+ const tm = dark ? '#94a3b8' : '#64748b'
+ const tf = dark ? '#475569' : '#94a3b8'
+ const accent = '#818cf8'
+
+ const { mostVisited, continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
+ const contEntries = continents ? Object.entries(continents).sort((a, b) => b[1] - a[1]) : []
+ const maxCont = contEntries.length > 0 ? contEntries[0][1] : 1
+ const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
+ const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
+
+ if (countries.length === 0 && !lastTrip) {
+ return (
+
+
+ {t('atlas.noData')}
+ {t('atlas.noDataHint')}
+
+ )
+ }
+
+ const thisYear = new Date().getFullYear()
+ const divider = `2px solid ${bg(0.08)}`
+
+ return (
+
+
+ {/* ═══ SECTION 1: Numbers ═══ */}
+ {/* Countries hero */}
+
+ {stats.totalCountries}
+ {t('atlas.countries')}
+
+ {/* Other stats */}
+ {[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
+
+ {v}
+ {l}
+
+ ))}
+
+ {/* ═══ DIVIDER ═══ */}
+
+
+ {/* ═══ SECTION 2: Continents ═══ */}
+
+ {['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map((cont) => {
+ const count = continents?.[cont] || 0
+ const active = count > 0
+ return (
+
+ {count}
+ {CL[cont]}
+
+ )
+ })}
+
+
+ {/* ═══ DIVIDER ═══ */}
+
+
+ {/* ═══ SECTION 3: Highlights & Streaks ═══ */}
+
+ {/* Last trip */}
+ {lastTrip && (
+
+ )}
+ {/* Next trip */}
+ {nextTrip && (
+
+ )}
+ {/* Streak */}
+ {streak > 0 && (
+
+ {streak}
+
+ {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
+
+
+ )}
+ {/* This year */}
+ {tripsThisYear > 0 && (
+
+ {tripsThisYear}
+
+ {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
+
+
+ )}
+
+
+ {/* ═══ Country detail overlay ═══ */}
+ {selectedCountry && countryDetail && (
+ <>
+
+
+ {countryCodeToFlag(selectedCountry)}
+
+ {resolveName(selectedCountry)}
+ {countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} Trips
+
+ {countryDetail.trips.slice(0, 3).map(trip => (
+
+ ))}
+
+
+
+ >
+ )}
+
+ )
+}
+
diff --git a/client/src/pages/DashboardPage.jsx b/client/src/pages/DashboardPage.jsx
index 3e0de10..ace9ff1 100644
--- a/client/src/pages/DashboardPage.jsx
+++ b/client/src/pages/DashboardPage.jsx
@@ -2,15 +2,17 @@ import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { tripsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
+import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
-import TravelStats from '../components/Dashboard/TravelStats'
+import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
+import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
- Archive, ArchiveRestore, Clock, MapPin,
+ Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
} from 'lucide-react'
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
@@ -345,11 +347,25 @@ export default function DashboardPage() {
const [showForm, setShowForm] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [showArchived, setShowArchived] = useState(false)
+ const [showWidgetSettings, setShowWidgetSettings] = useState(false)
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
+ const { settings, updateSetting } = useSettingsStore()
+ const showCurrency = settings.dashboard_currency !== 'off'
+ const showTimezone = settings.dashboard_timezone !== 'off'
+ const showSidebar = showCurrency || showTimezone
+
+ useEffect(() => {
+ if (showWidgetSettings === 'mobile') {
+ document.body.style.overflow = 'hidden'
+ } else {
+ document.body.style.overflow = ''
+ }
+ return () => { document.body.style.overflow = '' }
+ }, [showWidgetSettings])
useEffect(() => { loadTrips() }, [])
@@ -437,10 +453,10 @@ export default function DashboardPage() {
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
return (
-
+
{demoMode && }
-
+
{/* Header */}
@@ -453,21 +469,75 @@ export default function DashboardPage() {
: t('dashboard.subtitle.empty')}
-
+ {/* Widget settings dropdown */}
+ {showWidgetSettings && (
+
+ Widgets:
+
+
+
+ )}
+
+ {/* Mobile widgets button */}
+ {showSidebar && (
+ setShowWidgetSettings('mobile')}
+ className="lg:hidden flex items-center justify-center gap-2 w-full py-2.5 rounded-xl text-xs font-semibold mb-4"
+ style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
+ >
+
+ {showCurrency && showTimezone ? `${t('dashboard.currency')} & ${t('dashboard.timezone')}` : showCurrency ? t('dashboard.currency') : t('dashboard.timezone')}
+
+ )}
+
{/* Main content */}
@@ -562,14 +632,37 @@ export default function DashboardPage() {
)}
- {/* Stats sidebar */}
-
-
-
+ {/* Widgets sidebar */}
+ {showSidebar && (
+
+ {showCurrency && }
+ {showTimezone && }
+
+ )}
+ {/* Mobile widgets bottom sheet */}
+ {showWidgetSettings === 'mobile' && (
+ setShowWidgetSettings(false)}>
+ e.stopPropagation()}>
+
+ Widgets
+ setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
+
+
+
+
+ {showCurrency && }
+ {showTimezone && }
+
+
+
+ )}
+
{ setShowForm(false); setEditingTrip(null) }}
diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx
index f990eab..cddef01 100644
--- a/client/src/pages/TripPlannerPage.jsx
+++ b/client/src/pages/TripPlannerPage.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
+import ReactDOM from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
@@ -19,6 +20,7 @@ import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
+import { addonsApi } from '../api/client'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
@@ -32,12 +34,22 @@ export default function TripPlannerPage() {
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
+ const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
+
+ useEffect(() => {
+ addonsApi.enabled().then(data => {
+ const map = {}
+ data.addons.forEach(a => { map[a.id] = true })
+ setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
+ }).catch(() => {})
+ }, [])
+
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan') },
- { id: 'buchungen', label: t('trip.tabs.reservations') },
- { id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') },
- { id: 'finanzplan', label: t('trip.tabs.budget') },
- { id: 'dateien', label: t('trip.tabs.files') },
+ { id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') },
+ ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
+ ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
+ ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
]
const [activeTab, setActiveTab] = useState('plan')
@@ -287,7 +299,7 @@ export default function TripPlannerPage() {
{/* Offset by navbar (56px) + tab bar (44px) */}
-
+
{activeTab === 'plan' && (
@@ -422,16 +434,20 @@ export default function TripPlannerPage() {
-
- setMobileSidebarOpen('left')}
- style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
- {t('trip.mobilePlan')}
-
- setMobileSidebarOpen('right')}
- style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
- {t('trip.mobilePlaces')}
-
-
+ {/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */}
+ {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
+
+ setMobileSidebarOpen('left')}
+ style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
+ {t('trip.mobilePlan')}
+
+ setMobileSidebarOpen('right')}
+ style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
+ {t('trip.mobilePlaces')}
+
+ ,
+ document.body
+ )}
{selectedPlace && (
)}
- {mobileSidebarOpen && (
- setMobileSidebarOpen(null)}>
- e.stopPropagation()}>
+ {mobileSidebarOpen && ReactDOM.createPortal(
+ setMobileSidebarOpen(null)}>
+ e.stopPropagation()}>
{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}
setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
@@ -466,13 +482,14 @@ export default function TripPlannerPage() {
}
-
+ ,
+ document.body
)}
)}
{activeTab === 'buchungen' && (
-
+
+
)}
{activeTab === 'finanzplan' && (
-
+
)}
{activeTab === 'dateien' && (
-
+
tripStore.addFile(tripId, fd)}
diff --git a/client/src/pages/VacayPage.jsx b/client/src/pages/VacayPage.jsx
new file mode 100644
index 0000000..9d59ebe
--- /dev/null
+++ b/client/src/pages/VacayPage.jsx
@@ -0,0 +1,282 @@
+import React, { useEffect, useState, useCallback } from 'react'
+import ReactDOM from 'react-dom'
+import { useTranslation } from '../i18n'
+import { useVacayStore } from '../store/vacayStore'
+import { addListener, removeListener } from '../api/websocket'
+import Navbar from '../components/Layout/Navbar'
+import VacayCalendar from '../components/Vacay/VacayCalendar'
+import VacayPersons from '../components/Vacay/VacayPersons'
+import VacayStats from '../components/Vacay/VacayStats'
+import VacaySettings from '../components/Vacay/VacaySettings'
+import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
+import Modal from '../components/shared/Modal'
+
+export default function VacayPage() {
+ const { t } = useTranslation()
+ const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
+ const [showSettings, setShowSettings] = useState(false)
+ const [deleteYear, setDeleteYear] = useState(null)
+ const [showMobileSidebar, setShowMobileSidebar] = useState(false)
+
+ useEffect(() => { loadAll() }, [])
+
+ // Live sync via WebSocket
+ const handleWsMessage = useCallback((msg) => {
+ if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
+ loadPlan()
+ loadEntries(selectedYear)
+ loadStats(selectedYear)
+ if (msg.type === 'vacay:settings') loadAll()
+ }
+ if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') {
+ loadAll()
+ }
+ }, [selectedYear])
+
+ useEffect(() => {
+ addListener(handleWsMessage)
+ return () => removeListener(handleWsMessage)
+ }, [handleWsMessage])
+ useEffect(() => {
+ if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
+ }, [selectedYear])
+
+ const handleAddYear = () => {
+ const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
+ addYear(nextYear)
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // Sidebar content (shared between desktop sidebar and mobile drawer)
+ const sidebarContent = (
+ <>
+ {/* Year Selector */}
+
+
+ {t('vacay.year')}
+
+
+
+
+
+ { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
+
+
+ {selectedYear}
+ { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
+
+
+
+
+ {years.map(y => (
+ setSelectedYear(y)}
+ className="group relative py-1.5 rounded-lg text-xs font-medium transition-all text-center cursor-pointer"
+ style={{
+ background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
+ color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
+ }}>
+ {y}
+ {years.length > 1 && (
+ { e.stopPropagation(); setDeleteYear(y); setShowMobileSidebar(false) }}
+ className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white text-[7px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
+
+
+ )}
+
+ ))}
+
+
+
+
+
+ {/* Legend */}
+ {(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends) && (
+
+ {t('vacay.legend')}
+
+ {plan?.holidays_enabled && }
+ {plan?.company_holidays_enabled && }
+ {plan?.block_weekends && }
+
+
+ )}
+
+
+ >
+ )
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+ Vacay
+ {t('vacay.subtitle')}
+
+
+
+ {/* Mobile sidebar toggle */}
+ setShowMobileSidebar(true)}
+ className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
+ >
+
+
+ setShowSettings(true)}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
+ >
+
+ {t('vacay.settings')}
+
+
+
+
+ {/* Main layout */}
+
+ {/* Desktop Sidebar */}
+
+ {sidebarContent}
+
+
+ {/* Calendar */}
+
+
+
+
+
+
+
+ {/* Mobile Sidebar Drawer */}
+ {showMobileSidebar && ReactDOM.createPortal(
+
+ setShowMobileSidebar(false)} />
+
+ {sidebarContent}
+
+ ,
+ document.body
+ )}
+
+ {/* Settings Modal */}
+ setShowSettings(false)} title={t('vacay.settings')} size="md">
+ setShowSettings(false)} />
+
+
+ {/* Delete Year Modal */}
+ setDeleteYear(null)} title={t('vacay.removeYear')} size="sm">
+
+
+
+
+
+ {t('vacay.removeYearConfirm', { year: deleteYear })}
+
+
+ {t('vacay.removeYearHint')}
+
+
+
+
+ setDeleteYear(null)} className="px-4 py-2 text-sm rounded-lg transition-colors" style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
+ {t('common.cancel')}
+
+ { await removeYear(deleteYear); setDeleteYear(null) }} className="px-4 py-2 text-sm bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
+ {t('vacay.remove')}
+
+
+
+
+
+ {/* Incoming invite — forced fullscreen modal */}
+ {incomingInvites.length > 0 && ReactDOM.createPortal(
+
+ {incomingInvites.map(inv => (
+
+
+
+ {inv.username?.[0]?.toUpperCase()}
+
+
+ {t('vacay.inviteTitle')}
+
+
+ {inv.username} {t('vacay.inviteWantsToFuse')}
+
+
+
+
+
+
+
+
+
+
+ declineInvite(inv.plan_id)}
+ className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
+ style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
+ {t('vacay.decline')}
+
+ acceptInvite(inv.plan_id)}
+ className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
+ style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
+ {t('vacay.acceptFusion')}
+
+
+
+ ))}
+ ,
+ document.body
+ )}
+
+
+
+ )
+}
+
+function InfoItem({ icon: Icon, text }) {
+ return (
+
+
+ {text}
+
+ )
+}
+
+function LegendItem({ color, label }) {
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/client/src/store/vacayStore.js b/client/src/store/vacayStore.js
new file mode 100644
index 0000000..dfbe227
--- /dev/null
+++ b/client/src/store/vacayStore.js
@@ -0,0 +1,188 @@
+import { create } from 'zustand'
+import apiClient from '../api/client'
+
+const ax = apiClient
+const api = {
+ getPlan: () => ax.get('/addons/vacay/plan').then(r => r.data),
+ updatePlan: (data) => ax.put('/addons/vacay/plan', data).then(r => r.data),
+ updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then(r => r.data),
+ invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then(r => r.data),
+ acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then(r => r.data),
+ declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then(r => r.data),
+ cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then(r => r.data),
+ dissolve: () => ax.post('/addons/vacay/dissolve').then(r => r.data),
+ availableUsers: () => ax.get('/addons/vacay/available-users').then(r => r.data),
+ getYears: () => ax.get('/addons/vacay/years').then(r => r.data),
+ addYear: (year) => ax.post('/addons/vacay/years', { year }).then(r => r.data),
+ removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then(r => r.data),
+ getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then(r => r.data),
+ toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then(r => r.data),
+ toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then(r => r.data),
+ getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then(r => r.data),
+ updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then(r => r.data),
+ getCountries: () => ax.get('/addons/vacay/holidays/countries').then(r => r.data),
+ getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then(r => r.data),
+}
+
+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: {}, // date -> { name, localName }
+ loading: false,
+
+ setSelectedYear: (year) => set({ selectedYear: year }),
+ setSelectedUserId: (id) => 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) => {
+ const data = await api.updatePlan(updates)
+ set({ plan: data.plan })
+ await get().loadEntries()
+ await get().loadStats()
+ await get().loadHolidays()
+ },
+
+ updateColor: async (color, targetUserId) => {
+ await api.updateColor(color, targetUserId)
+ await get().loadPlan()
+ await get().loadEntries()
+ },
+
+ invite: async (userId) => {
+ await api.invite(userId)
+ await get().loadPlan()
+ },
+
+ acceptInvite: async (planId) => {
+ await api.acceptInvite(planId)
+ await get().loadAll()
+ },
+
+ declineInvite: async (planId) => {
+ await api.declineInvite(planId)
+ await get().loadPlan()
+ },
+
+ cancelInvite: async (userId) => {
+ 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] })
+ }
+ },
+
+ addYear: async (year) => {
+ const data = await api.addYear(year)
+ set({ years: data.years })
+ await get().loadStats(year)
+ },
+
+ removeYear: async (year) => {
+ const data = await api.removeYear(year)
+ set({ years: data.years })
+ },
+
+ loadEntries: async (year) => {
+ const y = year || get().selectedYear
+ const data = await api.getEntries(y)
+ set({ entries: data.entries, companyHolidays: data.companyHolidays })
+ },
+
+ toggleEntry: async (date, targetUserId) => {
+ await api.toggleEntry(date, targetUserId)
+ await get().loadEntries()
+ await get().loadStats()
+ },
+
+ toggleCompanyHoliday: async (date) => {
+ await api.toggleCompanyHoliday(date)
+ await get().loadEntries()
+ },
+
+ loadStats: async (year) => {
+ const y = year || get().selectedYear
+ const data = await api.getStats(y)
+ set({ stats: data.stats })
+ },
+
+ updateVacationDays: async (year, days, targetUserId) => {
+ await api.updateStats(year, days, targetUserId)
+ await get().loadStats(year)
+ },
+
+ loadHolidays: async (year) => {
+ const y = year || get().selectedYear
+ const plan = get().plan
+ if (!plan?.holidays_enabled || !plan?.holidays_region) {
+ 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)
+ // Check if this country HAS regional holidays
+ const hasRegions = data.some(h => h.counties && h.counties.length > 0)
+ // If country has regions but no region selected yet, only show global ones
+ // Actually: don't show ANY holidays until region is selected
+ if (hasRegions && !region) {
+ set({ holidays: {} })
+ return
+ }
+ const map = {}
+ data.forEach(h => {
+ 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: {} })
+ }
+ },
+
+ loadAll: async () => {
+ set({ loading: true })
+ try {
+ await get().loadPlan()
+ await get().loadYears()
+ const year = get().selectedYear
+ await get().loadEntries(year)
+ await get().loadStats(year)
+ await get().loadHolidays(year)
+ } finally {
+ set({ loading: false })
+ }
+ },
+}))
diff --git a/server/package-lock.json b/server/package-lock.json
index 23a5da1..d6e8769 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nomad-server",
- "version": "2.3.4",
+ "version": "2.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
- "version": "2.3.4",
+ "version": "2.4.1",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
diff --git a/server/package.json b/server/package.json
index 0926485..bf46111 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "nomad-server",
- "version": "2.4.1",
+ "version": "2.5.0",
"main": "src/index.js",
"scripts": {
"start": "node --experimental-sqlite src/index.js",
diff --git a/server/src/db/database.js b/server/src/db/database.js
index 87311cc..fe5f909 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -211,6 +211,82 @@ function initDb() {
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+
+ -- Addon system
+ CREATE TABLE IF NOT EXISTS addons (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ type TEXT NOT NULL DEFAULT 'global',
+ icon TEXT DEFAULT 'Puzzle',
+ enabled INTEGER DEFAULT 0,
+ config TEXT DEFAULT '{}',
+ sort_order INTEGER DEFAULT 0
+ );
+
+ -- Vacay addon tables
+ CREATE TABLE IF NOT EXISTS vacay_plans (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ block_weekends INTEGER DEFAULT 1,
+ holidays_enabled INTEGER DEFAULT 0,
+ holidays_region TEXT DEFAULT '',
+ company_holidays_enabled INTEGER DEFAULT 1,
+ carry_over_enabled INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(owner_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_plan_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(plan_id, user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_user_colors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ color TEXT DEFAULT '#6366f1',
+ UNIQUE(user_id, plan_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_years (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ year INTEGER NOT NULL,
+ UNIQUE(plan_id, year)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_user_years (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ year INTEGER NOT NULL,
+ vacation_days INTEGER DEFAULT 30,
+ carried_over INTEGER DEFAULT 0,
+ UNIQUE(user_id, plan_id, year)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ date TEXT NOT NULL,
+ note TEXT DEFAULT '',
+ UNIQUE(user_id, plan_id, date)
+ );
+
+ CREATE TABLE IF NOT EXISTS vacay_company_holidays (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ date TEXT NOT NULL,
+ note TEXT DEFAULT '',
+ UNIQUE(plan_id, date)
+ );
`);
// Create indexes for performance
@@ -307,6 +383,25 @@ function initDb() {
} catch (err) {
console.error('Error seeding categories:', err.message);
}
+
+ // Seed: default addons
+ try {
+ const existingAddons = _db.prepare('SELECT COUNT(*) as count FROM addons').get();
+ if (existingAddons.count === 0) {
+ const defaultAddons = [
+ { id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', sort_order: 0 },
+ { id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', sort_order: 1 },
+ { id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
+ { id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
+ { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
+ ];
+ const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
+ for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
+ console.log('Default addons seeded');
+ }
+ } catch (err) {
+ console.error('Error seeding addons:', err.message);
+ }
}
// Initialize on module load
diff --git a/server/src/index.js b/server/src/index.js
index 7bb320a..0ca3fa3 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -91,6 +91,21 @@ app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
+
+// Public addons endpoint (authenticated but not admin-only)
+const { authenticate: addonAuth } = require('./middleware/auth');
+const { db: addonDb } = require('./db/database');
+app.get('/api/addons', addonAuth, (req, res) => {
+ const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all();
+ res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
+});
+
+// Addon routes
+const vacayRoutes = require('./routes/vacay');
+app.use('/api/addons/vacay', vacayRoutes);
+const atlasRoutes = require('./routes/atlas');
+app.use('/api/addons/atlas', atlasRoutes);
+
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index 55de8df..f241e93 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -13,7 +13,14 @@ router.get('/users', (req, res) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
).all();
- res.json({ users });
+ // Add online status from WebSocket connections
+ let onlineUserIds = new Set();
+ try {
+ const { getOnlineUserIds } = require('../websocket');
+ onlineUserIds = getOnlineUserIds();
+ } catch { /* */ }
+ const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
+ res.json({ users: usersWithStatus });
});
// POST /api/admin/users
@@ -145,4 +152,21 @@ router.post('/save-demo-baseline', (req, res) => {
}
});
+// ── Addons ─────────────────────────────────────────────────
+
+router.get('/addons', (req, res) => {
+ const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all();
+ res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
+});
+
+router.put('/addons/:id', (req, res) => {
+ const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
+ if (!addon) return res.status(404).json({ error: 'Addon not found' });
+ const { enabled, config } = req.body;
+ if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
+ if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
+ const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
+ res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
+});
+
module.exports = router;
diff --git a/server/src/routes/atlas.js b/server/src/routes/atlas.js
new file mode 100644
index 0000000..45a5ab3
--- /dev/null
+++ b/server/src/routes/atlas.js
@@ -0,0 +1,247 @@
+const express = require('express');
+const { db } = require('../db/database');
+const { authenticate } = require('../middleware/auth');
+
+const router = express.Router();
+router.use(authenticate);
+
+// Country code lookup from coordinates (bounding box approach)
+// Covers most countries — not pixel-perfect but good enough for visited-country tracking
+const COUNTRY_BOXES = {
+ AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
+ AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
+ BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
+ CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
+ EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
+ GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
+ IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
+ JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
+ LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
+ NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
+ PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
+ RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
+ SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
+ AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
+};
+
+function getCountryFromCoords(lat, lng) {
+ for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
+ if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
+ return code;
+ }
+ }
+ return null;
+}
+
+function getCountryFromAddress(address) {
+ if (!address) return null;
+ // Take last segment after comma, trim
+ const parts = address.split(',').map(s => s.trim()).filter(Boolean);
+ if (parts.length === 0) return null;
+ const last = parts[parts.length - 1];
+ // Try to match known country names to codes
+ const NAME_TO_CODE = {
+ 'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
+ 'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
+ 'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','österreich':'AT','switzerland':'CH',
+ 'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','türkei':'TR',
+ 'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
+ 'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
+ 'denmark':'DK','dänemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
+ 'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumänien':'RO',
+ 'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
+ 'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
+ 'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
+ 'india':'IN','indien':'IN','egypt':'EG','ägypten':'EG','morocco':'MA','marokko':'MA',
+ 'south africa':'ZA','südafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
+ 'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
+ 'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
+ 'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
+ 'vietnam':'VN','south korea':'KR','südkorea':'KR','philippines':'PH','philippinen':'PH',
+ 'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
+ 'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
+ 'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
+ };
+ const normalized = last.toLowerCase();
+ if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
+ // Try 2-letter code directly
+ if (last.length === 2 && last === last.toUpperCase()) return last;
+ return null;
+}
+
+// GET /api/addons/atlas/stats
+router.get('/stats', (req, res) => {
+ const userId = req.user.id;
+
+ // Get all trips (own + shared)
+ const trips = db.prepare(`
+ SELECT DISTINCT t.* FROM trips t
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
+ WHERE t.user_id = ? OR m.user_id = ?
+ ORDER BY t.start_date DESC
+ `).all(userId, userId, userId);
+
+ // Get all places from those trips
+ const tripIds = trips.map(t => t.id);
+ if (tripIds.length === 0) {
+ return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
+ }
+
+ const placeholders = tripIds.map(() => '?').join(',');
+ const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
+
+ // Extract countries
+ const countrySet = new Map(); // code -> { code, places: [], trips: Set }
+ for (const place of places) {
+ let code = getCountryFromAddress(place.address);
+ if (!code && place.lat && place.lng) {
+ code = getCountryFromCoords(place.lat, place.lng);
+ }
+ if (code) {
+ if (!countrySet.has(code)) {
+ countrySet.set(code, { code, places: [], tripIds: new Set() });
+ }
+ countrySet.get(code).places.push({ id: place.id, name: place.name, lat: place.lat, lng: place.lng });
+ countrySet.get(code).tripIds.add(place.trip_id);
+ }
+ }
+
+ // Calculate total days across all trips
+ let totalDays = 0;
+ for (const trip of trips) {
+ if (trip.start_date && trip.end_date) {
+ const start = new Date(trip.start_date);
+ const end = new Date(trip.end_date);
+ const diff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
+ if (diff > 0) totalDays += diff;
+ }
+ }
+
+ const countries = [...countrySet.values()].map(c => {
+ const countryTrips = trips.filter(t => c.tripIds.has(t.id));
+ const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort();
+ return {
+ code: c.code,
+ placeCount: c.places.length,
+ tripCount: c.tripIds.size,
+ firstVisit: dates[0] || null,
+ lastVisit: dates[dates.length - 1] || null,
+ };
+ });
+
+ // Unique cities (extract city from address — second to last comma segment)
+ const citySet = new Set();
+ for (const place of places) {
+ if (place.address) {
+ const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
+ if (parts.length >= 2) citySet.add(parts[parts.length - 2]);
+ else if (parts.length === 1) citySet.add(parts[0]);
+ }
+ }
+ const totalCities = citySet.size;
+
+ // Most visited country
+ const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
+
+ // Continent breakdown
+ const CONTINENT_MAP = {
+ AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
+ BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
+ EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
+ IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
+ LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
+ PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
+ SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
+ };
+ const continents = {};
+ countries.forEach(c => {
+ const cont = CONTINENT_MAP[c.code] || 'Other';
+ continents[cont] = (continents[cont] || 0) + 1;
+ });
+
+ // Last trip (most recent past trip)
+ const now = new Date().toISOString().split('T')[0];
+ const pastTrips = trips.filter(t => t.end_date && t.end_date <= now).sort((a, b) => b.end_date.localeCompare(a.end_date));
+ const lastTrip = pastTrips[0] ? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date } : null;
+ // Find country for last trip
+ if (lastTrip) {
+ const lastTripPlaces = places.filter(p => p.trip_id === lastTrip.id);
+ for (const p of lastTripPlaces) {
+ let code = getCountryFromAddress(p.address);
+ if (!code && p.lat && p.lng) code = getCountryFromCoords(p.lat, p.lng);
+ if (code) { lastTrip.countryCode = code; break; }
+ }
+ }
+
+ // Next trip (earliest future trip)
+ const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date.localeCompare(b.start_date));
+ const nextTrip = futureTrips[0] ? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date } : null;
+ if (nextTrip) {
+ const diff = Math.ceil((new Date(nextTrip.start_date) - new Date()) / (1000 * 60 * 60 * 24));
+ nextTrip.daysUntil = Math.max(0, diff);
+ }
+
+ // Travel streak (consecutive years with at least one trip)
+ const tripYears = new Set(trips.filter(t => t.start_date).map(t => parseInt(t.start_date.split('-')[0])));
+ let streak = 0;
+ const currentYear = new Date().getFullYear();
+ for (let y = currentYear; y >= 2000; y--) {
+ if (tripYears.has(y)) streak++;
+ else break;
+ }
+ const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null;
+
+ res.json({
+ countries,
+ stats: {
+ totalTrips: trips.length,
+ totalPlaces: places.length,
+ totalCountries: countries.length,
+ totalDays,
+ totalCities,
+ },
+ mostVisited,
+ continents,
+ lastTrip,
+ nextTrip,
+ streak,
+ firstYear,
+ tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length,
+ });
+});
+
+// GET /api/addons/atlas/country/:code — details for a country
+router.get('/country/:code', (req, res) => {
+ const userId = req.user.id;
+ const code = req.params.code.toUpperCase();
+
+ const trips = db.prepare(`
+ SELECT DISTINCT t.* FROM trips t
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
+ WHERE t.user_id = ? OR m.user_id = ?
+ `).all(userId, userId, userId);
+
+ const tripIds = trips.map(t => t.id);
+ if (tripIds.length === 0) return res.json({ places: [], trips: [] });
+
+ const placeholders = tripIds.map(() => '?').join(',');
+ const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
+
+ const matchingPlaces = [];
+ const matchingTripIds = new Set();
+
+ for (const place of places) {
+ let pCode = getCountryFromAddress(place.address);
+ if (!pCode && place.lat && place.lng) pCode = getCountryFromCoords(place.lat, place.lng);
+ if (pCode === code) {
+ matchingPlaces.push({ id: place.id, name: place.name, address: place.address, lat: place.lat, lng: place.lng, trip_id: place.trip_id });
+ matchingTripIds.add(place.trip_id);
+ }
+ }
+
+ const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
+
+ res.json({ places: matchingPlaces, trips: matchingTrips });
+});
+
+module.exports = router;
diff --git a/server/src/routes/vacay.js b/server/src/routes/vacay.js
new file mode 100644
index 0000000..592d74b
--- /dev/null
+++ b/server/src/routes/vacay.js
@@ -0,0 +1,582 @@
+const express = require('express');
+const { db } = require('../db/database');
+const { authenticate } = require('../middleware/auth');
+
+// In-memory cache for holiday API results (key: "year-country", ttl: 24h)
+const holidayCache = new Map();
+const CACHE_TTL = 24 * 60 * 60 * 1000;
+
+const router = express.Router();
+router.use(authenticate);
+
+// Broadcast vacay updates to all users in the same plan
+function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
+ try {
+ const { broadcastToUser } = require('../websocket');
+ const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
+ if (!plan) return;
+ const userIds = [plan.owner_id];
+ const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
+ members.forEach(m => userIds.push(m.user_id));
+ userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event }));
+ } catch { /* */ }
+}
+
+// ── Helpers ────────────────────────────────────────────────
+
+// Get or create the user's own plan
+function getOwnPlan(userId) {
+ let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
+ if (!plan) {
+ db.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(userId);
+ plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
+ const yr = new Date().getFullYear();
+ db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
+ // Create user config for current year
+ db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
+ }
+ return plan;
+}
+
+// Get the plan the user is currently part of (own or fused)
+function getActivePlan(userId) {
+ // Check if user has accepted a fusion
+ const membership = db.prepare(`
+ SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
+ `).get(userId);
+ if (membership) {
+ return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id);
+ }
+ return getOwnPlan(userId);
+}
+
+function getActivePlanId(userId) {
+ return getActivePlan(userId).id;
+}
+
+// Get all users in a plan (owner + accepted members)
+function getPlanUsers(planId) {
+ const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
+ if (!plan) return [];
+ const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id);
+ const members = db.prepare(`
+ SELECT u.id, u.username, u.email FROM vacay_plan_members m
+ JOIN users u ON m.user_id = u.id
+ WHERE m.plan_id = ? AND m.status = 'accepted'
+ `).all(planId);
+ return [owner, ...members];
+}
+
+// ── Plan ───────────────────────────────────────────────────
+
+router.get('/plan', (req, res) => {
+ const plan = getActivePlan(req.user.id);
+ const activePlanId = plan.id;
+
+ // Get user colors
+ const users = getPlanUsers(activePlanId).map(u => {
+ const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId);
+ return { ...u, color: colorRow?.color || '#6366f1' };
+ });
+
+ // Pending invites (sent from this plan)
+ const pendingInvites = db.prepare(`
+ SELECT m.id, m.user_id, u.username, u.email, m.created_at
+ FROM vacay_plan_members m JOIN users u ON m.user_id = u.id
+ WHERE m.plan_id = ? AND m.status = 'pending'
+ `).all(activePlanId);
+
+ // Pending invites FOR this user (from others)
+ const incomingInvites = db.prepare(`
+ SELECT m.id, m.plan_id, u.username, u.email, m.created_at
+ FROM vacay_plan_members m
+ JOIN vacay_plans p ON m.plan_id = p.id
+ JOIN users u ON p.owner_id = u.id
+ WHERE m.user_id = ? AND m.status = 'pending'
+ `).all(req.user.id);
+
+ res.json({
+ plan: {
+ ...plan,
+ block_weekends: !!plan.block_weekends,
+ holidays_enabled: !!plan.holidays_enabled,
+ company_holidays_enabled: !!plan.company_holidays_enabled,
+ carry_over_enabled: !!plan.carry_over_enabled,
+ },
+ users,
+ pendingInvites,
+ incomingInvites,
+ isOwner: plan.owner_id === req.user.id,
+ isFused: users.length > 1,
+ });
+});
+
+router.put('/plan', async (req, res) => {
+ const planId = getActivePlanId(req.user.id);
+ const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled } = req.body;
+
+ const updates = [];
+ const params = [];
+ if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); }
+ if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); }
+ if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
+ if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
+
+ if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
+
+ if (updates.length > 0) {
+ params.push(planId);
+ db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ }
+
+ // If company holidays re-enabled, remove vacation entries that overlap with company holidays
+ if (company_holidays_enabled === true) {
+ const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId);
+ for (const { date } of companyDates) {
+ db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
+ }
+ }
+
+ // If public holidays enabled (or region changed), remove vacation entries that land on holidays
+ // Only if a full region is selected (for countries that require it)
+ const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
+ 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);
+ for (const { year } of years) {
+ try {
+ const cacheKey = `${year}-${country}`;
+ let holidays = holidayCache.get(cacheKey)?.data;
+ if (!holidays) {
+ const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
+ holidays = await resp.json();
+ holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
+ }
+ const hasRegions = holidays.some(h => h.counties && h.counties.length > 0);
+ // If country has regions but no region selected, skip cleanup
+ 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 */ }
+ }
+ }
+
+ // If carry-over was just disabled, reset all carried_over values to 0
+ if (carry_over_enabled === false) {
+ db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
+ }
+
+ // If carry-over was just enabled, recalculate all years
+ if (carry_over_enabled === true) {
+ const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
+ const users = getPlanUsers(planId);
+ for (let i = 0; i < years.length - 1; i++) {
+ const yr = years[i].year;
+ const nextYr = years[i + 1].year;
+ for (const u of users) {
+ const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`).count;
+ const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr);
+ const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
+ const carry = Math.max(0, total - used);
+ db.prepare(`
+ INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
+ ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
+ `).run(u.id, planId, nextYr, carry, carry);
+ }
+ }
+ }
+
+ notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+
+ const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
+ 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 }
+ });
+});
+
+// ── User color ─────────────────────────────────────────────
+
+router.put('/color', (req, res) => {
+ const { color, target_user_id } = req.body;
+ const planId = getActivePlanId(req.user.id);
+ const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
+ const planUsers = getPlanUsers(planId);
+ if (!planUsers.find(u => u.id === userId)) {
+ return res.status(403).json({ error: 'User not in plan' });
+ }
+ db.prepare(`
+ INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
+ ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
+ `).run(userId, planId, color || '#6366f1');
+ notifyPlanUsers(planId, req.user.id, 'vacay:update');
+ res.json({ success: true });
+});
+
+// ── Invite / Accept / Decline / Dissolve ───────────────────
+
+// Invite a user
+router.post('/invite', (req, res) => {
+ const { user_id } = req.body;
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
+ if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot invite yourself' });
+
+ const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(user_id);
+ if (!targetUser) return res.status(404).json({ error: 'User not found' });
+
+ const plan = getActivePlan(req.user.id);
+
+ // Check if already invited or member
+ const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, user_id);
+ if (existing) {
+ if (existing.status === 'accepted') return res.status(400).json({ error: 'Already fused' });
+ if (existing.status === 'pending') return res.status(400).json({ error: 'Invite already pending' });
+ }
+
+ // Check if target user is already fused with someone else
+ const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(user_id);
+ if (targetFusion) return res.status(400).json({ error: 'User is already fused with another plan' });
+
+ db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(plan.id, user_id, 'pending');
+
+ // Broadcast via WebSocket if available
+ try {
+ const { broadcastToUser } = require('../websocket');
+ broadcastToUser(user_id, {
+ type: 'vacay:invite',
+ from: { id: req.user.id, username: req.user.username },
+ planId: plan.id,
+ });
+ } catch { /* websocket not available */ }
+
+ res.json({ success: true });
+});
+
+// Accept invite
+router.post('/invite/accept', (req, res) => {
+ const { plan_id } = req.body;
+ const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(plan_id, req.user.id);
+ if (!invite) return res.status(404).json({ error: 'No pending invite' });
+
+ // Accept
+ db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id);
+
+ // Migrate user's own entries into the fused plan
+ const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(req.user.id);
+ if (ownPlan && ownPlan.id !== plan_id) {
+ // Move entries
+ db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(plan_id, ownPlan.id, req.user.id);
+ // Copy year configs
+ const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(req.user.id, ownPlan.id);
+ for (const y of ownYears) {
+ db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(req.user.id, plan_id, y.year, y.vacation_days, y.carried_over);
+ }
+ // Copy color
+ const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, ownPlan.id);
+ if (colorRow) {
+ db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(req.user.id, plan_id, colorRow.color);
+ }
+ }
+
+ // Auto-change color if it collides with existing plan users
+ const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488'];
+ const existingColors = db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, req.user.id).map(r => r.color);
+ const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, plan_id);
+ if (myColor && existingColors.includes(myColor.color)) {
+ const available = COLORS.find(c => !existingColors.includes(c));
+ if (available) {
+ db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, req.user.id, plan_id);
+ }
+ }
+
+ // Ensure years exist in target plan
+ const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id);
+ for (const y of targetYears) {
+ db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(req.user.id, plan_id, y.year);
+ }
+
+ // Notify all plan users (not just owner)
+ notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
+
+ res.json({ success: true });
+});
+
+// Decline invite
+router.post('/invite/decline', (req, res) => {
+ const { plan_id } = req.body;
+ db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
+
+ notifyPlanUsers(plan_id, req.user.id, 'vacay:declined');
+
+ res.json({ success: true });
+});
+
+// Cancel pending invite (by inviter)
+router.post('/invite/cancel', (req, res) => {
+ const { user_id } = req.body;
+ const plan = getActivePlan(req.user.id);
+ db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan.id, user_id);
+
+ try {
+ const { broadcastToUser } = require('../websocket');
+ broadcastToUser(user_id, { type: 'vacay:cancelled' });
+ } catch { /* */ }
+
+ res.json({ success: true });
+});
+
+// Dissolve fusion
+router.post('/dissolve', (req, res) => {
+ const plan = getActivePlan(req.user.id);
+ const isOwner = plan.owner_id === req.user.id;
+
+ // Collect all user IDs and company holidays before dissolving
+ const allUserIds = getPlanUsers(plan.id).map(u => u.id);
+ const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id);
+
+ if (isOwner) {
+ const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id);
+ for (const m of members) {
+ const memberPlan = getOwnPlan(m.user_id);
+ db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id);
+ // Copy company holidays to member's own plan
+ for (const ch of companyHolidays) {
+ db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note);
+ }
+ }
+ db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id);
+ } else {
+ const ownPlan = getOwnPlan(req.user.id);
+ db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, req.user.id);
+ // Copy company holidays to own plan
+ for (const ch of companyHolidays) {
+ db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note);
+ }
+ db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, req.user.id);
+ }
+
+ // Notify all former plan members
+ try {
+ const { broadcastToUser } = require('../websocket');
+ allUserIds.filter(id => id !== req.user.id).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
+ } catch { /* */ }
+
+ res.json({ success: true });
+});
+
+// ── Available users to invite ──────────────────────────────
+
+router.get('/available-users', (req, res) => {
+ const planId = getActivePlanId(req.user.id);
+ // All users except: self, already in this plan, already fused elsewhere
+ const users = db.prepare(`
+ SELECT u.id, u.username, u.email FROM users u
+ WHERE u.id != ?
+ AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?)
+ AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted')
+ AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN (
+ SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted'
+ ))
+ ORDER BY u.username
+ `).all(req.user.id, planId);
+ res.json({ users });
+});
+
+// ── Years ──────────────────────────────────────────────────
+
+router.get('/years', (req, res) => {
+ const planId = getActivePlanId(req.user.id);
+ const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
+ res.json({ years: years.map(y => y.year) });
+});
+
+router.post('/years', (req, res) => {
+ const { year } = req.body;
+ if (!year) return res.status(400).json({ error: 'Year required' });
+ const planId = getActivePlanId(req.user.id);
+ try {
+ db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year);
+ const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
+ const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
+ const users = getPlanUsers(planId);
+ for (const u of users) {
+ // Calculate carry-over from previous year if enabled
+ let carriedOver = 0;
+ if (carryOverEnabled) {
+ const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1);
+ if (prevConfig) {
+ const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`).count;
+ const total = prevConfig.vacation_days + prevConfig.carried_over;
+ carriedOver = Math.max(0, total - used);
+ }
+ }
+ db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
+ }
+ } catch { /* exists */ }
+ notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+ const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
+ res.json({ years: years.map(y => y.year) });
+});
+
+router.delete('/years/:year', (req, res) => {
+ const year = parseInt(req.params.year);
+ const planId = getActivePlanId(req.user.id);
+ db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
+ db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
+ db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
+ notifyPlanUsers(planId, req.user.id, 'vacay:settings');
+ const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
+ res.json({ years: years.map(y => y.year) });
+});
+
+// ── Entries ────────────────────────────────────────────────
+
+router.get('/entries/:year', (req, res) => {
+ const year = req.params.year;
+ const planId = getActivePlanId(req.user.id);
+ const entries = db.prepare(`
+ SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
+ FROM vacay_entries e
+ JOIN users u ON e.user_id = u.id
+ LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id
+ WHERE e.plan_id = ? AND e.date LIKE ?
+ `).all(planId, `${year}-%`);
+ const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`);
+ res.json({ entries, companyHolidays });
+});
+
+router.post('/entries/toggle', (req, res) => {
+ const { date, target_user_id } = req.body;
+ if (!date) return res.status(400).json({ error: 'date required' });
+ const planId = getActivePlanId(req.user.id);
+ // Allow toggling for another user if they are in the same plan
+ let userId = req.user.id;
+ if (target_user_id && parseInt(target_user_id) !== req.user.id) {
+ const planUsers = getPlanUsers(planId);
+ const tid = parseInt(target_user_id);
+ if (!planUsers.find(u => u.id === tid)) {
+ return res.status(403).json({ error: 'User not in plan' });
+ }
+ userId = tid;
+ }
+ const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
+ if (existing) {
+ db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
+ notifyPlanUsers(planId, req.user.id);
+ res.json({ action: 'removed' });
+ } else {
+ db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
+ notifyPlanUsers(planId, req.user.id);
+ res.json({ action: 'added' });
+ }
+});
+
+router.post('/entries/company-holiday', (req, res) => {
+ const { date, note } = req.body;
+ const planId = getActivePlanId(req.user.id);
+ const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
+ if (existing) {
+ db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
+ notifyPlanUsers(planId, req.user.id);
+ res.json({ action: 'removed' });
+ } else {
+ db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
+ // Remove any vacation entries on this date
+ db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
+ notifyPlanUsers(planId, req.user.id);
+ res.json({ action: 'added' });
+ }
+});
+
+// ── Stats ──────────────────────────────────────────────────
+
+router.get('/stats/:year', (req, res) => {
+ const year = parseInt(req.params.year);
+ const planId = getActivePlanId(req.user.id);
+ const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
+ const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
+ const users = getPlanUsers(planId);
+
+ const stats = users.map(u => {
+ const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`).count;
+ const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year);
+ const vacationDays = config ? config.vacation_days : 30;
+ const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
+ const total = vacationDays + carriedOver;
+ const remaining = total - used;
+ const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId);
+
+ // Auto-update carry-over into next year (only if enabled)
+ const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1);
+ if (nextYearExists && carryOverEnabled) {
+ const carry = Math.max(0, remaining);
+ db.prepare(`
+ INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
+ ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
+ `).run(u.id, planId, year + 1, carry, carry);
+ }
+
+ return {
+ user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
+ year, vacation_days: vacationDays, carried_over: carriedOver,
+ total_available: total, used, remaining,
+ };
+ });
+
+ res.json({ stats });
+});
+
+// Update vacation days for a year (own or fused partner)
+router.put('/stats/:year', (req, res) => {
+ const year = parseInt(req.params.year);
+ const { vacation_days, target_user_id } = req.body;
+ const planId = getActivePlanId(req.user.id);
+ const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
+ const planUsers = getPlanUsers(planId);
+ if (!planUsers.find(u => u.id === userId)) {
+ return res.status(403).json({ error: 'User not in plan' });
+ }
+ db.prepare(`
+ INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
+ ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
+ `).run(userId, planId, year, vacation_days);
+ notifyPlanUsers(planId, req.user.id);
+ res.json({ success: true });
+});
+
+// ── Public Holidays API (proxy to Nager.Date) ─────────────
+
+router.get('/holidays/countries', async (req, res) => {
+ const cacheKey = 'countries';
+ const cached = holidayCache.get(cacheKey);
+ if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
+ try {
+ const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries');
+ const data = await resp.json();
+ holidayCache.set(cacheKey, { data, time: Date.now() });
+ res.json(data);
+ } catch {
+ res.status(502).json({ error: 'Failed to fetch countries' });
+ }
+});
+
+router.get('/holidays/:year/:country', async (req, res) => {
+ const { year, country } = req.params;
+ const cacheKey = `${year}-${country}`;
+ const cached = holidayCache.get(cacheKey);
+ if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
+ try {
+ const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
+ const data = await resp.json();
+ holidayCache.set(cacheKey, { data, time: Date.now() });
+ res.json(data);
+ } catch {
+ res.status(502).json({ error: 'Failed to fetch holidays' });
+ }
+});
+
+module.exports = router;
diff --git a/server/src/websocket.js b/server/src/websocket.js
index a898be4..8347638 100644
--- a/server/src/websocket.js
+++ b/server/src/websocket.js
@@ -141,4 +141,26 @@ function broadcast(tripId, eventType, payload, excludeSid) {
}
}
-module.exports = { setupWebSocket, broadcast };
+function broadcastToUser(userId, payload) {
+ if (!wss) return;
+ for (const ws of wss.clients) {
+ if (ws.readyState !== 1) continue;
+ const user = socketUser.get(ws);
+ if (user && user.id === userId) {
+ ws.send(JSON.stringify(payload));
+ }
+ }
+}
+
+function getOnlineUserIds() {
+ const ids = new Set();
+ if (!wss) return ids;
+ for (const ws of wss.clients) {
+ if (ws.readyState !== 1) continue;
+ const user = socketUser.get(ws);
+ if (user) ids.add(user.id);
+ }
+ return ids;
+}
+
+module.exports = { setupWebSocket, broadcast, broadcastToUser, getOnlineUserIds };
|