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

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