mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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
This commit is contained in:
@@ -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<Record<number, RouteSegment>>({})
|
||||
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<AbortController | null>(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<RouteSegment | undefined> => {
|
||||
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 && (
|
||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||
)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
@@ -2057,6 +2110,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isSelected && hotelLegs.bottom && (
|
||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||
)}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Car, Footprints } from 'lucide-react'
|
||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
{travelRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{travelRow}
|
||||
{hotelRow}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" onClick={onToggle}
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||
it('renders an unchecked checkbox and forwards remember_me: true when ticked', async () => {
|
||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/auth/login', async ({ request }) => {
|
||||
@@ -120,13 +120,13 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||
import { useLogin } from './login/useLogin'
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
@@ -573,15 +574,15 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
|
||||
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||
<span
|
||||
onClick={() => setRememberMe(!rememberMe)}
|
||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||
>
|
||||
{t('login.rememberMe')}
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
|
||||
Reference in New Issue
Block a user