Context menus, climate hourly data, UI fixes

- Right-click context menus for places in day plan (edit, remove, Google Maps, delete)
- Right-click context menus for places in places list (edit, add to day, delete)
- Right-click context menus for notes (edit, delete)
- Historical climate now shows full hourly data, wind, sunrise/sunset (same as forecast)
- Day header selected background improved for dark mode
- Note input: textarea with 150 char limit and counter
- Note text wraps properly in day plan
This commit is contained in:
Maurice
2026-03-24 22:23:15 +01:00
parent 2e0481c045
commit e1cd9655fb
5 changed files with 161 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Tr
import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -79,12 +80,13 @@ export default function DayPlanSidebar({
selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
onAddReservation,
}) {
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
@@ -455,7 +457,7 @@ export default function DayPlanSidebar({
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px',
cursor: 'pointer',
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
transition: 'background 0.12s',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
@@ -648,6 +650,14 @@ export default function DayPlanSidebar({
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
style={{
@@ -790,6 +800,11 @@ export default function DayPlanSidebar({
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
@@ -810,12 +825,11 @@ export default function DayPlanSidebar({
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{note.text}
</span>
{note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
)}
</div>
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
@@ -965,6 +979,7 @@ export default function DayPlanSidebar({
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
</div>
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}

View File

@@ -1,16 +1,18 @@
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { Search, Plus, X, CalendarDays } from 'lucide-react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
}) {
const { t } = useTranslation()
const ctxMenu = useContextMenu()
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
@@ -138,6 +140,14 @@ export default function PlacesSidebar({
onPlaceClick(isSelected ? null : place.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
@@ -237,6 +247,7 @@ export default function PlacesSidebar({
</div>,
document.body
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}

View File

@@ -0,0 +1,83 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
export function useContextMenu() {
const [menu, setMenu] = useState(null) // { x, y, items }
const open = (e, items) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
}
const close = () => setMenu(null)
return { menu, open, close }
}
export function ContextMenu({ menu, onClose }) {
const ref = useRef(null)
useEffect(() => {
if (!menu) return
const handler = () => onClose()
document.addEventListener('click', handler)
document.addEventListener('contextmenu', handler)
return () => {
document.removeEventListener('click', handler)
document.removeEventListener('contextmenu', handler)
}
}, [menu, onClose])
// Adjust position if menu would overflow viewport
useEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
const rect = el.getBoundingClientRect()
let { x, y } = menu
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
if (x !== menu.x || y !== menu.y) {
el.style.left = `${x}px`
el.style.top = `${y}px`
}
}, [menu])
if (!menu) return null
return ReactDOM.createPortal(
<div ref={ref} style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
minWidth: 160,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
animation: 'ctxIn 0.1s ease-out',
}}>
{menu.items.filter(Boolean).map((item, i) => {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
const Icon = item.icon
return (
<button key={i} onClick={() => { item.onClick(); onClose() }} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', borderRadius: 7, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 12, fontWeight: 500, textAlign: 'left',
color: item.danger ? '#ef4444' : 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
<span>{item.label}</span>
</button>
)
})}
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>,
document.body
)
}

View File

@@ -463,6 +463,9 @@ export default function TripPlannerPage() {
reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place) => { setEditingPlace(place); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
/>
{!leftCollapsed && (
@@ -520,6 +523,8 @@ export default function TripPlannerPage() {
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => { setEditingPlace(place); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
/>
</div>
</div>

View File

@@ -298,17 +298,16 @@ router.get('/detailed', authenticate, async (req, res) => {
const dateStr = targetDate.toISOString().slice(0, 10);
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
// Beyond 16-day forecast window → archive API (daily only, no hourly)
// Beyond 16-day forecast window → archive API with hourly data from same date last year
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
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 refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
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,weathercode,precipitation_sum&timezone=auto`;
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
@@ -317,36 +316,51 @@ router.get('/detailed', authenticate, async (req, res) => {
}
const daily = data.daily;
const hourly = data.hourly;
if (!daily || !daily.time || daily.time.length === 0) {
return res.json({ error: 'no_forecast' });
}
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++;
const idx = 0;
const code = daily.weathercode?.[idx];
const avgMax = daily.temperature_2m_max[idx];
const avgMin = daily.temperature_2m_min[idx];
// Build hourly array
const hourlyData = [];
if (hourly?.time) {
for (let i = 0; i < hourly.time.length; i++) {
const hour = new Date(hourly.time[i]).getHours();
const hCode = hourly.weathercode?.[i];
hourlyData.push({
hour,
temp: Math.round(hourly.temperature_2m[i]),
precipitation: hourly.precipitation?.[i] || 0,
precipitation_probability: 0, // archive has no probability
main: WMO_MAP[hCode] || 'Clouds',
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
humidity: hourly.relativehumidity_2m?.[i] || 0,
});
}
}
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;
// Format sunrise/sunset
let sunrise = null, sunset = null;
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
const result = {
type: 'climate',
temp: Math.round(avgTemp),
temp: Math.round((avgMax + avgMin) / 2),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main: estimateCondition(avgTemp, avgPrecip),
precipitation_sum: Math.round(avgPrecip * 10) / 10,
main: WMO_MAP[code] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
description: descriptions[code] || '',
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
sunrise,
sunset,
hourly: hourlyData,
};
setCache(ck, result, TTL_CLIMATE_MS);