fix(roadtrip): a booked night holds its check-in and heads its day

A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.

The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.

A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.

The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
This commit is contained in:
Maurice
2026-09-20 17:26:50 +02:00
parent 979a9283cd
commit cd769d9993
14 changed files with 307 additions and 136 deletions
@@ -164,9 +164,16 @@ export class AccommodationsService {
return this.mirrorStay(accommodationId, placeId, dayId, checkIn);
}
/** Carry a stay's own stop over to where the stay now is. */
moveStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
return this.remirrorStay(accommodationId, placeId, dayId, checkIn);
/** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the
* booking was given a new hour, which seats the night afresh (see remirrorStay). */
moveStayStop(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts);
}
/** Take back the stops of a stay that is being deleted elsewhere. */
@@ -277,17 +284,16 @@ export class AccommodationsService {
/**
* Where in the day the booked night belongs.
*
* Last, unless the booking names an hour. A night usually ends the day, which is why
* that is the default — but a check-in at eleven says the traveller is at the desk
* before whatever they pinned to the afternoon, and dropping the stop behind it built
* a drive that visits the hotel after the sixteen-hundred it was booked around.
*
* Only stops that carry a clock of their own are compared. One without a time takes
* its position from the chain, not from the hour, so pushing past it would move a
* stop the traveller placed deliberately.
* First. The hotel is where the day is based: the traveller checks in and the stops
* they placed without an hour follow from there. Only a stop with a clock of its
* own can stand ahead of it, and only when that clock is at or before the check-in:
* a stop pinned to eight with a check-in at ten puts the night second, one pinned
* to the afternoon does not. It used to go last unless something pinned later
* pulled it forward, and a night booked for ten in the morning sat behind a whole
* day of unpinned stops, reached at a quarter past twelve.
*/
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number | undefined {
if (!checkIn) return undefined;
private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number {
if (!checkIn) return 0;
// excludeId leaves the row being re-seated out of the chain it is measured
// against. Without it a night parked at the end of the day can find itself.
const rows = this.db.all<{ order_index: number; at: string | null }>(`
@@ -295,18 +301,20 @@ export class AccommodationsService {
FROM day_assignments da JOIN places p ON p.id = da.place_id
WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index
`, dayId, excludeId ?? -1);
const later = rows.find(row => row.at !== null && row.at > checkIn);
return later?.order_index;
let seat = 0;
for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1;
return seat;
}
/**
* Whether the night already sits where its check-in says it should.
* Whether the night sits somewhere its check-in allows, on an edit that did not
* touch the check-in.
*
* Read off the chain rather than recomputed, because the index a fresh insert would
* get is not the index this row already occupies. Two ways to be wrong: something
* with a later hour ahead of it, or something with an earlier one behind it. Stops
* without an hour are passed over in both directions — their place in the chain is
* the traveller's doing, not a clock's.
* without an hour are passed over in both directions: the night may have been
* dragged past them on purpose, and a change of notes is no reason to undo that.
*/
private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean {
if (!checkIn) return true;
@@ -493,7 +501,7 @@ export class AccommodationsService {
this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id);
const seat = this.positionForCheckIn(dayId, checkIn, stop.id);
if (seat !== undefined && seat < end) {
if (seat < end) {
this.db.run(
'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?',
dayId, seat, stop.id,
@@ -519,9 +527,8 @@ export class AccommodationsService {
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
const before = this.stopOrders([dayId]);
// Through AssignmentsService, so the stop lands at the end of the day with the
// order_index every other new assignment gets. Evening is where you arrive at a
// hotel, and the day planner has no drive to position it against anyway.
// Through AssignmentsService, seated where the check-in says (see
// positionForCheckIn), with everything behind it moved up one.
//
// The booking id goes in with the INSERT, not as an UPDATE afterwards: what this
// returns is the row the answer hands the client, and stamping the id on later
@@ -590,14 +597,24 @@ export class AccommodationsService {
*
* Runs inside the caller's transaction.
*/
private remirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
private remirrorStay(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
const own = this.ownStops(accommodationId);
if (own.length === 0) return noMirror();
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
// Same place, same day — but a check-in moved to a different hour can still put
// the night somewhere else in the chain, and a stop sitting after the afternoon
// it was booked around is the plan reading back wrong.
if (this.seatedByCheckIn(dayId, own[0].id, checkIn)) return noMirror();
// Same place, same day. A check-in given a new hour seats the night afresh, the
// way booking it with that hour would have; any other edit leaves a stop where
// it is unless the clocks around it say it is in the wrong place, so a night the
// traveller dragged somewhere stays there through a change of notes.
const settled = opts.checkInChanged
? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index
: this.seatedByCheckIn(dayId, own[0].id, checkIn);
if (settled) return noMirror();
}
const mirror = noMirror();
@@ -689,7 +706,9 @@ export class AccommodationsService {
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
);
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn);
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null),
});
});
// Sync check-in/out/confirmation to every linked reservation. The booking form
@@ -846,6 +846,7 @@ export class ReservationsService {
if (start_day_id && end_day_id) {
this.requireResolvableStay(create_accommodation);
if (resolvedAccId) {
const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId);
this.db.run(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?',
accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId
@@ -853,7 +854,9 @@ export class ReservationsService {
// The stay just moved. Its stop moves with it, or it is left sitting on a
// day nobody sleeps there any more, hidden from the day list because it
// still carries this booking's id and stranded in the middle of the drive.
stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in);
stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, {
checkInChanged: (check_in || null) !== (prior?.check_in ?? null),
});
} else if (accPlaceId) {
const accResult = this.db.run(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
@@ -22,6 +22,7 @@ import {
type RoutedLeg,
type SnappedWaypoint,
type RouteAvoidClass,
standsAsDay,
} from '@trek/shared/roadtrip';
interface StoredDay {
@@ -32,6 +33,8 @@ interface StoredDay {
default_transport_mode: string | null;
}
interface VisitRow {
/** The booking this stop stands for on its check-in day, when there is one. */
stay_id: number | null;
check_in: string | null;
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
check_out: string | null;
@@ -75,7 +78,7 @@ export class RoadtripPlanService {
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
@@ -123,6 +126,7 @@ export class RoadtripPlanService {
// planner makes in the browser (useRoadtripRoutes).
leaveAt: v.end_time,
checkInTime: v.check_in,
night: v.stay_id !== null,
dwellMinutes: v.duration_minutes,
endDay: v.end_day === 1,
legMode: v.leg_transport_mode,
@@ -245,8 +249,8 @@ export class RoadtripPlanService {
preferences.roadtrip_range_km,
) || null;
const calculated = assembleRoadtrip({
plan: plan.filter((d) => d.stops.length > 1),
quietDays: plan.filter((d) => d.stops.length < 2),
plan: plan.filter((d) => standsAsDay(d.stops)),
quietDays: plan.filter((d) => !standsAsDay(d.stops)),
window,
allLegs,
snapByDay,