mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): a hotel visited again on its check-in day is the night only at its last visit
A stop counted as the booked night whenever a stay began at its place on its day, so a hotel on its check-in day twice (the booking's own stop and a rest the traveller planned there) was read as two nights. Both were held to the check-in: the rest waited for the room or was set back to it, and with a check-in far from its arrival the rest and every stop behind it moved onto the next day's card. oneNightPerStay in shared keeps the night and its check-in on the last visit of the place and turns the earlier ones into ordinary stops, still a hotel. The browser and calculate_roadtrip both run it, the stored ride slot times only that row by the check-in, and opening an earlier visit in road trip mode opens the stop rather than the night. Refs #2636
This commit is contained in:
@@ -1405,6 +1405,34 @@ describe('a booked night at both ends of its days', () => {
|
||||
expect(runs).toContainEqual(['driving', [HOTEL[0], P[4][0], P[5][0], HOTEL[0]]])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-065: a hotel visited again on its check-in day is the night only at its last visit (#2636)', async () => {
|
||||
switchTo(false)
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
// A rest at the hotel at noon, a place, and back to the hotel for the night.
|
||||
const assignments = map(1, [
|
||||
{ id: 1, at: P[0], time: '09:00', dwell: 0 },
|
||||
{ id: 2, at: HOTEL, dwell: 60 },
|
||||
{ id: 3, at: P[1], dwell: 30 },
|
||||
{ id: 4, at: HOTEL },
|
||||
]) as AssignmentsMap
|
||||
Object.assign(assignments['1'][3], { place_id: 20 })
|
||||
const stays = [stayOf(5, 2, 1, 2, HOTEL, { check_in: '15:00' })]
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], stays))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
const [card] = result.current.days
|
||||
expect(card.stops.map(s => [s.name, s.night, s.checkInTime])).toEqual([
|
||||
['Stop 1', false, null],
|
||||
['Stop 2', false, null],
|
||||
['Stop 3', false, null],
|
||||
['Stop 4', true, '15:00'],
|
||||
])
|
||||
// The rest is reached when the drive gets there, the night waits for the room.
|
||||
expect(card.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '10:00', '12:00', '15:00'])
|
||||
expect(card.schedule.warnings).toEqual([])
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-064: the stop tonight’s booking put first on its day gives way to the evening at the stay (#2546)', async () => {
|
||||
const { days } = cam()
|
||||
const stays = [stayOf(1, 1, 1, 2, GETAWAY), stayOf(2, 4, 2, 3, WALLINGA)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
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 { assembleRoadtrip, bookendStaysOf, carrierLegsFor, carrierSeam, foldRouteRun, hotelBookendsOn, isCarrierMode, isStationaryJoin, mergeRouteSegments, oneNightPerStay, 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'
|
||||
@@ -319,8 +319,9 @@ export function useRoadtripRoutes(
|
||||
.filter((x): x is { assignment: Assignment; stop: RoadtripStop } => x.stop !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
// counting before the filter would name a row the rail does not draw.
|
||||
const stops = located.map((x, i) => ({ ...x.stop, ownerIndex: i }))
|
||||
// counting before the filter would name a row the rail does not draw. A hotel
|
||||
// visited twice on its check-in day is the night at its last visit only.
|
||||
const stops = oneNightPerStay(located.map((x, i) => ({ ...x.stop, ownerIndex: i })))
|
||||
return {
|
||||
dayId: d.id,
|
||||
dayNumber: d.day_number ?? 0,
|
||||
|
||||
@@ -362,6 +362,22 @@ describe('usePlaceFormOpeners', () => {
|
||||
expect(result.current.form.editingPlace?.id).toBe(6)
|
||||
})
|
||||
|
||||
it('FE-TP-OPENERS-014b: a hotel visited twice on its check-in day opens as the night only at its last visit (#2636)', () => {
|
||||
const tripAccommodations = [stay({ id: 70, place_id: 3, start_day_id: 14, end_day_id: 12, check_in: '15:00', check_out: '10:00' })]
|
||||
const twice: AssignmentsMap = {
|
||||
...assignments,
|
||||
14: [buildAssignment({ id: 141, place: hotel, order_index: 0 }), buildAssignment({ id: 142, place: hotel, order_index: 2 })],
|
||||
}
|
||||
const { result } = renderOpeners({ roadtripActive: true, tripAccommodations, assignments: twice })
|
||||
|
||||
act(() => { result.current.openPlaceEditor(hotel, 141) })
|
||||
expect(result.current.stopDraft).toMatchObject({ dayId: 14, position: 0, editing: { accommodationId: undefined, checkIn: '' } })
|
||||
expect(result.current.stopDraft?.overnight).toBeUndefined()
|
||||
|
||||
act(() => { result.current.openPlaceEditor(hotel, 142) })
|
||||
expect(result.current.stopDraft).toMatchObject({ dayId: 14, position: 2, editing: { accommodationId: 70, checkIn: '15:00' }, overnight: { defaultEndDayId: 12 } })
|
||||
})
|
||||
|
||||
it('FE-TP-OPENERS-015: an ordinary place opens the form on its one visit, a tour on the phone selects it instead', () => {
|
||||
const { result } = renderOpeners()
|
||||
|
||||
|
||||
@@ -295,7 +295,9 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
if (entry) {
|
||||
const dayId = Number(entry[0])
|
||||
const visit = entry[1].find(visit => visit.id === visitId)!
|
||||
const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId)
|
||||
// The night is the hotel's last visit of the day; an earlier one opens as a stop.
|
||||
const lastVisit = entry[1].filter(other => other.place_id === place.id).every(other => (other.order_index ?? 0) <= (visit.order_index ?? 0))
|
||||
const stay = lastVisit ? tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId) : undefined
|
||||
const category = place.stop_type ?? (stay ? 'hotel' : '')
|
||||
const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId))
|
||||
const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null
|
||||
|
||||
@@ -413,6 +413,15 @@ describe('storedRideSlot', () => {
|
||||
expect(storedRideSlot(ferry(), [amsterdam, hotel], [{ ...checkIn[0], start_day_id: 4 }], 5)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('times only the last visit of the hotel by its check-in, an earlier one is a stop (#2636)', () => {
|
||||
// Checked in at six, after the 17:30 sailing: the night is behind the crossing, and so
|
||||
// is the rest at the same hotel before it. Read as a second check-in it timed the rest
|
||||
// at six too, and the slot went behind both, so the drive reached them overland.
|
||||
const checkIn = [{ place_id: 30, start_day_id: 5, check_in: '18:00' }]
|
||||
const night = { ...hotel, id: 4, order_index: 2 }
|
||||
expect(storedRideSlot(ferry(), [amsterdam, hotel, night], checkIn, 5)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('opens the day, closes it, or sits behind a timed row, measured on order indexes', () => {
|
||||
expect(storedRideSlot(ferry(), [row(2, 3, 54.9783, -1.6178)], [], 5)).toBe(2.5)
|
||||
expect(storedRideSlot(ferry(), [amsterdam], [], 5)).toBe(0.5)
|
||||
|
||||
@@ -375,8 +375,9 @@ interface StoredStop {
|
||||
* number among their order indexes all the same, and worked out over the rows the list
|
||||
* shows it could land behind a hidden stop on the far shore, which the drive then went to
|
||||
* overland before the crossing (#2461). Each row counts with its start, else the check-in
|
||||
* of the stay that begins there that day (`stayStartingOn`), and with its coordinates; a
|
||||
* row without them is no stop of the drive. Where the rule keeps the clock's seat, the
|
||||
* of the stay that begins there that day (`stayStartingOn`) on the place's last row of the
|
||||
* day, and with its coordinates; a row without them is no stop of the drive. Where the
|
||||
* rule keeps the clock's seat, the
|
||||
* clock is read over the same rows: behind the last one timed at or before the departure,
|
||||
* else at the end of the day.
|
||||
*/
|
||||
@@ -388,11 +389,16 @@ export function storedRideSlot(
|
||||
): number | null {
|
||||
const ride = sameDayRide(r)
|
||||
if (!ride || !day.length) return null
|
||||
// The night is the last visit of its hotel on the day (`oneNightPerStay`), so only that
|
||||
// row is timed by the check-in; an earlier visit of the same place is a stop like any other.
|
||||
const lastOfPlace = new Map<number | null | undefined, number>()
|
||||
for (const row of day) lastOfPlace.set(row.place_id, Math.max(lastOfPlace.get(row.place_id) ?? -Infinity, row.order_index))
|
||||
const items: SeatItem[] = day.flatMap(row => {
|
||||
const lat = row.place?.lat
|
||||
const lng = row.place?.lng
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) return []
|
||||
const start = row.place?.place_time ?? stayStartingOn(stays, row.place_id, dayId)?.check_in
|
||||
const night = lastOfPlace.get(row.place_id) === row.order_index
|
||||
const start = row.place?.place_time ?? (night ? stayStartingOn(stays, row.place_id, dayId)?.check_in : null)
|
||||
return [{ key: row.order_index, minutes: parseTimeToMinutes(start), point: { lat, lng } }]
|
||||
})
|
||||
const keys = day.map(row => row.order_index)
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
foldRouteRun,
|
||||
hotelBookendsOn,
|
||||
isStationaryJoin,
|
||||
oneNightPerStay,
|
||||
splitIntoRuns,
|
||||
spillChains,
|
||||
dayWindow,
|
||||
@@ -147,28 +148,31 @@ export class RoadtripPlanService {
|
||||
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
|
||||
const stored: PlanDay[] = context.days.map((day) => {
|
||||
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
|
||||
const stops = visits.map((v, index): RoadtripStop => ({
|
||||
assignmentId: v.id,
|
||||
ownerDayId: day.id,
|
||||
ownerIndex: index,
|
||||
placeId: v.place_id,
|
||||
name: v.name,
|
||||
lat: v.lat!,
|
||||
lng: v.lng!,
|
||||
time: v.time,
|
||||
// The visit's end time is when the drive leaves it, the same reading the
|
||||
// planner makes in the browser (useRoadtripRoutes).
|
||||
leaveAt: v.end_time,
|
||||
checkInTime: v.check_in,
|
||||
night: v.stay_id !== null,
|
||||
bookedNightId: v.accommodation_id,
|
||||
dwellMinutes: v.duration_minutes,
|
||||
endDay: v.end_day === 1,
|
||||
legMode: v.leg_transport_mode,
|
||||
incomingLegMode: v.incoming_leg_transport_mode,
|
||||
stopType: v.stop_type,
|
||||
fillPercent: v.fill_percent,
|
||||
}));
|
||||
// One night per hotel, the last visit of it, the way the browser reads the day.
|
||||
const stops = oneNightPerStay(
|
||||
visits.map((v, index): RoadtripStop => ({
|
||||
assignmentId: v.id,
|
||||
ownerDayId: day.id,
|
||||
ownerIndex: index,
|
||||
placeId: v.place_id,
|
||||
name: v.name,
|
||||
lat: v.lat!,
|
||||
lng: v.lng!,
|
||||
time: v.time,
|
||||
// The visit's end time is when the drive leaves it, the same reading the
|
||||
// planner makes in the browser (useRoadtripRoutes).
|
||||
leaveAt: v.end_time,
|
||||
checkInTime: v.check_in,
|
||||
night: v.stay_id !== null,
|
||||
bookedNightId: v.accommodation_id,
|
||||
dwellMinutes: v.duration_minutes,
|
||||
endDay: v.end_day === 1,
|
||||
legMode: v.leg_transport_mode,
|
||||
incomingLegMode: v.incoming_leg_transport_mode,
|
||||
stopType: v.stop_type,
|
||||
fillPercent: v.fill_percent,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number,
|
||||
|
||||
@@ -48,7 +48,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'calculate_roadtrip',
|
||||
description:
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. The departure terminal is pinned a check-in ahead of the timetable departure (an hour for a flight); reaching it after that is a late warning with the full minutes, however many, and the ride keeps its own day, so the drive after the arrival terminal goes on from the timetable arrival. On a day a ride leaves and lands on, the stop of a stay checked into that day comes after the arrival terminal when the stay lies nearer to it than to the departure terminal: a check-in is the earliest arrival, not a time that seats the stay ahead of the ride, and it pins the stay only when the ride lands before it. A time set on the stop itself keeps its seat. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut, and checkIn on the evening a stay begins when it has a check-in time}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out and check-in are labels, never a departure or arrival time. A day between two nights in the same stay with no visit has no drive. Nor does a day with nothing on it but the check-in of the next stay when a flight, train, ferry, cruise or bus is booked on that day, with or without located terminals: the booking is the move between the two stays. A stay is never joined by road to a terminal more than 2000 km away in a straight line. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. The departure terminal is pinned a check-in ahead of the timetable departure (an hour for a flight); reaching it after that is a late warning with the full minutes, however many, and the ride keeps its own day, so the drive after the arrival terminal goes on from the timetable arrival. On a day a ride leaves and lands on, the stop of a stay checked into that day comes after the arrival terminal when the stay lies nearer to it than to the departure terminal: a check-in is the earliest arrival, not a time that seats the stay ahead of the ride, and it pins the stay only when the ride lands before it. A time set on the stop itself keeps its seat. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking that is on none of the days of the trip (saved without a date, or on a day the trip no longer has) is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date within the trip with the reservation tools, after which the road ends at its terminal. With roadtrip_hotel_bookends switched on (missing means off), a day after a booked night starts at that stay and a day before one ends there. Such stops carry `bookend` {phase, accommodationId, reservationId, checkingOut, checkingIn, checkOut, and checkIn on the evening a stay begins when it has a check-in time}, stopType hotel, the real placeId of the stay and a synthetic negative assignmentId that belongs to no assignment: they cannot be reordered, edited or given via points, and `failures` may name them. The check-out and check-in are labels, never a departure or arrival time. A day between two nights in the same stay with no visit has no drive. Nor does a day with nothing on it but the check-in of the next stay when a flight, train, ferry, cruise or bus is booked on that day, with or without located terminals: the booking is the move between the two stays. A stay is never joined by road to a terminal more than 2000 km away in a straight line. Pass settings {roadtrip_hotel_bookends:true} to preview the drive from and to the stays without saving it. A hotel visited more than once on the check-in day of its stay is the night (night true, checkInTime set) only at its last visit; the earlier visits are ordinary stops. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
inputSchema: roadtripPlanRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -244,6 +244,38 @@ describe('a booked night on the road trip (#2410)', () => {
|
||||
expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true });
|
||||
});
|
||||
|
||||
it('holds only the last visit of the hotel on its check-in day to the check-in, an earlier one is a stop (#2636)', async () => {
|
||||
const { user, trip, plans } = await setup();
|
||||
const arrival = createDay(db, trip.id);
|
||||
const start = createPlace(db, trip.id, { name: 'Wismar', lat: 53.9, lng: 11.46 });
|
||||
const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 });
|
||||
const beach = createPlace(db, trip.id, { name: 'Warnemuende', lat: 54.17, lng: 12.08 });
|
||||
await setPlace(start.id, { duration_minutes: 30 });
|
||||
await setPlace(hotel.id, { duration_minutes: 30 });
|
||||
await setPlace(beach.id, { duration_minutes: 30 });
|
||||
const first = createDayAssignment(db, arrival.id, start.id);
|
||||
createDayAssignment(db, arrival.id, hotel.id);
|
||||
createDayAssignment(db, arrival.id, beach.id);
|
||||
createDayAssignment(db, arrival.id, hotel.id);
|
||||
await setVisit(first.id, { assignment_time: '09:00' });
|
||||
const departure = createDay(db, trip.id);
|
||||
createDayAccommodation(db, trip.id, hotel.id, arrival.id, departure.id, { check_in: '15:00' });
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
const [day] = calculated.days.filter((d) => d.dayId === arrival.id);
|
||||
expect(day.stops.map((s) => [s.name, s.night, s.checkInTime])).toEqual([
|
||||
['Wismar', false, null],
|
||||
['Rostock', false, null],
|
||||
['Warnemuende', false, null],
|
||||
['Rostock', true, '15:00'],
|
||||
]);
|
||||
// The rest at the hotel is reached at half past ten, not held for the room, and nothing is late.
|
||||
expect(day.schedule.entries.map((e) => e.arrival)).toEqual(['09:00', '10:30', '12:00', '15:00']);
|
||||
expect(day.schedule.entries[3]).toMatchObject({ anchored: true });
|
||||
expect(day.schedule.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('files a lone stop that is no night under the quiet days', async () => {
|
||||
const { user, trip, plans } = await setup();
|
||||
const quiet = createDay(db, trip.id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ROADTRIP-ASSEMBLE-001..022: what the assembler does with the model's warnings, and
|
||||
* ROADTRIP-ASSEMBLE-001..023: what the assembler does with the model's warnings, and
|
||||
* what it tells a surface about the drive between connected days. From 017 on: a booked
|
||||
* night seated at both ends of its days (`seatNightBookends`) is one spot overnight,
|
||||
* driven to and from like any stop and never between.
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import { assembleRoadtrip } from './assemble';
|
||||
import type { DayWindow } from './dayWindow';
|
||||
import { seatNightBookends } from './nightBookends';
|
||||
import { oneNightPerStay, seatNightBookends } from './nightBookends';
|
||||
import type { PlanDay, RoadtripStop, RoutedLeg, SnappedWaypoint } from './planning-types';
|
||||
import { standsAsDay } from './roadtripModel';
|
||||
|
||||
@@ -386,6 +386,38 @@ describe('assembleRoadtrip booked nights', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleRoadtrip a hotel visited twice on its check-in day (#2636)', () => {
|
||||
// 90 km at the 40 seconds a kilometre `leg` drives is an hour.
|
||||
const hour = 90;
|
||||
const hotel = { placeId: 900, lat: 45, lng: 7, night: true, stopType: 'hotel' };
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-023: the rest at noon is a stop, and the day stays on its card', () => {
|
||||
// A late check-in and a rest at the hotel before it. Both visits read as the night held
|
||||
// the rest to ten at night, and the places behind it went onto the next day's card.
|
||||
const stops = oneNightPerStay([
|
||||
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
|
||||
stop({ ownerIndex: 1, ...hotel, checkInTime: '22:00', dwellMinutes: 60 }),
|
||||
stop({ ownerIndex: 2, dwellMinutes: 30 }),
|
||||
stop({ ownerIndex: 3, ...hotel, checkInTime: '22:00' }),
|
||||
]);
|
||||
|
||||
const routes = assemble(stops, [hour, hour, hour]);
|
||||
|
||||
expect(routes.days).toHaveLength(1);
|
||||
const day = routes.days[0]!;
|
||||
expect(day.stops.map((s) => [s.ownerIndex, s.night, s.checkInTime])).toEqual([
|
||||
[0, undefined, undefined],
|
||||
[1, false, null],
|
||||
[2, undefined, undefined],
|
||||
[3, true, '22:00'],
|
||||
]);
|
||||
expect(day.schedule.entries.map((e) => e.arrival)).toEqual(['09:00', '10:00', '12:00', '22:00']);
|
||||
expect(day.schedule.entries[1]!.anchored).toBeFalsy();
|
||||
expect(day.schedule.entries[3]!.anchored).toBe(true);
|
||||
expect(day.schedule.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleRoadtrip nights at a booked hotel', () => {
|
||||
// Three nights in one hotel from day 1, places on days 1 and 2, the check-out on day 3
|
||||
// with nothing of its own.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* that a night spent at one hotel is a leg going nowhere. From 029 on: a day moved by a
|
||||
* ride the road does not see, a terminal out of a drive's reach, and the flight day of
|
||||
* the report read from the stored day to its nights. From 033 on: the stop a booking put
|
||||
* on its check-in day gives way to the evening at that stay.
|
||||
* on its check-in day gives way to the evening at that stay. From 036 on: a hotel visited
|
||||
* twice on its check-in day is the night at its last visit only (#2636).
|
||||
*/
|
||||
import { assembleRoadtrip } from './assemble';
|
||||
import { carrierLegsFor, carrierSeam, seatCarrierStops, terminalAssignmentId, type CarrierBooking } from './carriers';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
bookendStaysOf,
|
||||
hotelBookendsOn,
|
||||
isStationaryJoin,
|
||||
oneNightPerStay,
|
||||
seatNightBookends,
|
||||
withStationaryJoins,
|
||||
type BookendStay,
|
||||
@@ -815,3 +817,40 @@ describe('the stop a booking put on its check-in day (#2546)', () => {
|
||||
expect(shape(other[0])).toEqual(['morning:1', 'B', 'P', 'evening:2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hotel visited twice on its check-in day (#2636)', () => {
|
||||
const b = stay(2, D2, D3, WALLINGA);
|
||||
const nights = (day: PlanDay | undefined) =>
|
||||
(day?.stops ?? []).map((s) => [s.bookend ? s.bookend.phase : s.name, s.night ?? false, s.checkInTime ?? null]);
|
||||
|
||||
it('ROADTRIP-BOOKENDS-036: the last visit is the night, an earlier one a stop without the check-in', () => {
|
||||
const rest = stayStop(D2, 0, WALLINGA, { name: 'Rest', placeId: 502, checkInTime: '15:00' });
|
||||
const place = visit(D2, 1, { name: 'P' });
|
||||
const night = stayStop(D2, 2, WALLINGA, { name: 'Night', placeId: 502, checkInTime: '15:00', bookedNightId: 2 });
|
||||
expect(nights(planDay(D2, oneNightPerStay([rest, place, night])))).toEqual([
|
||||
['Rest', false, null],
|
||||
['P', false, null],
|
||||
['Night', true, '15:00'],
|
||||
]);
|
||||
// Still a hotel: only the night and its check-in move.
|
||||
expect(oneNightPerStay([rest, place, night])[0]).toMatchObject({ stopType: 'hotel', placeId: 502 });
|
||||
// One visit per hotel, two hotels on one day, or no hotel: the day comes back as it was.
|
||||
const other = stayStop(D2, 3, GETAWAY, { name: 'Other', placeId: 501, checkInTime: '18:00' });
|
||||
for (const stops of [[place, night], [night, other], [place]]) expect(oneNightPerStay(stops)).toBe(stops);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-037: with the days at the stay, the rest stays a stop and the booked night ends the day', () => {
|
||||
const stops = oneNightPerStay([
|
||||
stayStop(D2, 0, WALLINGA, { name: 'Rest', placeId: 502, checkInTime: '15:00' }),
|
||||
visit(D2, 1, { name: 'P' }),
|
||||
stayStop(D2, 2, WALLINGA, { name: 'B', placeId: 502, checkInTime: '15:00', bookedNightId: 2 }),
|
||||
]);
|
||||
const [day] = seatNightBookends([planDay(D2, stops)], days, [stay(1, D1, D2, GETAWAY), b]);
|
||||
expect(nights(day)).toEqual([
|
||||
['morning', false, null],
|
||||
['Rest', false, null],
|
||||
['P', false, null],
|
||||
['B', true, '15:00'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,33 @@ export function bookendStaysOf<S extends Omit<BookendStay, 'reservation_id'>>(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored day's stops with one night per hotel: the last visit of a place a stay begins
|
||||
* at on this day (#2636).
|
||||
*
|
||||
* A stop is a night when a stay begins at its place on its day, and a hotel can be on its
|
||||
* check-in day twice: the stop the booking put there and the traveller's own visit, back
|
||||
* for a rest at noon. Both were read as the night and both held to the check-in, so the
|
||||
* rest waited for the room or was set back to it, and a check-in twelve hours off its
|
||||
* arrival moved it and every stop behind it onto the next day's card. The night ends the
|
||||
* day, so the last visit is the night; the ones before it are stops like any other, a
|
||||
* hotel all the same, without the check-in.
|
||||
*
|
||||
* Read over the stops in their stored order, before a terminal or a bookend is seated
|
||||
* among them, the same in the browser and on the server. A day with one visit per hotel
|
||||
* comes back as it was.
|
||||
*/
|
||||
export function oneNightPerStay(stops: RoadtripStop[]): RoadtripStop[] {
|
||||
const last = new Map<number, number>();
|
||||
stops.forEach((stop, i) => {
|
||||
if (stop.night) last.set(stop.placeId, i);
|
||||
});
|
||||
if (last.size === stops.filter((stop) => stop.night).length) return stops;
|
||||
return stops.map((stop, i) =>
|
||||
stop.night && last.get(stop.placeId) !== i ? { ...stop, night: false, checkInTime: null } : stop,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bookend as a stop. `legMode` is the mode of the drive that leaves it: the morning's
|
||||
* drive to the first stop goes the way that stop is reached from the hotel
|
||||
|
||||
Reference in New Issue
Block a user