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 { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||||
@@ -152,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
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 legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
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.
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
const merged = mergedItemsMap[selectedDayId] || []
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
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 (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()
|
const controller = new AbortController()
|
||||||
legsAbortRef.current = controller
|
legsAbortRef.current = controller
|
||||||
@@ -422,9 +450,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (err instanceof Error && err.name === 'AbortError') return
|
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) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -938,6 +984,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1085,6 +1133,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
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)
|
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 ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
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 */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
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'
|
import type { RouteSegment } from '../../types'
|
||||||
|
|
||||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
/** 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>
|
</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'
|
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 (
|
return (
|
||||||
<button type="button" onClick={onToggle}
|
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
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', () => {
|
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;
|
let capturedBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/auth/login', async ({ request }) => {
|
http.post('/api/auth/login', async ({ request }) => {
|
||||||
@@ -120,13 +120,13 @@ describe('LoginPage', () => {
|
|||||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
|
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||||
await user.click(checkbox);
|
await user.click(toggle);
|
||||||
expect(checkbox).toBeChecked();
|
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
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 { 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 { useLogin } from './login/useLogin'
|
||||||
|
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
@@ -573,15 +574,15 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
{mode === 'login' && (
|
{mode === 'login' && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<input
|
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||||
type="checkbox"
|
<span
|
||||||
checked={rememberMe}
|
onClick={() => setRememberMe(!rememberMe)}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
|
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||||
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
|
>
|
||||||
/>
|
|
||||||
{t('login.rememberMe')}
|
{t('login.rememberMe')}
|
||||||
</label>
|
</span>
|
||||||
|
</div>
|
||||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||||
|
|||||||
Reference in New Issue
Block a user