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:
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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}¤t=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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user