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:
27
README.md
27
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)
|
||||
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
263
client/src/components/Admin/GitHubPanel.jsx
Normal file
263
client/src/components/Admin/GitHubPanel.jsx
Normal file
@@ -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(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
}
|
||||
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header card */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div style={{ marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<ArrowRightLeft size={12} style={{ color: 'rgba(255,255,255,0.4)' }} />
|
||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.4)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 }}>{t('budget.converted')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 700 }}>
|
||||
{Number(converted).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<select
|
||||
value={convertTo}
|
||||
onChange={e => { setConvertTo(e.target.value); localStorage.setItem('budget_convert_to', e.target.value) }}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 6, color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 600, padding: '2px 4px', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{CURRENCIES.filter(c => c !== currency).map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.3)', marginTop: 4 }}>
|
||||
1 {currency} = {((toRate / fromRate) || 0).toFixed(4)} {convertTo}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -855,6 +855,17 @@ export default function DayPlanSidebar({
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
|
||||
{TRANSPORT_MODES.map(m => (
|
||||
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
|
||||
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
|
||||
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
|
||||
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{routeInfo && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||
|
||||
@@ -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 (
|
||||
<div onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} style={{ userSelect: 'none' }}>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<VacayMonthCard
|
||||
@@ -108,8 +55,6 @@ export default function VacayCalendar() {
|
||||
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||
entryMap={entryMap}
|
||||
onCellClick={handleCellClick}
|
||||
onCellMouseDown={handleCellMouseDown}
|
||||
onCellMouseEnter={handleCellMouseEnter}
|
||||
companyMode={companyMode}
|
||||
blockWeekends={blockWeekends}
|
||||
/>
|
||||
|
||||
@@ -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 && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
|
||||
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||
|
||||
@@ -72,36 +72,16 @@ export default function VacayPersons() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{users.length >= 2 && (
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}>
|
||||
<div className="w-3.5 h-3.5 rounded-full shrink-0 flex items-center justify-center" style={{ background: 'var(--text-muted)' }}>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, color: 'var(--bg-card)', lineHeight: 1 }}>A</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('vacay.everyone')}
|
||||
</span>
|
||||
{selectedUserId === 'all' && (
|
||||
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.map(u => {
|
||||
const isSelected = selectedUserId === u.id
|
||||
return (
|
||||
<div key={u.id}
|
||||
onClick={() => { 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',
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
||||
@@ -113,7 +93,7 @@ export default function VacayPersons() {
|
||||
{u.username}
|
||||
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||
</span>
|
||||
{isSelected && (isFused || users.length >= 2) && (
|
||||
{isSelected && isFused && (
|
||||
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={12} />
|
||||
{temp !== null && <span>{temp}{unit}</span>}
|
||||
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={15} />
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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() {
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.mapsKey')}
|
||||
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
||||
<span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -551,54 +553,35 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenWeatherMap Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.weather ? 'text' : 'password'}
|
||||
value={weatherKey}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('weather')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||
<Sun className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span>
|
||||
</div>
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('weather')}
|
||||
disabled={!weatherKey || validating.weather}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.weather ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.weather === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.weather === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
|
||||
{validation.weather === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.weather === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -682,6 +665,8 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/nomad:latest
|
||||
image: mauriceboe/nomad:2.5.5
|
||||
container_name: nomad
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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