From 25324108cbe2c01641803a016991091d0ba4fc5a Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:51:57 +0200 Subject: [PATCH] Day plan: hotel travel times at start/end + login toggle polish (#1206) * fix(login): use the shared toggle for the stay-signed-in option * feat(planner): show hotel travel times at the start and end of a day * fix(login): give the stay-signed-in toggle an accessible name and fix its test --- .../src/components/Planner/DayPlanSidebar.tsx | 66 +++++++++++++++++-- .../Planner/DayPlanSidebarRouteConnector.tsx | 59 ++++++++++++++++- .../src/components/Settings/ToggleSwitch.tsx | 4 +- client/src/pages/LoginPage.test.tsx | 10 +-- client/src/pages/LoginPage.tsx | 19 +++--- 5 files changed, 136 insertions(+), 22 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 0e7659b1..5e6a63a1 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -27,7 +27,7 @@ import { import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants' -import { RouteConnector } from './DayPlanSidebarRouteConnector' +import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal' @@ -152,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [isCalculating, setIsCalculating] = useState(false) const [routeInfo, setRouteInfo] = useState(null) const [routeLegs, setRouteLegs] = useState>({}) + const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({}) + const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) const legsAbortRef = useRef(null) const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) @@ -379,7 +381,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // the start place's assignment id. Shares RouteCalculator's cache with the map. useEffect(() => { if (legsAbortRef.current) legsAbortRef.current.abort() - if (!selectedDayId || !routeShown) { setRouteLegs({}); return } + if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return } const merged = mergedItemsMap[selectedDayId] || [] const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { const e = (r.endpoints || []).find((x: any) => x.role === role) @@ -408,7 +410,33 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { } } if (cur.length >= 2) runs.push(cur) - if (runs.length === 0) { setRouteLegs({}); return } + + // Hotel bookend legs: the drive from the day's accommodation to the first + // located place (morning) and from the last place back to it (evening). Only + // when the "optimize from accommodation" setting is on and the day has a hotel, + // mirroring the range logic the optimizer itself uses (getAccommodationAnchors). + const day = days.find(d => d.id === selectedDayId) + const dayAccs = day && optimizeFromAccommodation !== false + ? accommodations.filter(a => a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) + : [] + const checkOut = day ? dayAccs.find(a => a.end_day_id === day.id) : undefined + const checkIn = day ? dayAccs.find(a => a.start_day_id === day.id) : undefined + const transfer = !!(checkOut && checkIn && checkOut !== checkIn) + const startHotel = transfer ? checkOut : dayAccs[0] + const endHotel = transfer ? checkIn : dayAccs[0] + const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || '' + const placePts: { lat: number; lng: number }[] = [] + for (const it of merged) { + if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { + placePts.push({ lat: it.data.place.lat, lng: it.data.place.lng }) + } + } + const firstPlace = placePts[0] + const lastPlace = placePts[placePts.length - 1] + const wantTop = !!(startHotel && firstPlace) + const wantBottom = !!(endHotel && lastPlace) + + if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return } const controller = new AbortController() legsAbortRef.current = controller @@ -422,9 +450,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (err instanceof Error && err.name === 'AbortError') return } } - if (!controller.signal.aborted) setRouteLegs(map) + + // One extra cached OSRM call per bookend; shares RouteCalculator's cache. + const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise => { + try { + const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile }) + return r.legs[0] + } catch { return undefined } + } + const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {} + if (wantTop) { + const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstPlace.lat, lng: firstPlace.lng }) + if (seg) hotel.top = { seg, name: hotelName(startHotel!) } + } + if (wantBottom) { + const seg = await legBetween({ lat: lastPlace.lat, lng: lastPlace.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }) + if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) } + } + + if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) } })() - }, [selectedDayId, routeShown, routeProfile, mergedItemsMap]) + }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation]) const openAddNote = (dayId, e) => { e?.stopPropagation() @@ -938,6 +984,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setRouteInfo, routeLegs, setRouteLegs, + hotelLegs, + setHotelLegs, legsAbortRef, draggingId, setDraggingId, @@ -1085,6 +1133,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP setRouteInfo, routeLegs, setRouteLegs, + hotelLegs, + setHotelLegs, legsAbortRef, draggingId, setDraggingId, @@ -1427,6 +1477,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) }} > + {isSelected && hotelLegs.top && ( + + )} {merged.length === 0 && !dayNoteUi ? (
{ e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} @@ -2057,6 +2110,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP ) }) )} + {isSelected && hotelLegs.bottom && ( + + )} {/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
) } + +/** + * The hotel's bookend legs for a day: a two-line connector naming the day's + * accommodation with the drive to/from it. Rendered above the first place (the + * morning departure from the hotel) and below the last place (the evening return), + * when the "optimize from accommodation" setting is on and the day has a hotel. + */ +export function HotelRouteConnector({ + seg, + profile, + name, + placement, +}: { + seg: RouteSegment + profile: 'driving' | 'walking' + name: string + placement: 'top' | 'bottom' +}) { + const driving = profile === 'driving' + const Icon = driving ? Car : Footprints + const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' } + const hotelRow = ( +
+ + + {name} + +
+ ) + const travelRow = ( +
+
+
+ + {seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)} + · + {seg.distanceText} +
+
+
+ ) + return ( +
+ {placement === 'top' ? ( + <> + {hotelRow} + {travelRow} + + ) : ( + <> + {travelRow} + {hotelRow} + + )} +
+ ) +} diff --git a/client/src/components/Settings/ToggleSwitch.tsx b/client/src/components/Settings/ToggleSwitch.tsx index b74e5513..a4a7b5fd 100644 --- a/client/src/components/Settings/ToggleSwitch.tsx +++ b/client/src/components/Settings/ToggleSwitch.tsx @@ -1,8 +1,8 @@ import React from 'react' -export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { +export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) { return ( -
{mode === 'login' && (
- +
+ setRememberMe(!rememberMe)} label={t('login.rememberMe')} /> + setRememberMe(!rememberMe)} + style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }} + > + {t('login.rememberMe')} + +