mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user