v2.5.6: Open-Meteo weather, WebSocket fixes, admin improvements

- Replace OpenWeatherMap with Open-Meteo (no API key needed)
  - 16-day forecast (up from 5 days)
  - Historical climate averages as fallback beyond 16 days
  - Auto-upgrade from climate to real forecast when available
- Fix Vacay WebSocket sync across devices (socket-ID exclusion instead of user-ID)
- Add GitHub release history tab in admin panel
- Show cluster count "1" for single map markers when zoomed out
- Add weather info panel in admin settings (replaces OpenWeatherMap key input)
- Update i18n translations (DE + EN)
This commit is contained in:
Maurice
2026-03-24 10:02:03 +01:00
parent faa8c84655
commit e4607e426c
22 changed files with 631 additions and 327 deletions

View File

@@ -107,23 +107,6 @@ app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
// Exchange rates (cached 1h, authenticated)
const { authenticate: rateAuth } = require('./middleware/auth');
let _rateCache = { data: null, ts: 0 };
app.get('/api/exchange-rates', rateAuth, async (req, res) => {
const now = Date.now();
if (_rateCache.data && now - _rateCache.ts < 3600000) return res.json(_rateCache.data);
try {
const r = await fetch('https://api.frankfurter.app/latest?from=EUR');
if (!r.ok) return res.status(502).json({ error: 'Failed to fetch rates' });
const data = await r.json();
_rateCache = { data, ts: now };
res.json(data);
} catch {
res.status(502).json({ error: 'Failed to fetch rates' });
}
});
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');

View File

@@ -9,8 +9,8 @@ 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') {
// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
try {
const { broadcastToUser } = require('../websocket');
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
@@ -18,7 +18,7 @@ function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
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 }));
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
} catch { /* */ }
}
@@ -191,7 +191,7 @@ router.put('/plan', async (req, res) => {
}
}
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
res.json({
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
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');
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
res.json({ success: true });
});
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
}
// Notify all plan users (not just owner)
notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
res.json({ success: true });
});
@@ -310,7 +310,7 @@ 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');
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
res.json({ success: true });
});
@@ -417,7 +417,7 @@ router.post('/years', (req, res) => {
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');
notifyPlanUsers(planId, req.headers['x-socket-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) });
});
@@ -428,7 +428,7 @@ router.delete('/years/:year', (req, res) => {
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');
notifyPlanUsers(planId, req.headers['x-socket-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) });
});
@@ -453,28 +453,10 @@ 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);
const planUsers = getPlanUsers(planId);
// Toggle for all users in plan
if (target_user_id === 'all') {
const actions = [];
for (const u of planUsers) {
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(u.id, date, planId);
if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
actions.push({ user_id: u.id, action: 'removed' });
} else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, u.id, date, '');
actions.push({ user_id: u.id, action: 'added' });
}
}
notifyPlanUsers(planId, req.user.id);
return res.json({ action: 'toggled_all', actions });
}
// 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' });
@@ -484,11 +466,11 @@ router.post('/entries/toggle', (req, res) => {
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);
notifyPlanUsers(planId, req.headers['x-socket-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);
notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' });
}
});
@@ -499,13 +481,13 @@ router.post('/entries/company-holiday', (req, res) => {
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);
notifyPlanUsers(planId, req.headers['x-socket-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);
notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' });
}
});
@@ -562,7 +544,7 @@ router.put('/stats/:year', (req, res) => {
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);
notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ success: true });
});

View File

