diff --git a/README.md b/README.md index 96277eb..cfad21d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ - **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed) - **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering - **Route Optimization** — Auto-optimize place order and export to Google Maps -- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching +- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback ### Travel Management - **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments @@ -71,7 +71,7 @@ ### Customization & Admin - **Dark Mode** — Full light and dark theme with dynamic status bar color matching - **Multilingual** — English and German (i18n) -- **Admin Panel** — User management, global categories, addon management, API keys, and backups +- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history - **Auto-Backups** — Scheduled backups with configurable interval and retention - **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates @@ -84,18 +84,13 @@ - **State**: Zustand - **Auth**: JWT + OIDC - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) -- **Weather**: OpenWeatherMap API (optional) +- **Weather**: Open-Meteo API (free, no key required) - **Icons**: lucide-react ## Quick Start ```bash -mkdir -p /opt/nomad && cd /opt/nomad -docker run -d --name nomad -p 3000:3000 \ - -v /opt/nomad/data:/app/data \ - -v /opt/nomad/uploads:/app/uploads \ - --restart unless-stopped \ - mauriceboe/nomad:latest +docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad ``` The app runs on port `3000`. The first user to register becomes the admin. @@ -123,8 +118,8 @@ services: - NODE_ENV=production - PORT=3000 volumes: - - /opt/nomad/data:/app/data - - /opt/nomad/uploads:/app/uploads + - ./data:/app/data + - ./uploads:/app/uploads restart: unless-stopped ``` @@ -137,18 +132,14 @@ docker compose up -d ### Updating ```bash -docker pull mauriceboe/nomad:latest +docker pull mauriceboe/nomad docker rm -f nomad -docker run -d --name nomad -p 3000:3000 \ - -v /opt/nomad/data:/app/data \ - -v /opt/nomad/uploads:/app/uploads \ - --restart unless-stopped \ - mauriceboe/nomad:latest +docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad ``` Or with Docker Compose: `docker compose pull && docker compose up -d` -Your data is persisted in the mounted `/opt/nomad/data` and `/opt/nomad/uploads` volumes. +Your data is persisted in the mounted `data` and `uploads` volumes. ### Reverse Proxy (recommended) diff --git a/client/package-lock.json b/client/package-lock.json index 7402f98..76cbe57 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "nomad-client", - "version": "2.5.2", + "version": "2.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-client", - "version": "2.5.2", + "version": "2.5.5", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/package.json b/client/package.json index 79ed22f..4e7a4d2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "nomad-client", - "version": "2.5.5", + "version": "2.5.6", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.js b/client/src/api/client.js index cfcad6d..213e801 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -166,12 +166,8 @@ export const reservationsApi = { delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), } -export const exchangeApi = { - getRates: () => apiClient.get('/exchange-rates').then(r => r.data), -} - export const weatherApi = { - get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data), + get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), } export const settingsApi = { diff --git a/client/src/components/Admin/GitHubPanel.jsx b/client/src/components/Admin/GitHubPanel.jsx new file mode 100644 index 0000000..ea24e30 --- /dev/null +++ b/client/src/components/Admin/GitHubPanel.jsx @@ -0,0 +1,263 @@ +import React, { useState, useEffect } from 'react' +import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' +import { useTranslation } from '../../i18n' + +const REPO = 'mauriceboe/NOMAD' +const PER_PAGE = 10 + +export default function GitHubPanel() { + const { t, language } = useTranslation() + const [releases, setReleases] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState({}) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + + const fetchReleases = async (pageNum = 1, append = false) => { + try { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`) + if (!res.ok) throw new Error(`GitHub API: ${res.status}`) + const data = await res.json() + setReleases(prev => append ? [...prev, ...data] : data) + setHasMore(data.length === PER_PAGE) + } catch (err) { + setError(err.message) + } + } + + useEffect(() => { + setLoading(true) + fetchReleases(1).finally(() => setLoading(false)) + }, []) + + const handleLoadMore = async () => { + const next = page + 1 + setLoadingMore(true) + await fetchReleases(next, true) + setPage(next) + setLoadingMore(false) + } + + const toggleExpand = (id) => { + setExpanded(prev => ({ ...prev, [id]: !prev[id] })) + } + + const formatDate = (dateStr) => { + const d = new Date(dateStr) + return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' }) + } + + // Simple markdown-to-html for release notes (handles headers, bold, lists, links) + const renderBody = (body) => { + if (!body) return null + const lines = body.split('\n') + const elements = [] + let listItems = [] + + const flushList = () => { + if (listItems.length > 0) { + elements.push( + + ) + listItems = [] + } + } + + const inlineFormat = (text) => { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + } + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) { flushList(); continue } + + if (trimmed.startsWith('### ')) { + flushList() + elements.push( +

