From e1cd9655fb38085b1cb425099ea301ed2edd7c2c Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 24 Mar 2026 22:23:15 +0100 Subject: [PATCH] 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 --- .../src/components/Planner/DayPlanSidebar.jsx | 25 ++++-- .../src/components/Planner/PlacesSidebar.jsx | 15 +++- client/src/components/shared/ContextMenu.jsx | 83 +++++++++++++++++++ client/src/pages/TripPlannerPage.jsx | 5 ++ server/src/routes/weather.js | 66 +++++++++------ 5 files changed, 161 insertions(+), 33 deletions(-) create mode 100644 client/src/components/shared/ContextMenu.jsx diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 057f320..9f2d48c 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -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({
- + {note.text} {note.time && ( -
{note.time}
+
{note.time}
)}
@@ -965,6 +979,7 @@ export default function DayPlanSidebar({ {totalCost.toFixed(2)} {currency}
)} + ) } diff --git a/client/src/components/Planner/PlacesSidebar.jsx b/client/src/components/Planner/PlacesSidebar.jsx index 79a08e6..01e562c 100644 --- a/client/src/components/Planner/PlacesSidebar.jsx +++ b/client/src/components/Planner/PlacesSidebar.jsx @@ -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({ , document.body )} + ) } diff --git a/client/src/components/shared/ContextMenu.jsx b/client/src/components/shared/ContextMenu.jsx new file mode 100644 index 0000000..a0c9a04 --- /dev/null +++ b/client/src/components/shared/ContextMenu.jsx @@ -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( +
+ {menu.items.filter(Boolean).map((item, i) => { + if (item.divider) return
+ const Icon = item.icon + return ( + + ) + })} + +
, + document.body + ) +} diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx index baaa532..bc229f8 100644 --- a/client/src/pages/TripPlannerPage.jsx +++ b/client/src/pages/TripPlannerPage.jsx @@ -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)} />
diff --git a/server/src/routes/weather.js b/server/src/routes/weather.js index c249ce3..d20d748 100644 --- a/server/src/routes/weather.js +++ b/server/src/routes/weather.js @@ -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);