Reservation end time, route perf overhaul, assignment search fix

- Add reservation_end_time field (DB migration, API, UI)
- Split reservation form: separate date, start time, end time, status fields
- Fix DateTimePicker forcing 00:00 when no time selected
- Show end time across all reservation displays
- Link-to-assignment and date on same row (50/50 layout)
- Assignment search now shows day headers for filtered results
- Auto-fill date when selecting a day assignment
- Route segments: single OSRM request instead of N separate calls (~6s → ~1s)
- Route labels visible from zoom level 12 (was 16)
- Fix stale route labels after place deletion (useEffect triggers recalc)
- AbortController cancels outdated route calculations
This commit is contained in:
Maurice
2026-03-26 22:32:15 +01:00
parent 35275e209d
commit cb080954c9
18 changed files with 181 additions and 79 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "nomad-client",
"version": "2.5.5",
"version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-client",
"version": "2.5.5",
"version": "2.6.0",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",

View File

@@ -162,11 +162,11 @@ function MapClickHandler({ onClick }) {
// ── Route travel time label ──
function RouteLabel({ midpoint, walkingText, drivingText }) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 16)
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)

View File

@@ -7,7 +7,7 @@ const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
* @param {string} profile - 'driving' | 'walking' | 'cycling'
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
*/
export async function calculateRoute(waypoints, profile = 'driving') {
export async function calculateRoute(waypoints, profile = 'driving', { signal } = {}) {
if (!waypoints || waypoints.length < 2) {
throw new Error('At least 2 waypoints required')
}
@@ -16,7 +16,7 @@ export async function calculateRoute(waypoints, profile = 'driving') {
// OSRM public API only supports driving; we override duration for other modes
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url)
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Route could not be calculated')
}
@@ -100,6 +100,36 @@ export function optimizeRoute(places) {
return result
}
/**
* Calculate per-leg travel times in a single OSRM request
* Returns array of { mid, walkingText, drivingText } for each leg
*/
export async function calculateSegments(waypoints, { signal } = {}) {
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
const legs = data.routes[0].legs
return legs.map((leg, 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]
const walkingDuration = leg.distance / (5000 / 3600) // 5 km/h
return {
mid, from, to,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
}
})
}
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`

View File

@@ -248,9 +248,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
</div>
{r.reservation_time && (
{r.reservation_time?.includes('T') && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
</span>
)}
</div>

View File

@@ -762,9 +762,10 @@ export default function DayPlanSidebar({
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time && (
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
)}
</div>

View File

@@ -347,10 +347,13 @@ export default function PlaceInspector({
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
</div>
)}
{res.reservation_time && (
{res.reservation_time?.includes('T') && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</div>
</div>
)}
{res.confirmation_number && (

View File

@@ -804,6 +804,7 @@ export default function PlannerSidebar({
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)}
{r.reservation_end_time && ` ${r.reservation_end_time}`}
</div>
)}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}

View File

@@ -4,7 +4,8 @@ import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -25,8 +26,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
if (da.length === 0) continue
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
const groupLabel = `${dayLabel}${dateStr}`
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: `${dayLabel}${dateStr}`, disabled: true, isHeader: true })
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) {
const place = da[i].place
if (!place) continue
@@ -34,6 +36,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
options.push({
value: da[i].id,
label: ` ${i + 1}. ${place.name}${timeStr}`,
searchLabel: place.name,
groupLabel,
dayDate: day.date || null,
})
}
}
@@ -66,6 +71,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
reservation_end_time: reservation.reservation_end_time || '',
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
@@ -74,7 +80,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '',
})
setPendingFiles([])
@@ -169,34 +175,66 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Assignment Picker */}
{assignmentOptions.length > 0 && (
<div>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('reservations.linkAssignment')}
</label>
<CustomSelect
value={form.assignment_id}
onChange={value => set('assignment_id', value)}
placeholder={t('reservations.pickAssignment')}
options={[
{ value: '', label: t('reservations.noAssignment') },
...assignmentOptions,
]}
searchable
size="sm"
{/* Assignment Picker + Date */}
<div style={{ display: 'flex', gap: 8 }}>
{assignmentOptions.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('reservations.linkAssignment')}
</label>
<CustomSelect
value={form.assignment_id}
onChange={value => {
set('assignment_id', value)
const opt = assignmentOptions.find(o => o.value === value)
if (opt?.dayDate) {
setForm(prev => {
if (prev.reservation_time) return prev
return { ...prev, reservation_time: opt.dayDate }
})
}
}}
placeholder={t('reservations.pickAssignment')}
options={[
{ value: '', label: t('reservations.noAssignment') },
...assignmentOptions,
]}
searchable
size="sm"
/>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}}
/>
</div>
)}
</div>
{/* Date/Time + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
{/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { 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)
}}
/>
</div>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}

View File

@@ -103,10 +103,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
</div>
)}
{r.reservation_time && (
{r.reservation_time?.includes('T') && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtTime(r.reservation_time)}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time}` : ''}
</div>
</div>
)}
{r.confirmation_number && (

View File

@@ -514,6 +514,7 @@ export function RightPanel({
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(reservation.reservation_time)}
{reservation.reservation_end_time && ` ${reservation.reservation_end_time}`}
</div>
)}
{reservation.location && (

View File

@@ -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 (

View File

@@ -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'

View File

@@ -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)',

View File

@@ -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)',

View File

@@ -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

View File

@@ -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",

View File

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

View File

@@ -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,