fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)

- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
This commit is contained in:
Maurice
2026-07-01 21:07:27 +02:00
committed by Maurice
parent 60cbd20327
commit a83ecc4ffb
35 changed files with 255 additions and 42 deletions
@@ -201,7 +201,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Remember which assignment we last auto-scrolled into view so we don't
// keep yanking the user back whenever they scroll away while the same
// place stays selected.
const lastAutoScrolledIdRef = useRef<number | null>(null)
const lastAutoScrolledIdRef = useRef<string | number | null>(null)
useEffect(() => {
// Reset the scroll-lock whenever selection moves, so the next selected
// row triggers a fresh scroll-into-view on its ref.
@@ -392,9 +392,15 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const merged = mergedItemsMap[selectedDayId] || []
const runs: { id: number; lat: number; lng: number }[][] = []
let cur: { id: number; lat: number; lng: number }[] = []
// A run is only a real drive when it holds an actual place. Two back-to-back
// transports (e.g. two flights on one day) would otherwise pair the first's
// arrival with the second's departure into a phantom airport→airport leg — the
// flight, not a drive — and surface it as a bogus connector distance (#1394).
let curHasPlace = false
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
curHasPlace = true
} else if (it.type === 'transport') {
const r = it.data
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
@@ -402,8 +408,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Located transport: route to its departure point, break the run (the
// flight/train itself isn't driven), and let its arrival start the next.
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
if (cur.length >= 2) runs.push(cur)
if (cur.length >= 2 && curHasPlace) runs.push(cur)
cur = []
curHasPlace = false
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
} else if (cur.length > 0) {
// No location: ignore for routing, but attribute the through-leg to the
@@ -412,7 +419,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
}
}
if (cur.length >= 2) runs.push(cur)
if (cur.length >= 2 && curHasPlace) runs.push(cur)
// Hotel bookend legs: the drive from the day's accommodation to the first located
// waypoint of the day (morning) and from the last one back to it (evening). Only when
@@ -1058,6 +1065,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
// A stable key for the current selection. A multi-day place renders one row per
// day (same place_id, different assignment ids); selecting it by place_id alone
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
// per-assignment scroll-lock would let each day's row scroll the list in turn.
// Keying the lock on the selection identity makes only the first row scroll (#1375).
const selectionScrollKey = S.selectedAssignmentId != null ? `a${S.selectedAssignmentId}` : S.selectedPlaceId != null ? `p${S.selectedPlaceId}` : null
// Needed by the route-tools visibility gate in the render below (#1330); the hook
// keeps its own copy, so read it reactively here in the component scope too.
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
@@ -1609,14 +1622,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// the transition "just became selected". Once we've
// scrolled for this assignment id, we won't scroll
// again until selection actually moves somewhere else.
if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) {
if (el && isPlaceSelected && selectionScrollKey != null && lastAutoScrolledIdRef.current !== selectionScrollKey) {
const rect = el.getBoundingClientRect()
const nearTop = rect.top < 80
const nearBottom = rect.bottom > window.innerHeight - 80
if (nearTop || nearBottom) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
lastAutoScrolledIdRef.current = assignment.id
lastAutoScrolledIdRef.current = selectionScrollKey
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
@@ -2186,8 +2199,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
onClick={() => onToggleRoute?.()}
className={routeShown ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
onClick={() => {
// The route is computed for the globally selected day, so on
// mobile (where an expanded non-selected day also shows this
// footer) tapping Route must first point the selection at this
// day — otherwise it toggles the previously selected day (#1392).
if (isSelected) { onToggleRoute?.() }
else { onSelectDay(day.id, true); if (!routeShown) onToggleRoute?.() }
}}
className={routeShown && isSelected ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, borderRadius: 8,
@@ -2201,7 +2221,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
// Bookend the Google Maps route with the day's accommodation the
// same way the drawn map route does (routeBookends is null when
// "optimize from accommodation" is off), so hotels aren't dropped
// from the exported route (#1372).
const stops = getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[]
const morning = routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -87,6 +87,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
place_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
@@ -139,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: reservation.url || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
place_id: reservation.place_id || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
@@ -168,6 +170,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: (prefill as { url?: string }).url || '',
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
place_id: '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
@@ -182,7 +185,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', place_id: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
})
@@ -242,6 +245,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
url: form.url,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
// Hotels link a place through the accommodation record; every other type links
// the picked trip place/activity directly on the reservation (#1353).
place_id: form.type === 'hotel' ? null : (form.place_id || null),
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [],
needs_review: false,
@@ -466,6 +472,35 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)}
{/* Location */}
{/* Link an existing trip place/activity to any non-hotel booking (#1353). Hotels
keep their own accommodation-based place picker below. */}
{form.type !== 'hotel' && (
<div>
<label className={labelClass}>{t('reservations.meta.linkPlace')}</label>
<CustomSelect
value={form.place_id}
onChange={value => {
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, place_id: value }
if (value && p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}}
placeholder={t('reservations.meta.pickPlace')}
options={[
{ value: '', label: '—' },
...places.map(p => ({ value: p.id, label: p.name })),
]}
searchable
size="sm"
/>
</div>
)}
{form.type !== 'hotel' && (
<div>
<label className={labelClass}>{t('reservations.locationAddress')}</label>
@@ -112,11 +112,14 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
// For a hotel linked to an accommodation, the accommodation's own start/end days are
// the source of truth for the stay range: a stale day_id left behind by a range edit
// would otherwise mislabel the card, so prefer the accommodation ids here (#1383).
const startDay = (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: r.day_id ? days.find(d => d.id === r.day_id)
: undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
const endDay = (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: r.end_day_id ? days.find(d => d.id === r.end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
@@ -234,8 +237,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
</div>
)}
{/* Date / Time row */}
{(hasDate || hasTime) && (
{/* Date / Time row — hidden for a hotel linked to an accommodation: its stay
already shows as the day-range label above, and a reservation_time stamped
on the auto-created reservation would otherwise duplicate it (#1383). */}
{(hasDate || hasTime) && !(isHotel && (r.accommodation_start_day_id || r.accommodation_end_day_id)) && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
{hasDate && (
<div>