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", "name": "nomad-client",
"version": "2.5.5", "version": "2.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.5", "version": "2.6.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",

View File

@@ -162,11 +162,11 @@ function MapClickHandler({ onClick }) {
// ── Route travel time label ── // ── Route travel time label ──
function RouteLabel({ midpoint, walkingText, drivingText }) { function RouteLabel({ midpoint, walkingText, drivingText }) {
const map = useMap() const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false) const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => { useEffect(() => {
if (!map) return if (!map) return
const check = () => setVisible(map.getZoom() >= 16) const check = () => setVisible(map.getZoom() >= 12)
check() check()
map.on('zoomend', check) map.on('zoomend', check)
return () => map.off('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' * @param {string} profile - 'driving' | 'walking' | 'cycling'
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>} * @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) { if (!waypoints || waypoints.length < 2) {
throw new Error('At least 2 waypoints required') 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 // 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 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) { if (!response.ok) {
throw new Error('Route could not be calculated') throw new Error('Route could not be calculated')
} }
@@ -100,6 +100,36 @@ export function optimizeRoute(places) {
return result 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) { function formatDistance(meters) {
if (meters < 1000) { if (meters < 1000) {
return `${Math.round(meters)} m` 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> <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>} {linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
</div> </div>
{r.reservation_time && ( {r.reservation_time?.includes('T') && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}> <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 })} {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> </span>
)} )}
</div> </div>

View File

@@ -762,9 +762,10 @@ export default function DayPlanSidebar({
}}> }}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()} {(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span> <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 }}> <span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} {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> </span>
)} )}
</div> </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 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> </div>
)} )}
{res.reservation_time && ( {res.reservation_time?.includes('T') && (
<div> <div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</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> </div>
)} )}
{res.confirmation_number && ( {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"> <div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)} {formatDateTime(r.reservation_time)}
{r.reservation_end_time && ` ${r.reservation_end_time}`}
</div> </div>
)} )}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</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 { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -25,8 +26,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
if (da.length === 0) continue if (da.length === 0) continue
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number }) const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : '' const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
const groupLabel = `${dayLabel}${dateStr}`
// Group header (non-selectable) // 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++) { for (let i = 0; i < da.length; i++) {
const place = da[i].place const place = da[i].place
if (!place) continue if (!place) continue
@@ -34,6 +36,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
options.push({ options.push({
value: da[i].id, value: da[i].id,
label: ` ${i + 1}. ${place.name}${timeStr}`, 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', type: reservation.type || 'other',
status: reservation.status || 'pending', status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '', reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
reservation_end_time: reservation.reservation_end_time || '',
location: reservation.location || '', location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '', confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '', notes: reservation.notes || '',
@@ -74,7 +80,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else { } else {
setForm({ setForm({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', notes: '', assignment_id: '',
}) })
setPendingFiles([]) setPendingFiles([])
@@ -169,34 +175,66 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} /> placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div> </div>
{/* Assignment Picker */} {/* Assignment Picker + Date */}
{assignmentOptions.length > 0 && ( <div style={{ display: 'flex', gap: 8 }}>
<div> {assignmentOptions.length > 0 && (
<label style={labelStyle}> <div style={{ flex: 1, minWidth: 0 }}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} /> <label style={labelStyle}>
{t('reservations.linkAssignment')} <Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
</label> {t('reservations.linkAssignment')}
<CustomSelect </label>
value={form.assignment_id} <CustomSelect
onChange={value => set('assignment_id', value)} value={form.assignment_id}
placeholder={t('reservations.pickAssignment')} onChange={value => {
options={[ set('assignment_id', value)
{ value: '', label: t('reservations.noAssignment') }, const opt = assignmentOptions.find(o => o.value === value)
...assignmentOptions, if (opt?.dayDate) {
]} setForm(prev => {
searchable if (prev.reservation_time) return prev
size="sm" 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>
)} </div>
{/* Date/Time + Status */} {/* Start Time + End Time + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div style={{ display: 'flex', gap: 8 }}>
<div> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.datetime')}</label> <label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} /> <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> <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> <label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect <CustomSelect
value={form.status} 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 style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
</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={{ 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: 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> </div>
)} )}
{r.confirmation_number && ( {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"> <div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatDateTime(reservation.reservation_time)} {formatDateTime(reservation.reservation_time)}
{reservation.reservation_end_time && ` ${reservation.reservation_end_time}`}
</div> </div>
)} )}
{reservation.location && ( {reservation.location && (

View File

@@ -172,7 +172,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
} }
const handleTimeChange = (t) => { const handleTimeChange = (t) => {
const d = datePart || new Date().toISOString().split('T')[0] const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : `${d}T00:00`) onChange(t ? `${d}T${t}` : d)
} }
return ( return (

View File

@@ -33,7 +33,29 @@ export default function CustomSelect({
const selected = options.find(o => o.value === value) const selected = options.find(o => o.value === value)
const filtered = searchable && search 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 : options
const sm = size === 'sm' const sm = size === 'sm'

View File

@@ -577,6 +577,8 @@ const de = {
'reservations.editTitle': 'Reservierung bearbeiten', 'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status', 'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit', 'reservations.datetime': 'Datum & Uhrzeit',
'reservations.startTime': 'Startzeit',
'reservations.endTime': 'Endzeit',
'reservations.date': 'Datum', 'reservations.date': 'Datum',
'reservations.time': 'Uhrzeit', 'reservations.time': 'Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)', 'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',

View File

@@ -577,6 +577,8 @@ const en = {
'reservations.editTitle': 'Edit Reservation', 'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status', 'reservations.status': 'Status',
'reservations.datetime': 'Date & Time', 'reservations.datetime': 'Date & Time',
'reservations.startTime': 'Start time',
'reservations.endTime': 'End time',
'reservations.date': 'Date', 'reservations.date': 'Date',
'reservations.time': 'Time', 'reservations.time': 'Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)', '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 { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket' import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' 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' import ConfirmDialog from '../components/shared/ConfirmDialog'
const MIN_SIDEBAR = 200 const MIN_SIDEBAR = 200
@@ -188,31 +188,29 @@ export default function TripPlannerPage() {
}, [places]) }, [places])
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
const routeAbortRef = useRef(null)
const updateRouteForDay = useCallback(async (dayId) => { const updateRouteForDay = useCallback(async (dayId) => {
// Abort any previous calculation
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return } if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) 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) const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) { if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map(p => [p.lat, p.lng]))
if (!routeCalcEnabled) { setRouteSegments([]); return } setRoute(waypoints.map(p => [p.lat, p.lng]))
// Calculate per-segment travel times if (!routeCalcEnabled) { setRouteSegments([]); return }
const segments = []
for (let i = 0; i < waypoints.length - 1; i++) { // Single OSRM request for all segments
const from = [waypoints[i].lat, waypoints[i].lng] const controller = new AbortController()
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng] routeAbortRef.current = controller
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
try { try {
const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking') const segments = await calculateSegments(waypoints, { signal: controller.signal })
segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText }) if (!controller.signal.aborted) setRouteSegments(segments)
} catch { } catch (err) {
segments.push({ mid, from, to, walkingText: '?', drivingText: '?' }) if (err.name !== 'AbortError') setRouteSegments([])
}
}
setRouteSegments(segments)
} else {
setRoute(null)
setRouteSegments([])
} }
}, [tripStore, routeCalcEnabled]) }, [tripStore, routeCalcEnabled])
@@ -231,8 +229,7 @@ export default function TripPlannerPage() {
setSelectedPlaceId(placeId) setSelectedPlaceId(placeId)
} }
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) } if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
updateRouteForDay(selectedDayId) }, [selectAssignment, setSelectedPlaceId])
}, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => { const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined const opening = placeId !== undefined
@@ -366,16 +363,10 @@ export default function TripPlannerPage() {
return map return map
}, [selectedDayId, assignments]) }, [selectedDayId, assignments])
// Auto-update route when assignments change // Auto-update route + segments when assignments change
useEffect(() => { useEffect(() => {
if (!selectedDayId) return if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
const da = (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) updateRouteForDay(selectedDayId)
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)
}
}, [selectedDayId, assignments]) }, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting // Places assigned to selected day (with coords) — used for map fitting

View File

@@ -1,12 +1,12 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.5", "version": "2.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.5", "version": "2.6.0",
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

View File

@@ -182,6 +182,7 @@ function initDb() {
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
reservation_time TEXT, reservation_time TEXT,
reservation_end_time TEXT,
location TEXT, location TEXT,
confirmation_number TEXT, confirmation_number TEXT,
notes 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 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 {} 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) // Future migrations go here (append only, never reorder)
]; ];

View File

@@ -31,7 +31,7 @@ router.get('/', authenticate, (req, res) => {
// POST /api/trips/:tripId/reservations // POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { tripId } = req.params; 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); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); 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' }); if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, location, confirmation_number, notes, status, type) INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
day_id || null, day_id || null,
@@ -48,6 +48,7 @@ router.post('/', authenticate, (req, res) => {
assignment_id || null, assignment_id || null,
title, title,
reservation_time || null, reservation_time || null,
reservation_end_time || null,
location || null, location || null,
confirmation_number || null, confirmation_number || null,
notes || null, notes || null,
@@ -70,7 +71,7 @@ router.post('/', authenticate, (req, res) => {
// PUT /api/trips/:tripId/reservations/:id // PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => { router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; 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); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -82,6 +83,7 @@ router.put('/:id', authenticate, (req, res) => {
UPDATE reservations SET UPDATE reservations SET
title = COALESCE(?, title), title = COALESCE(?, title),
reservation_time = ?, reservation_time = ?,
reservation_end_time = ?,
location = ?, location = ?,
confirmation_number = ?, confirmation_number = ?,
notes = ?, notes = ?,
@@ -94,6 +96,7 @@ router.put('/:id', authenticate, (req, res) => {
`).run( `).run(
title || null, title || null,
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time, 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, location !== undefined ? (location || null) : reservation.location,
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number, confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
notes !== undefined ? (notes || null) : reservation.notes, notes !== undefined ? (notes || null) : reservation.notes,