+ {trimmed.slice(4)} +

+ ) + } else if (trimmed.startsWith('## ')) { + flushList() + elements.push( +

+ {trimmed.slice(3)} +

+ ) + } else if (/^[-*] /.test(trimmed)) { + listItems.push(trimmed.slice(2)) + } else { + flushList() + elements.push( +

+ ) + } + } + flushList() + return elements + } + + if (loading) { + return ( +

+
+ +
+
+ ) + } + + if (error) { + return ( +
+
+

{t('admin.github.error')}

+

{error}

+
+
+ ) + } + + return ( +
+ {/* Header card */} +
+
+
+

{t('admin.github.title')}

+

{t('admin.github.subtitle').replace('{repo}', REPO)}

+
+ + + GitHub + +
+ + {/* Timeline */} +
+
+ {/* Timeline line */} +
+ +
+ {releases.map((release, idx) => { + const isLatest = idx === 0 + const isExpanded = expanded[release.id] + + return ( +
+ {/* Timeline dot */} +
+ +
+ + {/* Release content */} +
+
+ + {release.tag_name} + + {isLatest && ( + + {t('admin.github.latest')} + + )} + {release.prerelease && ( + + {t('admin.github.prerelease')} + + )} +
+ + {release.name && release.name !== release.tag_name && ( +

+ {release.name} +

+ )} + +
+ + + {formatDate(release.published_at || release.created_at)} + + {release.author && ( + + {t('admin.github.by')} {release.author.login} + + )} +
+ + {/* Expandable body */} + {release.body && ( +
+ + + {isExpanded && ( +
+ {renderBody(release.body)} +
+ )} +
+ )} +
+
+ ) + })} +
+
+ + {/* Load more */} + {hasMore && ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.jsx index f410a4e..c8898f0 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.jsx @@ -1,9 +1,8 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, ArrowRightLeft } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' -import { exchangeApi } from '../../api/client' // ── Helpers ────────────────────────────────────────────────────────────────── const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] @@ -154,15 +153,6 @@ export default function BudgetPanel({ tripId }) { const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') const currency = trip?.currency || 'EUR' - const [rates, setRates] = useState(null) - const [convertTo, setConvertTo] = useState(() => { - const saved = localStorage.getItem('budget_convert_to') - return saved || (currency === 'EUR' ? 'USD' : 'EUR') - }) - - useEffect(() => { - exchangeApi.getRates().then(setRates).catch(() => {}) - }, []) const fmt = (v, cur) => fmtNum(v, locale, cur) @@ -361,38 +351,6 @@ export default function BudgetPanel({ tripId }) { {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{SYMBOLS[currency] || currency} {currency}
- - {/* Live exchange rate conversion */} - {rates && (() => { - const fromRate = currency === 'EUR' ? 1 : rates.rates?.[currency] - const toRate = convertTo === 'EUR' ? 1 : rates.rates?.[convertTo] - const converted = fromRate && toRate ? (grandTotal / fromRate) * toRate : null - return converted != null ? ( -
-
- - {t('budget.converted')} -
-
- - {Number(converted).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - - -
-
- 1 {currency} = {((toRate / fromRate) || 0).toFixed(4)} {convertTo} -
-
- ) : null - })()} {pieSegments.length > 0 && ( diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.jsx index 5b6f4a5..a027640 100644 --- a/client/src/components/Map/MapView.jsx +++ b/client/src/components/Map/MapView.jsx @@ -232,6 +232,7 @@ export function MapView({ spiderfyOnMaxZoom showCoverageOnHover={false} zoomToBoundsOnClick + singleMarkerMode iconCreateFunction={(cluster) => { const count = cluster.getChildCount() const size = count < 10 ? 36 : count < 50 ? 42 : 48 diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 603547b..7283c49 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -855,6 +855,17 @@ export default function DayPlanSidebar({ {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {isSelected && getDayAssignments(day.id).length >= 2 && (
+
+ {TRANSPORT_MODES.map(m => ( + + ))} +
{routeInfo && (
diff --git a/client/src/components/Vacay/VacayCalendar.jsx b/client/src/components/Vacay/VacayCalendar.jsx index d150d09..283bbb5 100644 --- a/client/src/components/Vacay/VacayCalendar.jsx +++ b/client/src/components/Vacay/VacayCalendar.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback, useRef } from 'react' +import React, { useMemo, useState, useCallback } from 'react' import { useVacayStore } from '../../store/vacayStore' import { useTranslation } from '../../i18n' import { isWeekend } from './holidays' @@ -28,75 +28,22 @@ export default function VacayCalendar() { const blockWeekends = plan?.block_weekends !== false const companyHolidaysEnabled = plan?.company_holidays_enabled !== false - // Drag-to-paint state - const isDragging = useRef(false) - const dragAction = useRef(null) // 'add' or 'remove' - const dragProcessed = useRef(new Set()) - - const isDayBlocked = useCallback((dateStr) => { - if (holidays[dateStr]) return true - if (blockWeekends && isWeekend(dateStr)) return true - if (companyHolidaysEnabled && companyHolidaySet.has(dateStr) && !companyMode) return true - return false - }, [holidays, blockWeekends, companyHolidaySet, companyHolidaysEnabled, companyMode]) - - const handleCellMouseDown = useCallback((dateStr) => { - if (isDayBlocked(dateStr) && !companyMode) return - isDragging.current = true - dragProcessed.current = new Set([dateStr]) - - if (companyMode) { - dragAction.current = companyHolidaySet.has(dateStr) ? 'remove' : 'add' - toggleCompanyHoliday(dateStr) - } else { - const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined)) - dragAction.current = hasEntry ? 'remove' : 'add' - toggleEntry(dateStr, selectedUserId || undefined) - } - }, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId]) - - const handleCellMouseEnter = useCallback((dateStr) => { - if (!isDragging.current) return - if (dragProcessed.current.has(dateStr)) return - if (isDayBlocked(dateStr) && !companyMode) return - dragProcessed.current.add(dateStr) - - if (companyMode) { - const isSet = companyHolidaySet.has(dateStr) - if ((dragAction.current === 'add' && !isSet) || (dragAction.current === 'remove' && isSet)) { - toggleCompanyHoliday(dateStr) - } - } else { - const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined)) - if ((dragAction.current === 'add' && !hasEntry) || (dragAction.current === 'remove' && hasEntry)) { - toggleEntry(dateStr, selectedUserId || undefined) - } - } - }, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId]) - - const handleMouseUp = useCallback(() => { - isDragging.current = false - dragAction.current = null - dragProcessed.current.clear() - }, []) - - // Also handle click for single taps (touch/accessibility) const handleCellClick = useCallback(async (dateStr) => { - // Already handled by mousedown for mouse users, this is fallback for touch - if (isDragging.current) return if (companyMode) { if (!companyHolidaysEnabled) return await toggleCompanyHoliday(dateStr) return } - if (isDayBlocked(dateStr)) return + if (holidays[dateStr]) return + if (blockWeekends && isWeekend(dateStr)) return + if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return await toggleEntry(dateStr, selectedUserId || undefined) - }, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaysEnabled, isDayBlocked, selectedUserId]) + }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId]) const selectedUser = users.find(u => u.id === selectedUserId) return ( -
+
{Array.from({ length: 12 }, (_, i) => ( diff --git a/client/src/components/Vacay/VacayMonthCard.jsx b/client/src/components/Vacay/VacayMonthCard.jsx index 5ccba2f..b3d4e21 100644 --- a/client/src/components/Vacay/VacayMonthCard.jsx +++ b/client/src/components/Vacay/VacayMonthCard.jsx @@ -9,7 +9,7 @@ const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', export default function VacayMonthCard({ year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, - onCellClick, onCellMouseDown, onCellMouseEnter, companyMode, blockWeekends + onCellClick, companyMode, blockWeekends }) { const { language } = useTranslation() const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN @@ -69,13 +69,9 @@ export default function VacayMonthCard({ borderRight: '1px solid var(--border-secondary)', cursor: isBlocked ? 'default' : 'pointer', }} - onMouseDown={(e) => { e.preventDefault(); onCellMouseDown?.(dateStr) }} - onMouseEnter={(e) => { - onCellMouseEnter?.(dateStr) - if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' - }} + onClick={() => onCellClick(dateStr)} + onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }} - onTouchStart={() => onCellClick(dateStr)} > {holiday &&
} {isCompany &&
} diff --git a/client/src/components/Vacay/VacayPersons.jsx b/client/src/components/Vacay/VacayPersons.jsx index 1e30b47..b02e08b 100644 --- a/client/src/components/Vacay/VacayPersons.jsx +++ b/client/src/components/Vacay/VacayPersons.jsx @@ -72,36 +72,16 @@ export default function VacayPersons() {
- {users.length >= 2 && ( -
setSelectedUserId('all')} - className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all" - style={{ - background: selectedUserId === 'all' ? 'var(--bg-hover)' : 'transparent', - border: selectedUserId === 'all' ? '1px solid var(--border-primary)' : '1px solid transparent', - cursor: 'pointer', - }}> -
- A -
- - {t('vacay.everyone')} - - {selectedUserId === 'all' && ( - - )} -
- )} {users.map(u => { const isSelected = selectedUserId === u.id return (
{ if (isFused || users.length >= 2) setSelectedUserId(u.id) }} + onClick={() => { if (isFused) setSelectedUserId(u.id) }} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all" style={{ background: isSelected ? 'var(--bg-hover)' : 'transparent', border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent', - cursor: (isFused || users.length >= 2) ? 'pointer' : 'default', + cursor: isFused ? 'pointer' : 'default', }}>
diff --git a/client/src/components/Weather/WeatherWidget.jsx b/client/src/components/Weather/WeatherWidget.jsx index e1e787c..6f0e631 100644 --- a/client/src/components/Weather/WeatherWidget.jsx +++ b/client/src/components/Weather/WeatherWidget.jsx @@ -46,21 +46,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) { const cached = getWeatherCache(cacheKey) if (cached !== undefined) { if (cached === null) setFailed(true) - else setWeather(cached) + // Climate data: use from cache but re-fetch in background to upgrade to forecast + else if (cached.type === 'climate') { + setWeather(cached) + weatherApi.get(lat, lng, date) + .then(data => { + if (!data.error && data.temp !== undefined && data.type === 'forecast') { + setWeatherCache(cacheKey, data) + setWeather(data) + } + }) + .catch(() => {}) + return + } else { + setWeather(cached) + return + } return } setLoading(true) weatherApi.get(lat, lng, date) .then(data => { if (data.error || data.temp === undefined) { - setWeatherCache(cacheKey, null) setFailed(true) } else { setWeatherCache(cacheKey, data) setWeather(data) } }) - .catch(() => { setWeatherCache(cacheKey, null); setFailed(true) }) + .catch(() => { setFailed(true) }) .finally(() => setLoading(false)) }, [lat, lng, date]) @@ -83,20 +97,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) { const rawTemp = weather.temp const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null const unit = isFahrenheit ? '°F' : '°C' + const isClimate = weather.type === 'climate' if (compact) { return ( - + - {temp !== null && {temp}{unit}} + {temp !== null && {isClimate ? 'Ø ' : ''}{temp}{unit}} ) } return ( -
+
- {temp !== null && {temp}{unit}} + {temp !== null && {isClimate ? 'Ø ' : ''}{temp}{unit}} {weather.description && {weather.description}}
) diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index 8d916fc..1ec66d4 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -263,6 +263,31 @@ const de = { 'admin.addons.toast.updated': 'Addon aktualisiert', 'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden', 'admin.addons.noAddons': 'Keine Addons verfügbar', + // Weather info + 'admin.weather.title': 'Wetterdaten', + 'admin.weather.badge': 'Seit 24. März 2026', + 'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.', + 'admin.weather.forecast': '16-Tage-Vorhersage', + 'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)', + 'admin.weather.climate': 'Historische Klimadaten', + 'admin.weather.climateDesc': 'Durchschnittswerte der letzten 85 Jahre für Tage jenseits der 16-Tage-Vorhersage', + 'admin.weather.requests': '10.000 Anfragen / Tag', + 'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich', + 'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.', + + // GitHub + 'admin.tabs.github': 'GitHub', + 'admin.github.title': 'Update-Verlauf', + 'admin.github.subtitle': 'Neueste Updates von {repo}', + 'admin.github.latest': 'Aktuell', + 'admin.github.prerelease': 'Vorabversion', + 'admin.github.showDetails': 'Details anzeigen', + 'admin.github.hideDetails': 'Details ausblenden', + 'admin.github.loadMore': 'Mehr laden', + 'admin.github.loading': 'Wird geladen...', + 'admin.github.error': 'Releases konnten nicht geladen werden', + 'admin.github.by': 'von', + 'admin.update.available': 'Update verfügbar', 'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.', 'admin.update.button': 'Auf GitHub ansehen', @@ -335,7 +360,6 @@ const de = { 'vacay.dissolved': 'Kalender getrennt', 'vacay.fusedWith': 'Fusioniert mit', 'vacay.you': 'du', - 'vacay.everyone': 'Alle', 'vacay.noData': 'Keine Daten', 'vacay.changeColor': 'Farbe ändern', 'vacay.inviteUser': 'Benutzer einladen', @@ -570,7 +594,6 @@ const de = { 'budget.defaultCategory': 'Neue Kategorie', 'budget.total': 'Gesamt', 'budget.totalBudget': 'Gesamtbudget', - 'budget.converted': 'Umgerechnet', 'budget.byCategory': 'Nach Kategorie', 'budget.editTooltip': 'Klicken zum Bearbeiten', 'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index ee24ba9..0c758e6 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -263,6 +263,31 @@ const en = { 'admin.addons.toast.updated': 'Addon updated', 'admin.addons.toast.error': 'Failed to update addon', 'admin.addons.noAddons': 'No addons available', + // Weather info + 'admin.weather.title': 'Weather Data', + 'admin.weather.badge': 'Since March 24, 2026', + 'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.', + 'admin.weather.forecast': '16-day forecast', + 'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)', + 'admin.weather.climate': 'Historical climate data', + 'admin.weather.climateDesc': 'Averages from the last 85 years for days beyond the 16-day forecast', + 'admin.weather.requests': '10,000 requests / day', + 'admin.weather.requestsDesc': 'Free, no API key required', + 'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.', + + // GitHub + 'admin.tabs.github': 'GitHub', + 'admin.github.title': 'Release History', + 'admin.github.subtitle': 'Latest updates from {repo}', + 'admin.github.latest': 'Latest', + 'admin.github.prerelease': 'Pre-release', + 'admin.github.showDetails': 'Show details', + 'admin.github.hideDetails': 'Hide details', + 'admin.github.loadMore': 'Load more', + 'admin.github.loading': 'Loading...', + 'admin.github.error': 'Failed to load releases', + 'admin.github.by': 'by', + 'admin.update.available': 'Update available', 'admin.update.text': 'NOMAD {version} is available. You are running {current}.', 'admin.update.button': 'View on GitHub', @@ -335,7 +360,6 @@ const en = { 'vacay.dissolved': 'Calendar separated', 'vacay.fusedWith': 'Fused with', 'vacay.you': 'you', - 'vacay.everyone': 'Everyone', 'vacay.noData': 'No data', 'vacay.changeColor': 'Change color', 'vacay.inviteUser': 'Invite User', @@ -570,7 +594,6 @@ const en = { 'budget.defaultCategory': 'New Category', 'budget.total': 'Total', 'budget.totalBudget': 'Total Budget', - 'budget.converted': 'Converted', 'budget.byCategory': 'By Category', 'budget.editTooltip': 'Click to edit', 'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?', diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 737849e..117d632 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -9,8 +9,9 @@ import Modal from '../components/shared/Modal' import { useToast } from '../components/shared/Toast' import CategoryManager from '../components/Admin/CategoryManager' import BackupPanel from '../components/Admin/BackupPanel' +import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' export default function AdminPage() { @@ -23,6 +24,7 @@ export default function AdminPage() { { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, + { id: 'github', label: t('admin.tabs.github') }, ] const [activeTab, setActiveTab] = useState('users') @@ -502,7 +504,7 @@ export default function AdminPage() {
@@ -551,54 +553,35 @@ export default function AdminPage() { )}
- {/* OpenWeatherMap Key */} -
- -
-
- setWeatherKey(e.target.value)} - placeholder={t('settings.keyPlaceholder')} - className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> - + {/* Open-Meteo Weather Info */} +
+
+
+
+ +
+ {t('admin.weather.title')} +
+ {t('admin.weather.badge')} +
+
+

{t('admin.weather.description')}

+

{t('admin.weather.locationHint')}

+
+
+

{t('admin.weather.forecast')}

+

{t('admin.weather.forecastDesc')}

+
+
+

{t('admin.weather.climate')}

+

{t('admin.weather.climateDesc')}

+
+
+

{t('admin.weather.requests')}

+

{t('admin.weather.requestsDesc')}

+
-
-

{t('admin.weatherKeyHint')}

- {validation.weather === true && ( -

- - {t('admin.keyValid')} -

- )} - {validation.weather === false && ( -

- - {t('admin.keyInvalid')} -

- )}
diff --git a/docker-compose.yml b/docker-compose.yml index b311145..820e85f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: mauriceboe/nomad:latest + image: mauriceboe/nomad:2.5.5 container_name: nomad ports: - "3000:3000" diff --git a/server/package-lock.json b/server/package-lock.json index 47771d4..ca32792 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "nomad-server", - "version": "2.5.2", + "version": "2.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-server", - "version": "2.5.2", + "version": "2.5.5", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/package.json b/server/package.json index 0176f9a..5f2a7fc 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "nomad-server", - "version": "2.5.5", + "version": "2.5.6", "main": "src/index.js", "scripts": { "start": "node src/index.js", diff --git a/server/src/index.js b/server/src/index.js index 410dd56..26685fc 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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'); diff --git a/server/src/routes/vacay.js b/server/src/routes/vacay.js index 9e5449c..af41a78 100644 --- a/server/src/routes/vacay.js +++ b/server/src/routes/vacay.js @@ -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 }); }); diff --git a/server/src/routes/weather.js b/server/src/routes/weather.js index afd7d7a..86ed827 100644 --- a/server/src/routes/weather.js +++ b/server/src/routes/weather.js @@ -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) { diff --git a/server/src/websocket.js b/server/src/websocket.js index 8347638..af801cb 100644 --- a/server/src/websocket.js +++ b/server/src/websocket.js @@ -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));