+
+
+ { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
+ onChange={t => {
+ const [d] = (form.reservation_time || '').split('T')
+ const date = d || new Date().toISOString().split('T')[0]
+ set('reservation_time', t ? `${date}T${t}` : date)
+ }}
+ />
-
+
+
+ set('reservation_end_time', v)} />
+
+
{fmtDate(r.reservation_time)}
)}
- {r.reservation_time && (
+ {r.reservation_time?.includes('T') && (
{t('reservations.time')}
-
{fmtTime(r.reservation_time)}
+
+ {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
+
)}
{r.confirmation_number && (
diff --git a/client/src/components/Planner/RightPanel.jsx b/client/src/components/Planner/RightPanel.jsx
index a66e33f..0facf3d 100644
--- a/client/src/components/Planner/RightPanel.jsx
+++ b/client/src/components/Planner/RightPanel.jsx
@@ -514,6 +514,7 @@ export function RightPanel({
{formatDateTime(reservation.reservation_time)}
+ {reservation.reservation_end_time && ` – ${reservation.reservation_end_time}`}
)}
{reservation.location && (
diff --git a/client/src/components/shared/CustomDateTimePicker.jsx b/client/src/components/shared/CustomDateTimePicker.jsx
index c3d3f91..5391d23 100644
--- a/client/src/components/shared/CustomDateTimePicker.jsx
+++ b/client/src/components/shared/CustomDateTimePicker.jsx
@@ -172,7 +172,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
}
const handleTimeChange = (t) => {
const d = datePart || new Date().toISOString().split('T')[0]
- onChange(t ? `${d}T${t}` : `${d}T00:00`)
+ onChange(t ? `${d}T${t}` : d)
}
return (
diff --git a/client/src/components/shared/CustomSelect.jsx b/client/src/components/shared/CustomSelect.jsx
index 9b0ad73..d6d25db 100644
--- a/client/src/components/shared/CustomSelect.jsx
+++ b/client/src/components/shared/CustomSelect.jsx
@@ -33,7 +33,29 @@ export default function CustomSelect({
const selected = options.find(o => o.value === value)
const filtered = searchable && search
- ? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
+ ? (() => {
+ const q = search.toLowerCase()
+ const result = []
+ let currentHeader = null
+ let headerAdded = false
+ for (const o of options) {
+ if (o.isHeader) {
+ currentHeader = o
+ headerAdded = false
+ continue
+ }
+ // Match against label, searchLabel, or groupLabel
+ const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
+ if (haystack.includes(q)) {
+ if (currentHeader && !headerAdded) {
+ result.push(currentHeader)
+ headerAdded = true
+ }
+ result.push(o)
+ }
+ }
+ return result
+ })()
: options
const sm = size === 'sm'
diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js
index 8086965..3d7618a 100644
--- a/client/src/i18n/translations/de.js
+++ b/client/src/i18n/translations/de.js
@@ -577,6 +577,8 @@ const de = {
'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit',
+ 'reservations.startTime': 'Startzeit',
+ 'reservations.endTime': 'Endzeit',
'reservations.date': 'Datum',
'reservations.time': 'Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js
index d0d055b..d700f0a 100644
--- a/client/src/i18n/translations/en.js
+++ b/client/src/i18n/translations/en.js
@@ -577,6 +577,8 @@ const en = {
'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status',
'reservations.datetime': 'Date & Time',
+ 'reservations.startTime': 'Start time',
+ 'reservations.endTime': 'End time',
'reservations.date': 'Date',
'reservations.time': 'Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx
index dcbcf48..4149d11 100644
--- a/client/src/pages/TripPlannerPage.jsx
+++ b/client/src/pages/TripPlannerPage.jsx
@@ -23,7 +23,7 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen
import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
-import { calculateRoute } from '../components/Map/RouteCalculator'
+import { calculateRoute, calculateSegments } from '../components/Map/RouteCalculator'
import ConfirmDialog from '../components/shared/ConfirmDialog'
const MIN_SIDEBAR = 200
@@ -188,31 +188,29 @@ export default function TripPlannerPage() {
}, [places])
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
+ const routeAbortRef = useRef(null)
const updateRouteForDay = useCallback(async (dayId) => {
+ // Abort any previous calculation
+ if (routeAbortRef.current) routeAbortRef.current.abort()
+
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
- if (waypoints.length >= 2) {
- setRoute(waypoints.map(p => [p.lat, p.lng]))
- if (!routeCalcEnabled) { setRouteSegments([]); return }
- // Calculate per-segment travel times
- const segments = []
- for (let i = 0; i < waypoints.length - 1; i++) {
- const from = [waypoints[i].lat, waypoints[i].lng]
- const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
- const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
- try {
- const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking')
- segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText })
- } catch {
- segments.push({ mid, from, to, walkingText: '?', drivingText: '?' })
- }
- }
- setRouteSegments(segments)
- } else {
- setRoute(null)
- setRouteSegments([])
+ if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
+
+ setRoute(waypoints.map(p => [p.lat, p.lng]))
+ if (!routeCalcEnabled) { setRouteSegments([]); return }
+
+ // Single OSRM request for all segments
+ const controller = new AbortController()
+ routeAbortRef.current = controller
+
+ try {
+ const segments = await calculateSegments(waypoints, { signal: controller.signal })
+ if (!controller.signal.aborted) setRouteSegments(segments)
+ } catch (err) {
+ if (err.name !== 'AbortError') setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
@@ -231,8 +229,7 @@ export default function TripPlannerPage() {
setSelectedPlaceId(placeId)
}
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
- updateRouteForDay(selectedDayId)
- }, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
+ }, [selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined
@@ -366,16 +363,10 @@ export default function TripPlannerPage() {
return map
}, [selectedDayId, assignments])
- // Auto-update route when assignments change
+ // Auto-update route + segments when assignments change
useEffect(() => {
- if (!selectedDayId) return
- const da = (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
- const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
- if (waypoints.length >= 2) {
- setRoute(waypoints.map(p => [p.lat, p.lng]))
- } else {
- setRoute(null)
- }
+ if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
+ updateRouteForDay(selectedDayId)
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
diff --git a/server/package-lock.json b/server/package-lock.json
index ca32792..a1818ac 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nomad-server",
- "version": "2.5.5",
+ "version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
- "version": "2.5.5",
+ "version": "2.6.0",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
diff --git a/server/src/db/database.js b/server/src/db/database.js
index c65f5f9..ad25e44 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -182,6 +182,7 @@ function initDb() {
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL,
reservation_time TEXT,
+ reservation_end_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
@@ -606,6 +607,10 @@ function initDb() {
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
},
+ // 28: Add end_time to reservations
+ () => {
+ try { _db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
+ },
// Future migrations go here (append only, never reorder)
];
diff --git a/server/src/routes/reservations.js b/server/src/routes/reservations.js
index 1708cba..d97bc5f 100644
--- a/server/src/routes/reservations.js
+++ b/server/src/routes/reservations.js
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req, res) => {
// POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
- const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
+ const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -39,8 +39,8 @@ router.post('/', authenticate, (req, res) => {
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
- INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, location, confirmation_number, notes, status, type)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
@@ -48,6 +48,7 @@ router.post('/', authenticate, (req, res) => {
assignment_id || null,
title,
reservation_time || null,
+ reservation_end_time || null,
location || null,
confirmation_number || null,
notes || null,
@@ -70,7 +71,7 @@ router.post('/', authenticate, (req, res) => {
// PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
- const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
+ const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -82,6 +83,7 @@ router.put('/:id', authenticate, (req, res) => {
UPDATE reservations SET
title = COALESCE(?, title),
reservation_time = ?,
+ reservation_end_time = ?,
location = ?,
confirmation_number = ?,
notes = ?,
@@ -94,6 +96,7 @@ router.put('/:id', authenticate, (req, res) => {
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time,
+ reservation_end_time !== undefined ? (reservation_end_time || null) : reservation.reservation_end_time,
location !== undefined ? (location || null) : reservation.location,
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
notes !== undefined ? (notes || null) : reservation.notes,