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);