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)
|
- **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
|
- **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
|
- **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
|
### Travel Management
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
### Customization & Admin
|
### Customization & Admin
|
||||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
- **Multilingual** — English and German (i18n)
|
- **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
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -84,18 +84,13 @@
|
|||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT + OIDC
|
- **Auth**: JWT + OIDC
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: OpenWeatherMap API (optional)
|
- **Weather**: Open-Meteo API (free, no key required)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /opt/nomad && cd /opt/nomad
|
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
@@ -123,8 +118,8 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/nomad/data:/app/data
|
- ./data:/app/data
|
||||||
- /opt/nomad/uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,18 +132,14 @@ docker compose up -d
|
|||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/nomad:latest
|
docker pull mauriceboe/nomad
|
||||||
docker rm -f nomad
|
docker rm -f nomad
|
||||||
docker run -d --name nomad -p 3000:3000 \
|
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||||
-v /opt/nomad/data:/app/data \
|
|
||||||
-v /opt/nomad/uploads:/app/uploads \
|
|
||||||
--restart unless-stopped \
|
|
||||||
mauriceboe/nomad:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
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)
|
### Reverse Proxy (recommended)
|
||||||
|
|
||||||
|
|||||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.2",
|
"version": "2.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.2",
|
"version": "2.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.5",
|
"version": "2.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -166,12 +166,8 @@ export const reservationsApi = {
|
|||||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
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 = {
|
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 = {
|
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 React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useTranslation } from '../../i18n'
|
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 CustomSelect from '../shared/CustomSelect'
|
||||||
import { exchangeApi } from '../../api/client'
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
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 { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const currency = trip?.currency || 'EUR'
|
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)
|
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 })}
|
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</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>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export function MapView({
|
|||||||
spiderfyOnMaxZoom
|
spiderfyOnMaxZoom
|
||||||
showCoverageOnHover={false}
|
showCoverageOnHover={false}
|
||||||
zoomToBoundsOnClick
|
zoomToBoundsOnClick
|
||||||
|
singleMarkerMode
|
||||||
iconCreateFunction={(cluster) => {
|
iconCreateFunction={(cluster) => {
|
||||||
const count = cluster.getChildCount()
|
const count = cluster.getChildCount()
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||||
|
|||||||
@@ -855,6 +855,17 @@ export default function DayPlanSidebar({
|
|||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{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={{ 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 && (
|
{routeInfo && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<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 { useVacayStore } from '../../store/vacayStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isWeekend } from './holidays'
|
import { isWeekend } from './holidays'
|
||||||
@@ -28,75 +28,22 @@ export default function VacayCalendar() {
|
|||||||
const blockWeekends = plan?.block_weekends !== false
|
const blockWeekends = plan?.block_weekends !== false
|
||||||
const companyHolidaysEnabled = plan?.company_holidays_enabled !== 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) => {
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
// Already handled by mousedown for mouse users, this is fallback for touch
|
|
||||||
if (isDragging.current) return
|
|
||||||
if (companyMode) {
|
if (companyMode) {
|
||||||
if (!companyHolidaysEnabled) return
|
if (!companyHolidaysEnabled) return
|
||||||
await toggleCompanyHoliday(dateStr)
|
await toggleCompanyHoliday(dateStr)
|
||||||
return
|
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)
|
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)
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
|
|
||||||
return (
|
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">
|
<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) => (
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
<VacayMonthCard
|
<VacayMonthCard
|
||||||
@@ -108,8 +55,6 @@ export default function VacayCalendar() {
|
|||||||
companyHolidaysEnabled={companyHolidaysEnabled}
|
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||||
entryMap={entryMap}
|
entryMap={entryMap}
|
||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
onCellMouseDown={handleCellMouseDown}
|
|
||||||
onCellMouseEnter={handleCellMouseEnter}
|
|
||||||
companyMode={companyMode}
|
companyMode={companyMode}
|
||||||
blockWeekends={blockWeekends}
|
blockWeekends={blockWeekends}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli',
|
|||||||
|
|
||||||
export default function VacayMonthCard({
|
export default function VacayMonthCard({
|
||||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
onCellClick, onCellMouseDown, onCellMouseEnter, companyMode, blockWeekends
|
onCellClick, companyMode, blockWeekends
|
||||||
}) {
|
}) {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||||
@@ -69,13 +69,9 @@ export default function VacayMonthCard({
|
|||||||
borderRight: '1px solid var(--border-secondary)',
|
borderRight: '1px solid var(--border-secondary)',
|
||||||
cursor: isBlocked ? 'default' : 'pointer',
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => { e.preventDefault(); onCellMouseDown?.(dateStr) }}
|
onClick={() => onCellClick(dateStr)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onCellMouseEnter?.(dateStr)
|
|
||||||
if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
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)' }} />}
|
{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)' }} />}
|
{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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
<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 => {
|
{users.map(u => {
|
||||||
const isSelected = selectedUserId === u.id
|
const isSelected = selectedUserId === u.id
|
||||||
return (
|
return (
|
||||||
<div key={u.id}
|
<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"
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||||
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
cursor: (isFused || users.length >= 2) ? 'pointer' : 'default',
|
cursor: isFused ? 'pointer' : 'default',
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
||||||
@@ -113,7 +93,7 @@ export default function VacayPersons() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (isFused || users.length >= 2) && (
|
{isSelected && isFused && (
|
||||||
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,21 +46,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const cached = getWeatherCache(cacheKey)
|
const cached = getWeatherCache(cacheKey)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
if (cached === null) setFailed(true)
|
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
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
weatherApi.get(lat, lng, date)
|
weatherApi.get(lat, lng, date)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error || data.temp === undefined) {
|
if (data.error || data.temp === undefined) {
|
||||||
setWeatherCache(cacheKey, null)
|
|
||||||
setFailed(true)
|
setFailed(true)
|
||||||
} else {
|
} else {
|
||||||
setWeatherCache(cacheKey, data)
|
setWeatherCache(cacheKey, data)
|
||||||
setWeather(data)
|
setWeather(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
.catch(() => { setFailed(true) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [lat, lng, date])
|
}, [lat, lng, date])
|
||||||
|
|
||||||
@@ -83,20 +97,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const rawTemp = weather.temp
|
const rawTemp = weather.temp
|
||||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
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} />
|
<WeatherIcon main={weather.main} size={12} />
|
||||||
{temp !== null && <span>{temp}{unit}</span>}
|
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<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>}
|
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -263,6 +263,31 @@ const de = {
|
|||||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
'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.available': 'Update verfügbar',
|
||||||
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
|
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
|
||||||
'admin.update.button': 'Auf GitHub ansehen',
|
'admin.update.button': 'Auf GitHub ansehen',
|
||||||
@@ -335,7 +360,6 @@ const de = {
|
|||||||
'vacay.dissolved': 'Kalender getrennt',
|
'vacay.dissolved': 'Kalender getrennt',
|
||||||
'vacay.fusedWith': 'Fusioniert mit',
|
'vacay.fusedWith': 'Fusioniert mit',
|
||||||
'vacay.you': 'du',
|
'vacay.you': 'du',
|
||||||
'vacay.everyone': 'Alle',
|
|
||||||
'vacay.noData': 'Keine Daten',
|
'vacay.noData': 'Keine Daten',
|
||||||
'vacay.changeColor': 'Farbe ändern',
|
'vacay.changeColor': 'Farbe ändern',
|
||||||
'vacay.inviteUser': 'Benutzer einladen',
|
'vacay.inviteUser': 'Benutzer einladen',
|
||||||
@@ -570,7 +594,6 @@ const de = {
|
|||||||
'budget.defaultCategory': 'Neue Kategorie',
|
'budget.defaultCategory': 'Neue Kategorie',
|
||||||
'budget.total': 'Gesamt',
|
'budget.total': 'Gesamt',
|
||||||
'budget.totalBudget': 'Gesamtbudget',
|
'budget.totalBudget': 'Gesamtbudget',
|
||||||
'budget.converted': 'Umgerechnet',
|
|
||||||
'budget.byCategory': 'Nach Kategorie',
|
'budget.byCategory': 'Nach Kategorie',
|
||||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
'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.updated': 'Addon updated',
|
||||||
'admin.addons.toast.error': 'Failed to update addon',
|
'admin.addons.toast.error': 'Failed to update addon',
|
||||||
'admin.addons.noAddons': 'No addons available',
|
'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.available': 'Update available',
|
||||||
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
||||||
'admin.update.button': 'View on GitHub',
|
'admin.update.button': 'View on GitHub',
|
||||||
@@ -335,7 +360,6 @@ const en = {
|
|||||||
'vacay.dissolved': 'Calendar separated',
|
'vacay.dissolved': 'Calendar separated',
|
||||||
'vacay.fusedWith': 'Fused with',
|
'vacay.fusedWith': 'Fused with',
|
||||||
'vacay.you': 'you',
|
'vacay.you': 'you',
|
||||||
'vacay.everyone': 'Everyone',
|
|
||||||
'vacay.noData': 'No data',
|
'vacay.noData': 'No data',
|
||||||
'vacay.changeColor': 'Change color',
|
'vacay.changeColor': 'Change color',
|
||||||
'vacay.inviteUser': 'Invite User',
|
'vacay.inviteUser': 'Invite User',
|
||||||
@@ -570,7 +594,6 @@ const en = {
|
|||||||
'budget.defaultCategory': 'New Category',
|
'budget.defaultCategory': 'New Category',
|
||||||
'budget.total': 'Total',
|
'budget.total': 'Total',
|
||||||
'budget.totalBudget': 'Total Budget',
|
'budget.totalBudget': 'Total Budget',
|
||||||
'budget.converted': 'Converted',
|
|
||||||
'budget.byCategory': 'By Category',
|
'budget.byCategory': 'By Category',
|
||||||
'budget.editTooltip': 'Click to edit',
|
'budget.editTooltip': 'Click to edit',
|
||||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
'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 { useToast } from '../components/shared/Toast'
|
||||||
import CategoryManager from '../components/Admin/CategoryManager'
|
import CategoryManager from '../components/Admin/CategoryManager'
|
||||||
import BackupPanel from '../components/Admin/BackupPanel'
|
import BackupPanel from '../components/Admin/BackupPanel'
|
||||||
|
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||||
import AddonManager from '../components/Admin/AddonManager'
|
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'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -23,6 +24,7 @@ export default function AdminPage() {
|
|||||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||||
|
{ id: 'github', label: t('admin.tabs.github') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('users')
|
const [activeTab, setActiveTab] = useState('users')
|
||||||
@@ -502,7 +504,7 @@ export default function AdminPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||||
{t('admin.mapsKey')}
|
{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>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -551,54 +553,35 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenWeatherMap Key */}
|
{/* Open-Meteo Weather Info */}
|
||||||
<div>
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||||
<input
|
<Sun className="w-3.5 h-3.5 text-white" />
|
||||||
type={showKeys.weather ? 'text' : 'password'}
|
</div>
|
||||||
value={weatherKey}
|
<span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
|
||||||
onChange={e => setWeatherKey(e.target.value)}
|
</div>
|
||||||
placeholder={t('settings.keyPlaceholder')}
|
<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>
|
||||||
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"
|
</div>
|
||||||
/>
|
<div className="px-4 pb-3">
|
||||||
<button
|
<p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
|
||||||
type="button"
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
|
||||||
onClick={() => toggleKey('weather')}
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
<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>
|
||||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
|
||||||
</button>
|
</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>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -682,6 +665,8 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'backup' && <BackupPanel />}
|
{activeTab === 'backup' && <BackupPanel />}
|
||||||
|
|
||||||
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:latest
|
image: mauriceboe/nomad:2.5.5
|
||||||
container_name: nomad
|
container_name: nomad
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.2",
|
"version": "2.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.2",
|
"version": "2.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.5",
|
"version": "2.5.6",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
|
|||||||
@@ -107,23 +107,6 @@ app.use('/api/weather', weatherRoutes);
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
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
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Broadcast vacay updates to all users in the same plan
|
// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
|
||||||
function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
|
||||||
try {
|
try {
|
||||||
const { broadcastToUser } = require('../websocket');
|
const { broadcastToUser } = require('../websocket');
|
||||||
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
|
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 userIds = [plan.owner_id];
|
||||||
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
|
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));
|
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 { /* */ }
|
} 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);
|
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
res.json({
|
res.json({
|
||||||
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
|
|||||||
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
||||||
`).run(userId, planId, color || '#6366f1');
|
`).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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify all plan users (not just owner)
|
// 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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -310,7 +310,7 @@ router.post('/invite/decline', (req, res) => {
|
|||||||
const { plan_id } = req.body;
|
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);
|
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 });
|
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);
|
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 */ }
|
} 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);
|
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) });
|
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_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_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}-%`);
|
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);
|
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) });
|
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;
|
const { date, target_user_id } = req.body;
|
||||||
if (!date) return res.status(400).json({ error: 'date required' });
|
if (!date) return res.status(400).json({ error: 'date required' });
|
||||||
const planId = getActivePlanId(req.user.id);
|
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
|
// Allow toggling for another user if they are in the same plan
|
||||||
let userId = req.user.id;
|
let userId = req.user.id;
|
||||||
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
|
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
|
||||||
|
const planUsers = getPlanUsers(planId);
|
||||||
const tid = parseInt(target_user_id);
|
const tid = parseInt(target_user_id);
|
||||||
if (!planUsers.find(u => u.id === tid)) {
|
if (!planUsers.find(u => u.id === tid)) {
|
||||||
return res.status(403).json({ error: 'User not in plan' });
|
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);
|
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
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' });
|
res.json({ action: 'removed' });
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
|
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' });
|
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);
|
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
|
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' });
|
res.json({ action: 'removed' });
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
||||||
// Remove any vacation entries on this date
|
// Remove any vacation entries on this date
|
||||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, 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' });
|
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)
|
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
|
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
|
||||||
`).run(userId, planId, year, vacation_days);
|
`).run(userId, planId, year, vacation_days);
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const { db } = require('../db/database');
|
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -10,11 +9,12 @@ const weatherCache = new Map();
|
|||||||
|
|
||||||
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
|
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 rlat = parseFloat(lat).toFixed(2);
|
||||||
const rlng = parseFloat(lng).toFixed(2);
|
const rlng = parseFloat(lng).toFixed(2);
|
||||||
return `${rlat}_${rlng}_${date || 'current'}_${units}`;
|
return `${rlat}_${rlng}_${date || 'current'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCached(key) {
|
function getCached(key) {
|
||||||
@@ -30,46 +30,123 @@ function getCached(key) {
|
|||||||
function setCache(key, data, ttlMs) {
|
function setCache(key, data, ttlMs) {
|
||||||
weatherCache.set(key, { data, expiresAt: Date.now() + 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) {
|
// GET /api/weather?lat=&lng=&date=&lang=de
|
||||||
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
|
|
||||||
router.get('/', authenticate, async (req, res) => {
|
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) {
|
if (!lat || !lng) {
|
||||||
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
|
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// User's own key, or fall back to admin's key
|
const ck = cacheKey(lat, lng, date);
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If a date is requested, try the 5-day forecast first
|
// ── Forecast for a specific date ──
|
||||||
if (date) {
|
if (date) {
|
||||||
// Check cache
|
|
||||||
const cached = getCached(ck);
|
const cached = getCached(ck);
|
||||||
if (cached) return res.json(cached);
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
@@ -77,49 +154,122 @@ router.get('/', authenticate, async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
|
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
// Within 5-day forecast window
|
// Within 16-day forecast window → real forecast
|
||||||
if (diffDays >= -1 && diffDays <= 5) {
|
if (diffDays >= -1 && diffDays <= 16) {
|
||||||
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
|
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 response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok || data.error) {
|
||||||
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
|
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API Fehler' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = (data.list || []).filter(item => {
|
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||||
const itemDate = new Date(item.dt * 1000);
|
const idx = (data.daily?.time || []).indexOf(dateStr);
|
||||||
return itemDate.toDateString() === targetDate.toDateString();
|
|
||||||
});
|
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);
|
setCache(ck, result, TTL_FORECAST_MS);
|
||||||
return res.json(result);
|
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' });
|
return res.json({ error: 'no_forecast' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// No date — return current weather
|
// ── Current weather (no date) ──
|
||||||
const cached = getCached(ck);
|
const cached = getCached(ck);
|
||||||
if (cached) return res.json(cached);
|
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 response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok || data.error) {
|
||||||
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
|
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);
|
setCache(ck, result, TTL_CURRENT_MS);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -141,10 +141,12 @@ function broadcast(tripId, eventType, payload, excludeSid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastToUser(userId, payload) {
|
function broadcastToUser(userId, payload, excludeSid) {
|
||||||
if (!wss) return;
|
if (!wss) return;
|
||||||
|
const excludeNum = excludeSid ? Number(excludeSid) : null;
|
||||||
for (const ws of wss.clients) {
|
for (const ws of wss.clients) {
|
||||||
if (ws.readyState !== 1) continue;
|
if (ws.readyState !== 1) continue;
|
||||||
|
if (excludeNum && socketId.get(ws) === excludeNum) continue;
|
||||||
const user = socketUser.get(ws);
|
const user = socketUser.get(ws);
|
||||||
if (user && user.id === userId) {
|
if (user && user.id === userId) {
|
||||||
ws.send(JSON.stringify(payload));
|
ws.send(JSON.stringify(payload));
|
||||||
|
|||||||
Reference in New Issue
Block a user