mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
v2.5.0 — Addon System, Vacay, Atlas, Dashboard Widgets & Mobile Overhaul
The biggest NOMAD update yet. Introduces a modular addon architecture and three major new features. Addon System: - Admin panel addon management with enable/disable toggles - Trip addons (Packing List, Budget, Documents) dynamically show/hide in trip tabs - Global addons appear in the main navigation for all users Vacay — Vacation Day Planner (Global Addon): - Monthly calendar view with international public holidays (100+ countries via Nager.Date API) - Company holidays with auto-cleanup of conflicting entries - User-based system: each NOMAD user is a person in the calendar - Fusion system: invite other users to share a combined calendar with real-time WebSocket sync - Vacation entitlement tracking with automatic carry-over to next year - Full settings: block weekends, public holidays, company holidays, carry-over toggle - Invite/accept/decline flow with forced confirmation modal - Color management per user with collision detection on fusion - Dissolve fusion with preserved entries Atlas — Travel World Map (Global Addon): - Fullscreen Leaflet world map with colored country polygons (GeoJSON) - Glass-effect bottom panel with stats, continent breakdown, streak tracking - Country tooltips with trip count, places visited, first/last visit dates - Liquid glass hover effect on the stats panel - Canvas renderer with tile preloading for maximum performance - Responsive: mobile stats bars, no zoom controls on touch Dashboard Widgets: - Currency converter with 50 currencies, CustomSelect dropdowns, localStorage persistence - Timezone widget with customizable city list, live updating clock - Per-user toggle via settings button, bottom sheet on mobile Admin Panel: - Consistent dark mode across all tabs (CSS variable overrides) - Online/offline status badges on user list via WebSocket - Unified heading sizes and subtitles across all sections - Responsive tab grid on mobile Mobile Improvements: - Vacay: slide-in sidebar drawer, floating toolbar, responsive calendar grid - Atlas: top/bottom glass stat bars, no popups - Trip Planner: fixed position content container prevents overscroll, portal-based sidebar buttons - Dashboard: fixed viewport container, mobile widget bottom sheet - Admin: responsive tab grid, compact buttons - Global: overscroll-behavior fixes, modal scroll containment Other: - Trip tab labels: Planung→Karte, Packliste→Liste, Buchungen→Buchung (DE mobile) - Reservation form responsive layout - Backup panel responsive buttons
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+23
-1
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user