@@ -1,6 +1,5 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
@@ -10,11 +9,12 @@ const weatherCache = new Map();
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours (historical data doesn't change)
function cacheKey(lat, lng, date, units) {
function cacheKey(lat, lng, date) {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}_${units}`;
return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key) {
@@ -30,46 +30,123 @@ function getCached(key) {
function setCache(key, data, ttlMs) {
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
// WMO weather code mapping → condition string used by client icon map
const WMO_MAP = {
0: 'Clear',
1: 'Clear', // mainly clear
2: 'Clouds', // partly cloudy
3: 'Clouds', // overcast
45: 'Fog',
48: 'Fog',
51: 'Drizzle',
53: 'Drizzle',
55: 'Drizzle',
56: 'Drizzle', // freezing drizzle
57: 'Drizzle',
61: 'Rain',
63: 'Rain',
65: 'Rain', // heavy rain
66: 'Rain', // freezing rain
67: 'Rain',
71: 'Snow',
73: 'Snow',
75: 'Snow',
77: 'Snow', // snow grains
80: 'Rain', // rain showers
81: 'Rain',
82: 'Rain',
85: 'Snow', // snow showers
86: 'Snow',
95: 'Thunderstorm',
96: 'Thunderstorm',
99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE = {
0: 'Klar',
1: 'Überwiegend klar',
2: 'Teilweise bewölkt',
3: 'Bewölkt',
45: 'Nebel',
48: 'Nebel mit Reif',
51: 'Leichter Nieselregen',
53: 'Nieselregen',
55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen',
57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen',
63: 'Regen',
65: 'Starker Regen',
66: 'Gefrierender Regen',
67: 'Starker gefr. Regen',
71: 'Leichter Schneefall',
73: 'Schneefall',
75: 'Starker Schneefall',
77: 'Schneekörner',
80: 'Leichte Regenschauer',
81: 'Regenschauer',
82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer',
86: 'Starke Schneeschauer',
95: 'Gewitter',
96: 'Gewitter mit Hagel',
99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Rime fog',
51: 'Light drizzle',
53: 'Drizzle',
55: 'Heavy drizzle',
56: 'Freezing drizzle',
57: 'Heavy freezing drizzle',
61: 'Light rain',
63: 'Rain',
65: 'Heavy rain',
66: 'Freezing rain',
67: 'Heavy freezing rain',
71: 'Light snowfall',
73: 'Snowfall',
75: 'Heavy snowfall',
77: 'Snow grains',
80: 'Light rain showers',
81: 'Rain showers',
82: 'Heavy rain showers',
85: 'Light snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with hail',
99: 'Severe thunderstorm with hail',
};
// Estimate weather condition from average temperature + precipitation
function estimateCondition(tempAvg, precipMm) {
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
if (precipMm > 0.3) return 'Clouds';
return tempAvg > 15 ? 'Clear' : 'Clouds';
}
// -------------------------------------------------------
function formatItem(item) {
return {
temp: Math.round(item.main.temp),
feels_like: Math.round(item.main.feels_like),
humidity: item.main.humidity,
main: item.weather[0]?.main || '',
description: item.weather[0]?.description || '',
icon: item.weather[0]?.icon || '',
};
}
// GET /api/weather?lat=&lng=&date=&units=metric
// GET /api/weather?lat=&lng=&date=&lang=de
router.get('/', authenticate, async (req, res) => {
const { lat, lng, date, units = 'metric' } = req.query;
const { lat, lng, date, lang = 'de' } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
}
// User's own key, or fall back to admin's key
let key = null;
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (user?.openweather_api_key) {
key = user.openweather_api_key;
} else {
const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get();
key = admin?.openweather_api_key || null;
}
if (!key) {
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
}
const ck = cacheKey(lat, lng, date, units);
const ck = cacheKey(lat, lng, date);
try {
// If a date is requested, try the 5-day forecast first
// ── Forecast for a specific date ──
if (date) {
// Check cache
const cached = getCached(ck);
if (cached) return res.json(cached);
@@ -77,49 +154,122 @@ router.get('/', authenticate, async (req, res) => {
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
// Within 5-day forecast window
if (diffDays >= -1 && diffDays <= 5) {
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
// Within 16-day forecast window → real forecast
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
}
const filtered = (data.list || []).filter(item => {
const itemDate = new Date(item.dt * 1000);
return itemDate.toDateString() === targetDate.toDateString();
});
const dateStr = targetDate.toISOString().slice(0, 10);
const idx = (data.daily?.time || []).indexOf(dateStr);
if (idx !== -1) {
const code = data.daily.weathercode[idx];
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result = {
temp: Math.round((data.daily.temperature_2m_max[idx] + data.daily.temperature_2m_min[idx]) / 2),
temp_max: Math.round(data.daily.temperature_2m_max[idx]),
temp_min: Math.round(data.daily.temperature_2m_min[idx]),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'forecast',
};
if (filtered.length > 0) {
const midday = filtered.find(item => {
const hour = new Date(item.dt * 1000).getHours();
return hour >= 11 && hour <= 14;
}) || filtered[0];
const result = formatItem(midday);
setCache(ck, result, TTL_FORECAST_MS);
return res.json(result);
}
// Forecast didn't include this date — fall through to climate
}
// Outside forecast window — no data available
// Beyond forecast range or forecast gap → historical climate average
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
// Query a 5-day window around the target date for smoother averages (using last year as reference)
const refYear = targetDate.getFullYear() - 1;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API Fehler' });
}
const daily = data.daily;
if (!daily || !daily.time || daily.time.length === 0) {
return res.json({ error: 'no_forecast' });
}
// Average across the window
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
for (let i = 0; i < daily.time.length; i++) {
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
sumMax += daily.temperature_2m_max[i];
sumMin += daily.temperature_2m_min[i];
sumPrecip += daily.precipitation_sum[i] || 0;
count++;
}
}
if (count === 0) {
return res.json({ error: 'no_forecast' });
}
const avgMax = sumMax / count;
const avgMin = sumMin / count;
const avgTemp = (avgMax + avgMin) / 2;
const avgPrecip = sumPrecip / count;
const main = estimateCondition(avgTemp, avgPrecip);
const result = {
temp: Math.round(avgTemp),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main,
description: '',
type: 'climate',
};
setCache(ck, result, TTL_CLIMATE_MS);
return res.json(result);
}
// Past dates beyond yesterday
return res.json({ error: 'no_forecast' });
}
// No date — return current weather
// ── Current weather (no date) ──
const cached = getCached(ck);
if (cached) return res.json(cached);
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
}
const result = formatItem(data);
const code = data.current.weathercode;
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result = {
temp: Math.round(data.current.temperature_2m),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'current',
};
setCache(ck, result, TTL_CURRENT_MS);
res.json(result);
} catch (err) {

View File

@@ -141,10 +141,12 @@ function broadcast(tripId, eventType, payload, excludeSid) {
}
}
function broadcastToUser(userId, payload) {
function broadcastToUser(userId, payload, excludeSid) {
if (!wss) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of wss.clients) {
if (ws.readyState !== 1) continue;
if (excludeNum && socketId.get(ws) === excludeNum) continue;
const user = socketUser.get(ws);
if (user && user.id === userId) {
ws.send(JSON.stringify(payload));