mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off until somebody sets it, so every trip drives exactly as before. Switched on, the planner in the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the next day and tonight's hotel at the end of the day, both through seatNightBookends over the trip's stays in id order, with the earliest booking linked to each. A linked booking changes the hotel row and asks the router nothing. The switch sits in the desktop driving settings under the route line, and the phone's driving figures carry the same switch as the one control they offer; both write through the planner's own preference save, so a flip offline queues like any other. get_roadtrip_context reports the stays, calculate_roadtrip previews the switch through its settings, and the settings, corridor and day boundary tools say what it does and that missing means off.
This commit is contained in:
@@ -277,6 +277,36 @@ describe('RoadtripLimitsCard', () => {
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-018: starting and ending each day at the stay is off until it is asked for', async () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
|
||||
// Missing is off: a trip drives as it always has until somebody switches it on.
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
// What it does is said on the label, where the panel has no room for it.
|
||||
fireEvent.mouseEnter(screen.getByText('Start and end each day at your stay'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'After a booked night the day starts where you slept, and before one it ends there.',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-019: switched on, it reads on and reports the way back off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Start and end each day at your stay' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -313,6 +343,16 @@ describe('RoadtripLimitsCard', () => {
|
||||
expect(screen.getByText('Connect the days')).toBeInTheDocument()
|
||||
expect(screen.getByText('A colour per day')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-020: the stay switch shows its row and offers no switch', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_hotel_bookends: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
open(undefined)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Start and end each day at your stay' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Start and end each day at your stay')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ import { FS } from './typeScale'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
import SettingsHint from './SettingsHint'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { BOOKEND_ICON } from './nightBookend'
|
||||
import { useHotelBookends } from './useHotelBookends'
|
||||
|
||||
/**
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
@@ -162,18 +165,22 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
|
||||
* Disabled rather than hidden when there is no engine that can answer: an operator who
|
||||
* pointed the instance at their own OSRM has no second engine, and a switch that flips
|
||||
* and changes nothing is worse than one that says why it cannot.
|
||||
*
|
||||
* A `hint` is the sentence a switch needs and its panel has no room for, on the label.
|
||||
*/
|
||||
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
function AvoidRow({ icon: Icon, label, hint, on, disabled, onToggle }: {
|
||||
icon: typeof Coins
|
||||
label: string
|
||||
hint?: string
|
||||
on: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.ReactElement {
|
||||
const text = <span tabIndex={hint ? 0 : undefined} className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
{hint ? <Tooltip label={hint} placement="top">{text}</Tooltip> : text}
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
@@ -281,6 +288,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
// days gets the dialog with the trip's figures in it, not a form that takes a number
|
||||
// and drops it.
|
||||
const readOnly = !onSave
|
||||
const bookends = useHotelBookends(onSave)
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
@@ -657,6 +665,18 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
disabled={readOnly}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>}
|
||||
{/* Where a day begins and ends rather than what joins them: after a booked
|
||||
night the drive sets off from the stay, and before one it ends there.
|
||||
Off by default, because it adds a leg to most days of a trip that has
|
||||
stays and changes their kilometres and times. */}
|
||||
<AvoidRow
|
||||
icon={BOOKEND_ICON}
|
||||
label={t('roadtrip.line.hotelBookends')}
|
||||
hint={t('roadtrip.line.hotelBookendsHint')}
|
||||
on={bookends.on}
|
||||
disabled={!bookends.toggle}
|
||||
onToggle={() => bookends.toggle?.()}
|
||||
/>
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
stroke, and a colour per day is what puts the days back into it. */}
|
||||
<AvoidRow
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { hotelBookendsOn } from '@trek/shared/roadtrip'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
|
||||
/** The preference the switch is stored under, spelled once for both surfaces. */
|
||||
export const HOTEL_BOOKENDS_KEY = 'roadtrip_hotel_bookends'
|
||||
|
||||
/** Whether a trip's days start and end at the stay, and the way to flip it. */
|
||||
export interface HotelBookendsSwitch {
|
||||
on: boolean
|
||||
/** Absent for a reader who may not change it, or while the trip's settings are not in. */
|
||||
toggle?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch that seats a booked night at both ends of its days, as the desktop settings
|
||||
* and the phone's driving figures both offer it.
|
||||
*
|
||||
* One hook so the two cannot disagree about what "on" means or where it is written. The
|
||||
* value is the shared rule's own reading (`hotelBookendsOn`: missing is off), the one the
|
||||
* drive is planned with, and the write is the save the caller holds. On both surfaces that
|
||||
* is the planner's `saveRoadtripLimit`, so a flip on the phone goes through the same repo,
|
||||
* the same offline queue and the same broadcast as one on the desktop.
|
||||
*/
|
||||
export function useHotelBookends(
|
||||
save: ((key: string, value: boolean) => void) | undefined,
|
||||
tripId?: number | string | null,
|
||||
): HotelBookendsSwitch {
|
||||
const on = useRoadtripSettings(hotelBookendsOn, tripId)
|
||||
return { on, toggle: save ? () => save(HOTEL_BOOKENDS_KEY, !on) : undefined }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
|
||||
import type { Accommodation, Assignment, AssignmentsMap, Day, Reservation, Settings } from '../../types'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
// Hoisted together with the mock: the module factory runs before the file body, so a
|
||||
@@ -1163,3 +1163,197 @@ describe('a leg asked for again on demand', () => {
|
||||
expect(result.current.days[0].avoidMissed ?? []).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-ROUTES-052..059: a booked night at both ends of the days around it.
|
||||
*
|
||||
* The first cases here that hand the hook the trip's stays. What is pinned is what goes to
|
||||
* the router: the drive from the hotel slept in and to tonight's is part of the day's own
|
||||
* run, a night spent at one hotel asks for nothing, no via bends the hotel's legs, and with
|
||||
* the switch off the days are the stored ones and nothing else.
|
||||
*/
|
||||
describe('a booked night at both ends of its days', () => {
|
||||
const GETAWAY: [number, number] = [-33.71, 150.31]
|
||||
const LOOKOUT: [number, number] = [-33.73, 150.35]
|
||||
const FALLS: [number, number] = [-33.65, 150.38]
|
||||
const WALLINGA: [number, number] = [-34.1, 150.9]
|
||||
const HOTEL: [number, number] = [45.07, 7.68]
|
||||
const P: [number, number][] = [1, 2, 3, 4, 5, 6].map(n => [45 + n * 0.1, 7 + n * 0.1])
|
||||
const VIA: [number, number] = [44.5, 7.5]
|
||||
|
||||
/** A stay whose place is the one stop spec `stopId` stands for (`place_id` is id * 10). */
|
||||
const stayOf = (id: number, stopId: number, start: number, end: number, at: [number, number], over: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id, trip_id: 7, place_id: stopId * 10, start_day_id: start, end_day_id: end,
|
||||
check_in: null, check_out: '10:00', place_name: `Stay ${id}`, place_lat: at[0], place_lng: at[1],
|
||||
...over,
|
||||
}) as unknown as Accommodation
|
||||
const booking = (id: number, accommodationId: number) =>
|
||||
({ id, trip_id: 7, title: 'Motel', type: 'hotel', status: 'confirmed', accommodation_id: accommodationId }) as unknown as Reservation
|
||||
|
||||
/** An hour and 100 km for whatever pair the router is handed. */
|
||||
const hourly = (points: { lat: number; lng: number }[]) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 100000 * (points.length - 1),
|
||||
duration: 3600 * (points.length - 1),
|
||||
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
|
||||
})
|
||||
/** Every run the router was asked for, as its waypoints' latitudes. */
|
||||
const asked = () => calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
|
||||
const shape = (stops: RoadtripStop[]) =>
|
||||
stops.map(s => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name))
|
||||
const switchTo = (on: boolean) =>
|
||||
act(() => { useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_hotel_bookends: on } as never }) })
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
switchTo(true)
|
||||
})
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
/** A check-in with two places, then a transfer day that is only the next check-in. */
|
||||
const cam = () => ({
|
||||
days: [day(1, 1), day(2, 2), day(3, 3)],
|
||||
assignments: {
|
||||
...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }, { id: 3, at: FALLS }]),
|
||||
...map(2, [{ id: 4, at: WALLINGA }]),
|
||||
} as AssignmentsMap,
|
||||
stays: [stayOf(1, 1, 1, 2, GETAWAY, { check_in: '14:00' }), stayOf(2, 4, 2, 3, WALLINGA, { check_in: '15:00' })],
|
||||
})
|
||||
|
||||
/** Three nights in one hotel with two places on every day before the check-out. */
|
||||
const simeon = () => ({
|
||||
days: [day(1, 1), day(2, 2), day(3, 3), day(4, 4)],
|
||||
assignments: {
|
||||
...map(1, [{ id: 10, at: HOTEL }, { id: 11, at: P[0] }, { id: 12, at: P[1] }]),
|
||||
...map(2, [{ id: 13, at: P[2] }, { id: 14, at: P[3] }]),
|
||||
...map(3, [{ id: 15, at: P[4] }, { id: 16, at: P[5] }]),
|
||||
} as AssignmentsMap,
|
||||
stays: [stayOf(5, 10, 1, 4, HOTEL, { check_in: '15:00' })],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-052: the check-in day drives back to the stay, the transfer day sets off from it', async () => {
|
||||
const { days, assignments, stays } = cam()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, [booking(90, 1)]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.days.map(d => shape(d.stops))).toEqual([
|
||||
['Stop 1', 'Stop 2', 'Stop 3', 'evening:1'],
|
||||
['morning:1', 'Stop 4'],
|
||||
])
|
||||
// One run per day, the hotel legs inside it.
|
||||
expect(asked()).toEqual([
|
||||
[GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
|
||||
[GETAWAY[0], WALLINGA[0]],
|
||||
])
|
||||
expect(result.current.days[1].stops[0].bookend).toMatchObject({ checkingOut: true, checkOut: '10:00', reservationId: 90 })
|
||||
expect(result.current.days[0].legs).toHaveLength(3)
|
||||
// Nothing numbered changes: the hotel is a service stop, the day's own stops are counted.
|
||||
expect(result.current.totalStops).toBe(4)
|
||||
// The check-out day with nothing of its own is no card.
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-053: every day between the nights starts and ends at the hotel', async () => {
|
||||
const { days, assignments, stays } = simeon()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.days.map(d => shape(d.stops))).toEqual([
|
||||
['Stop 10', 'Stop 11', 'Stop 12', 'evening:5'],
|
||||
['morning:5', 'Stop 13', 'Stop 14', 'evening:5'],
|
||||
['morning:5', 'Stop 15', 'Stop 16', 'evening:5'],
|
||||
])
|
||||
expect(asked()).toEqual([
|
||||
[HOTEL[0], P[0][0], P[1][0], HOTEL[0]],
|
||||
[HOTEL[0], P[2][0], P[3][0], HOTEL[0]],
|
||||
[HOTEL[0], P[4][0], P[5][0], HOTEL[0]],
|
||||
])
|
||||
// The check-out day has nothing of its own and is not driven.
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-054: with the days connected, a night spent at one hotel asks the router nothing', async () => {
|
||||
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_connect_days: true } })) })
|
||||
const { days, assignments, stays } = cam()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
// The two day runs and nothing else: no seam from the Getaway to the Getaway.
|
||||
expect(asked()).toEqual([
|
||||
[GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]],
|
||||
[GETAWAY[0], WALLINGA[0]],
|
||||
])
|
||||
expect(result.current.days[1].arrivingLeg).toBeFalsy()
|
||||
expect(result.current.totalDistance).toBe(400000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-055: a via behind the last place of a day does not bend the drive to tonight’s stay', async () => {
|
||||
const { days, assignments, stays } = cam()
|
||||
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 2, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(asked()[0]).toEqual([GETAWAY[0], LOOKOUT[0], FALLS[0], GETAWAY[0]])
|
||||
expect(asked().flat()).not.toContain(VIA[0])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-056: no via leaves the morning hotel; one after the first place stays after it', async () => {
|
||||
const { days, assignments, stays } = simeon()
|
||||
const vias = { 2: [{ id: 1, day_id: 2, after_order_index: 0, sequence: 0, lat: VIA[0], lng: VIA[1] }] }
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', vias, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(asked()[1]).toEqual([HOTEL[0], P[2][0], VIA[0], P[3][0], HOTEL[0]])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-057: a transfer day with nothing stored drives from one stay to the other', async () => {
|
||||
const days = [day(1, 1), day(2, 2), day(3, 3)]
|
||||
const assignments = { ...map(1, [{ id: 1, at: GETAWAY }, { id: 2, at: LOOKOUT }]) } as AssignmentsMap
|
||||
const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const transfer = result.current.days.find(d => d.dayId === 2)!
|
||||
expect(shape(transfer.stops)).toEqual(['morning:1', 'evening:2'])
|
||||
expect(transfer.stops.map(s => s.bookend && [s.bookend.checkingOut, s.bookend.checkingIn])).toEqual([[true, false], [false, true]])
|
||||
expect(asked()).toContainEqual([GETAWAY[0], WALLINGA[0]])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-058: switched off, the days are the stored ones, and back on and off again they are again', async () => {
|
||||
switchTo(false)
|
||||
const { days, assignments, stays } = simeon()
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const stored = result.current.days.map(d => d.stops)
|
||||
expect(stored.flat().some(s => s.bookend)).toBe(false)
|
||||
expect(asked()).toEqual([
|
||||
[HOTEL[0], P[0][0], P[1][0]],
|
||||
[P[2][0], P[3][0]],
|
||||
[P[4][0], P[5][0]],
|
||||
])
|
||||
|
||||
switchTo(true)
|
||||
await waitFor(() => expect(result.current.days[1]?.stops[0]?.bookend?.phase).toBe('morning'))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
switchTo(false)
|
||||
await waitFor(() => expect(result.current.days.flatMap(d => d.stops).some(s => s.bookend)).toBe(false))
|
||||
expect(result.current.days.map(d => d.stops)).toEqual(stored)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-059: linking a booking to the stay changes the hotel row, not the road', async () => {
|
||||
const { days, assignments, stays } = cam()
|
||||
const { result, rerender } = renderHook(
|
||||
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays, reservations),
|
||||
{ initialProps: { reservations: [] as Reservation[] } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[1].stops[0].bookend?.reservationId).toBeNull()
|
||||
const calls = calculateRouteWithLegs.mock.calls.length
|
||||
|
||||
rerender({ reservations: [booking(91, 1), booking(90, 1)] })
|
||||
// The earliest booking of the stay, and the router is not asked again.
|
||||
await waitFor(() => expect(result.current.days[1].stops[0].bookend?.reservationId).toBe(90))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { assembleRoadtrip, bookendStaysOf, carrierLegsFor, carrierSeam, foldRouteRun, hotelBookendsOn, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, seatNightBookends, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { avoidedClasses, calculateRouteWithLegs, routeEngineFor, RoutingRefusedError, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
@@ -328,9 +328,29 @@ export function useRoadtripRoutes(
|
||||
})
|
||||
}, [days, assignments, accommodations, rideSeams])
|
||||
|
||||
const plan = useMemo<PlanDay[]>(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
|
||||
/**
|
||||
* Whether a day after a booked night starts at the stay and a day before one ends there.
|
||||
* Off until the trip switches it on, so a trip drives exactly as it did before.
|
||||
*/
|
||||
const bookendsOn = useRoadtripSettings(hotelBookendsOn, tripId)
|
||||
// The nights as the rule reads them, with the booking behind each. Read only while the
|
||||
// switch is on, so a new booking on a trip without it touches nothing downstream.
|
||||
const stays = useMemo(
|
||||
() => (bookendsOn ? bookendStaysOf(accommodations, reservations) : []),
|
||||
[bookendsOn, accommodations, reservations],
|
||||
)
|
||||
// The stored days with the night before and the night after seated at their edges, by
|
||||
// the rule the server's calculate_roadtrip runs too. Before a day is asked whether it
|
||||
// stands as one: a day with a bookend always has two stops. A linked booking changes a
|
||||
// bookend's reservation and nothing `planKey` reads, so it asks the router nothing.
|
||||
const seatedDays = useMemo<PlanDay[]>(
|
||||
() => (bookendsOn ? seatNightBookends(storedDays, days, stays) : storedDays),
|
||||
[bookendsOn, storedDays, days, stays],
|
||||
)
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
|
||||
const plan = useMemo<PlanDay[]>(() => seatedDays.filter(d => standsAsDay(d.stops)), [seatedDays])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => seatedDays.filter(d => !standsAsDay(d.stops)), [seatedDays])
|
||||
|
||||
/** Days apart, for a ride that lands on a later day than it left. */
|
||||
const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.day_number ?? 0
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Car, Fuel, Zap } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MToggle from '../../../components/MToggle'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
|
||||
import { useVehicleRange } from '../../../../components/Roadtrip/useVehicleRange'
|
||||
import { useHotelBookends } from '../../../../components/Roadtrip/useHotelBookends'
|
||||
import { blockMask } from '../../../../components/Roadtrip/RangeStrip'
|
||||
import { dayWindow } from '../../../../components/Roadtrip/dayWindow'
|
||||
import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip/roadtripModel'
|
||||
@@ -21,13 +23,21 @@ import type { DistanceUnit } from '../../../../types'
|
||||
* tank runs dry, and on a phone there was nothing behind those sentences: the numbers they
|
||||
* are measured against live in a desktop dialog with seven panels and two columns. Without
|
||||
* them a warning is an opinion. So two of those panels come along, the two that decide what
|
||||
* the stage is allowed to say, and nothing else does.
|
||||
* the stage is allowed to say, and of a third only the one switch below.
|
||||
*
|
||||
* Nothing here can be changed. That is the point rather than a gap: driving limits are set
|
||||
* once, at a table, before anybody is in the car, and a form in the passenger seat is a way
|
||||
* to break a trip while it is happening. So there is no field, no slider and no greyed-out
|
||||
* button that looks like one, and the last line says plainly where the settings are and what
|
||||
* else that screen can do that this one cannot.
|
||||
* The figures cannot be changed here. That is the point rather than a gap: driving limits
|
||||
* are set once, at a table, before anybody is in the car, and a form in the passenger seat is
|
||||
* a way to break a trip while it is happening. So there is no field, no slider and no
|
||||
* greyed-out button that looks like one, and the last line says plainly where the settings
|
||||
* are and what else that screen can do that this one cannot.
|
||||
*
|
||||
* One switch is the exception: whether a day starts and ends at the stay. It is no figure and
|
||||
* breaks nothing. It moves where a drawn day begins and ends, never a stored stop, and
|
||||
* switched back off it gives exactly the stored plan again. It is also the one a traveller
|
||||
* on the road reaches for, when the day on the phone sets off from its first place instead
|
||||
* of the hotel they woke up in. It writes through the desktop dialog's own save
|
||||
* (`useHotelBookends` over the planner's `saveRoadtripLimit`), and stays disabled for a
|
||||
* reader who may not edit days.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -70,7 +80,8 @@ function InfoCard({ label, children }: { label: string; children: ReactNode }) {
|
||||
|
||||
/**
|
||||
* Driving figures sheet ('rtinfo'): the vehicle's range as the tank it leaves the factory
|
||||
* with, and the limits every warning on the stage is measured against. Read-only.
|
||||
* with, and the limits every warning on the stage is measured against. Read-only but for
|
||||
* the switch that starts and ends each day at the stay.
|
||||
*/
|
||||
export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -91,6 +102,7 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
|
||||
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
|
||||
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
||||
const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
|
||||
|
||||
const electric = vehicleKind === 'electric'
|
||||
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
|
||||
@@ -228,6 +240,24 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
{/* ── The one switch: whether a day starts and ends at the stay ── */}
|
||||
<div className="mt-2.5">
|
||||
<InfoCard label={t('roadtrip.line.section')}>
|
||||
<div className="mt-1 flex items-center justify-between gap-3 py-2">
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] text-m-muted">{t('roadtrip.line.hotelBookends')}</span>
|
||||
<MToggle
|
||||
checked={bookends.on}
|
||||
onChange={() => bookends.toggle?.()}
|
||||
ariaLabel={t('roadtrip.line.hotelBookends')}
|
||||
disabled={!bookends.toggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-geist text-[0.65625rem] leading-snug text-m-faint">
|
||||
{t('roadtrip.line.hotelBookendsHint')}
|
||||
</div>
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 font-geist text-[0.6875rem] leading-snug text-m-faint">
|
||||
{t('mobileTrip.rtDesktopNote')}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
@@ -7,9 +7,9 @@ import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPrefe
|
||||
import type { RoadtripPreferences } from '@trek/shared'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { render, screen } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-015
|
||||
// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-018
|
||||
//
|
||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||
// in English. Everything it shows comes out of the roadtrip preferences store,
|
||||
@@ -36,9 +36,13 @@ function seedPreferences(preferences: RoadtripPreferences): void {
|
||||
useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } })
|
||||
}
|
||||
|
||||
function renderSheet(preferences: RoadtripPreferences = FULL, shellOverrides: Record<string, unknown> = {}) {
|
||||
function renderSheet(
|
||||
preferences: RoadtripPreferences = FULL,
|
||||
shellOverrides: Record<string, unknown> = {},
|
||||
plannerOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
seedPreferences(preferences)
|
||||
const planner = buildPlanner({ tripId: TRIP_ID } as unknown as Partial<TripPlanner>)
|
||||
const planner = buildPlanner({ tripId: TRIP_ID, ...plannerOverrides } as unknown as Partial<TripPlanner>)
|
||||
const shell = buildShell({ sheet: { id: 'rtinfo' }, ...shellOverrides } as unknown as Partial<MTripShellApi>)
|
||||
render(<MRtInfoSheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
@@ -136,13 +140,16 @@ describe('MRtInfoSheet', () => {
|
||||
expect(limitValue('Avoid where possible')).toBe('off')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-012: carries no control of any kind, only the way out', () => {
|
||||
it('FE-MOB-RTINFO-012: carries no control but the stay switch and the way out', () => {
|
||||
renderSheet()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Driving figures' })
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
expect(dialog.querySelectorAll('input, select, textarea')).toHaveLength(0)
|
||||
for (const role of ['textbox', 'spinbutton', 'slider', 'switch', 'checkbox', 'combobox', 'radio'] as const) {
|
||||
// The figures stay a desktop matter; the one switch here is no figure.
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
expect(screen.getByRole('switch', { name: 'Start and end each day at your stay' })).toBeInTheDocument()
|
||||
for (const role of ['textbox', 'spinbutton', 'slider', 'checkbox', 'combobox', 'radio'] as const) {
|
||||
expect(screen.queryByRole(role)).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
@@ -165,4 +172,34 @@ describe('MRtInfoSheet', () => {
|
||||
expect(screen.getByRole('img', { name: '2000 km per tank' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-016: the stay switch reads off when unset and saves on through the desktop dialog’s write', () => {
|
||||
const saveRoadtripLimit = vi.fn()
|
||||
renderSheet(FULL, {}, { saveRoadtripLimit })
|
||||
const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
expect(toggle).toBeEnabled()
|
||||
expect(screen.getByText('After a booked night the day starts where you slept, and before one it ends there.')).toBeInTheDocument()
|
||||
fireEvent.click(toggle)
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_hotel_bookends', true)
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-017: switched on, it reads on and turns back off', () => {
|
||||
const saveRoadtripLimit = vi.fn()
|
||||
renderSheet({ ...FULL, roadtrip_hotel_bookends: true }, {}, { saveRoadtripLimit })
|
||||
const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_hotel_bookends', false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-018: a reader who may not edit days sees the switch, disabled', () => {
|
||||
// The planner hands out no save to a reader without day_edit, or before the trip's
|
||||
// settings are in, and the switch says so rather than flipping and springing back.
|
||||
renderSheet({ ...FULL, roadtrip_hotel_bookends: true }, {}, { saveRoadtripLimit: undefined })
|
||||
const toggle = screen.getByRole('switch', { name: 'Start and end each day at your stay' })
|
||||
expect(toggle).toBeDisabled()
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user