refactor(planner): the night's hotels are read in shared

Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
This commit is contained in:
Maurice
2026-09-23 13:32:53 +02:00
parent 3a67a8e279
commit 7fa0434570
5 changed files with 288 additions and 74 deletions
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import {
getDayBookendHotels as sharedBookendHotels,
getDayOrder as sharedDayOrder,
isDayInAccommodationRange as sharedInRange,
} from '@trek/shared'
import { getDayBookendHotels, getDayOrder, isDayInAccommodationRange } from './dayOrder'
describe('the night hotels Days reads', () => {
it('FE-UTIL-DAYORDER-001: are the rule in @trek/shared itself, not a copy of it', () => {
expect(getDayOrder).toBe(sharedDayOrder)
expect(isDayInAccommodationRange).toBe(sharedInRange)
expect(getDayBookendHotels).toBe(sharedBookendHotels)
})
})
+4 -74
View File
@@ -1,3 +1,4 @@
import { getDayBookendHotels, getDayOrder, hotelIsTheStop, isDayInAccommodationRange } from '@trek/shared'
import type { Day, Accommodation, RouteAnchors } from '../types' import type { Day, Accommodation, RouteAnchors } from '../types'
import { parseTimeToMinutes } from './dayMerge' import { parseTimeToMinutes } from './dayMerge'
import { haversineKm, withinDayTripRange } from './geo' import { haversineKm, withinDayTripRange } from './geo'
@@ -12,8 +13,9 @@ import { haversineKm, withinDayTripRange } from './geo'
*/ */
export type CarrierEdge = 'departure' | 'arrival' | null export type CarrierEdge = 'departure' | 'arrival' | null
export const getDayOrder = (day: Day, days: Day[]): number => // Which stay a day wakes up in and which one it sleeps in is read in @trek/shared, so every
day.day_number ?? days.indexOf(day) // reader of a day's nights makes the same choice. Days keeps importing it from here.
export { getDayBookendHotels, getDayOrder, isDayInAccommodationRange }
type EdgeStop = { isPlace: boolean; time?: string | null; carrierEdge?: CarrierEdge; lat?: number; lng?: number } type EdgeStop = { isPlace: boolean; time?: string | null; carrierEdge?: CarrierEdge; lat?: number; lng?: number }
@@ -32,59 +34,6 @@ const noTimeLoopHolds = (hotel: Accommodation, stop?: EdgeStop, dayHasCarrier?:
return withinDayTripRange({ lat: hotel.place_lat, lng: hotel.place_lng }, { lat: stop.lat, lng: stop.lng }) return withinDayTripRange({ lat: hotel.place_lat, lng: hotel.place_lng }, { lat: stop.lat, lng: stop.lng })
} }
/**
* The hotel and the day's edge waypoint are one and the same spot.
*
* Booking a night also puts its hotel on the check-in day as a stop, so that day's
* last waypoint IS the hotel far more often than not, and the bookend leg drawn to
* it would be a zero-kilometre round trip: in the drawn line, in the sidebar's leg
* list and in every exported directions link, which would carry the hotel twice in
* a row. Compared by coordinates, because the bookend comes off the stay row and
* the waypoint off the assignment, and the same tie holds for two places pinned at
* the same spot.
*/
const hotelIsTheStop = (hotel: Accommodation | undefined, stop?: EdgeStop): boolean =>
!!hotel && hotel.place_lat != null && hotel.place_lng != null
&& stop?.lat != null && stop.lng != null
&& hotel.place_lat === stop.lat && hotel.place_lng === stop.lng
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
// not end exactly on the transfer day.
export const getDayBookendHotels = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
)
if (inRange.length === 0) return {}
const dayOrd = getDayOrder(day, days)
const orderOf = (id: number) => {
const d = days.find(x => x.id === id)
return d ? getDayOrder(d, days) : dayOrd
}
const checkIn = inRange.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
const sleptHere = inRange.find(a => orderOf(a.start_day_id) < dayOrd) // the hotel you woke up in
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
}
}
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home // Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel // base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
// and into another — instead runs from the morning hotel to the evening one. // and into another — instead runs from the morning hotel to the evening one.
@@ -183,22 +132,3 @@ export const shouldDrawEveningLeg = (
const stop = parseTimeToMinutes(lastStop.time) const stop = parseTimeToMinutes(lastStop.time)
return stop != null && stop <= checkOut return stop != null && stop <= checkOut
} }
export const isDayInAccommodationRange = (
day: Day,
startDayId: number,
endDayId: number,
days: Day[],
): boolean => {
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
if (!startDay || !endDay) {
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
// both endpoints are present in a fully-loaded trip's days list.
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
}
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
}
+150
View File
@@ -0,0 +1,150 @@
import { getDayBookendHotels, getDayOrder, hotelIsTheStop, isDayInAccommodationRange } from './stay-bookends';
import { describe, expect, it } from 'vitest';
const d1 = { id: 10, day_number: 1 };
const d2 = { id: 20, day_number: 2 };
const d3 = { id: 30, day_number: 3 };
const d4 = { id: 40, day_number: 4 };
const days = [d1, d2, d3, d4];
const stay = (id: number, start: number, end: number, at: [number, number] | null = [48.1, 11.5]) => ({
id,
name: `Stay ${id}`,
start_day_id: start,
end_day_id: end,
place_lat: at ? at[0] : null,
place_lng: at ? at[1] : null,
});
describe('the order of a day', () => {
it('STAY-BOOKENDS-001: a day is ordered by its day number, and by its place in the list without one', () => {
expect(getDayOrder(d2, days)).toBe(2);
// The number wins over the position in the list.
expect(getDayOrder(d2, [d4, d3, d2, d1])).toBe(2);
const first = { id: 5 };
const second = { id: 6, day_number: null };
expect(getDayOrder(first, [first, second])).toBe(0);
expect(getDayOrder(second, [first, second])).toBe(1);
});
});
describe('the days a stay spans', () => {
it('STAY-BOOKENDS-002: a stay spans its check-in and check-out day by day order, and by id when its days are not loaded', () => {
expect(isDayInAccommodationRange(d1, 10, 30, days)).toBe(true);
expect(isDayInAccommodationRange(d2, 10, 30, days)).toBe(true);
expect(isDayInAccommodationRange(d3, 10, 30, days)).toBe(true);
expect(isDayInAccommodationRange(d4, 10, 30, days)).toBe(false);
expect(isDayInAccommodationRange(d1, 20, 30, days)).toBe(false);
// Given the wrong way round, the range still holds.
expect(isDayInAccommodationRange(d3, 40, 20, days)).toBe(true);
// The ids of a reordered trip say nothing: the day with id 3 is day 2, between the
// stay's check-in on day 1 (id 7) and its check-out on day 3 (id 5).
const shuffled = [
{ id: 7, day_number: 1 },
{ id: 3, day_number: 2 },
{ id: 5, day_number: 3 },
];
expect(isDayInAccommodationRange({ id: 3, day_number: 2 }, 7, 5, shuffled)).toBe(true);
// Without the endpoint days the ids are all there is.
expect(isDayInAccommodationRange({ id: 25 }, 20, 30, [])).toBe(true);
expect(isDayInAccommodationRange({ id: 35 }, 20, 30, [d2])).toBe(false);
});
});
describe('the hotels at both ends of a day', () => {
it('STAY-BOOKENDS-003: a transfer day wakes up in the stay it leaves and sleeps in the one it checks into', () => {
const left = stay(1, 10, 20, [1, 1]);
const next = stay(2, 20, 40, [9, 9]);
const hotels = getDayBookendHotels(d2, days, [next, left]);
// The caller's own rows come back, not copies.
expect(hotels.morning).toBe(left);
expect(hotels.evening).toBe(next);
expect(hotels.morningIsSleptHere).toBe(true);
expect(hotels.eveningIsOvernight).toBe(true);
// Two check-ins on one day: the first in the list is tonight's.
const other = stay(3, 20, 30, [5, 5]);
expect(getDayBookendHotels(d2, days, [left, other, next]).evening).toBe(other);
expect(getDayBookendHotels(d2, days, [left, next, other]).evening).toBe(next);
});
it('STAY-BOOKENDS-004: a stay of several nights opens on its check-in day, holds the days between and closes on its check-out day', () => {
const home = stay(1, 10, 30);
expect(getDayBookendHotels(d1, days, [home])).toEqual({
morning: home,
evening: home,
morningIsSleptHere: false,
eveningIsOvernight: true,
});
expect(getDayBookendHotels(d2, days, [home])).toEqual({
morning: home,
evening: home,
morningIsSleptHere: true,
eveningIsOvernight: true,
});
expect(getDayBookendHotels(d3, days, [home])).toEqual({
morning: home,
evening: home,
morningIsSleptHere: true,
eveningIsOvernight: false,
});
expect(getDayBookendHotels(d4, days, [home])).toEqual({});
// A long stay still owns the morning of a day another stay checks in on (#887).
const long = stay(2, 10, 40, [1, 1]);
const late = stay(3, 20, 40, [9, 9]);
const transfer = getDayBookendHotels(d2, days, [late, long]);
expect(transfer.morning).toBe(long);
expect(transfer.evening).toBe(late);
// A stay that starts and ends on one day reads as that evening's check-in; the flag
// only says the stay is booked from today, not that a night follows.
const dayUse = stay(4, 20, 20);
expect(getDayBookendHotels(d2, days, [dayUse])).toEqual({
morning: dayUse,
evening: dayUse,
morningIsSleptHere: false,
eveningIsOvernight: true,
});
// With its check-in day not loaded, a stay still holds the day by its ids, but it
// makes no claim about a night on either side.
expect(getDayBookendHotels(d2, [d2, d3], [home])).toEqual({
morning: home,
evening: home,
morningIsSleptHere: false,
eveningIsOvernight: false,
});
});
it('STAY-BOOKENDS-005: a stay without coordinates is left out, and the other stay answers on its own', () => {
const unplaced = stay(1, 10, 20, null);
const next = stay(2, 20, 30, [9, 9]);
expect(getDayBookendHotels(d2, days, [unplaced, next])).toEqual({
morning: next,
evening: next,
morningIsSleptHere: false,
eveningIsOvernight: true,
});
const halfPlaced = { ...stay(3, 10, 30), place_lng: null };
expect(getDayBookendHotels(d2, days, [unplaced, halfPlaced])).toEqual({});
expect(getDayBookendHotels(d2, days, [])).toEqual({});
});
});
describe('the hotel is the edge stop itself', () => {
it('STAY-BOOKENDS-006: a stop is the hotel only when both carry the same coordinates', () => {
const hotel = { place_lat: 48.1, place_lng: 11.5 };
expect(hotelIsTheStop(hotel, { lat: 48.1, lng: 11.5 })).toBe(true);
expect(hotelIsTheStop(hotel, { lat: 48.1, lng: 11.6 })).toBe(false);
expect(hotelIsTheStop(hotel, { lat: null, lng: 11.5 })).toBe(false);
expect(hotelIsTheStop(hotel, { lat: 48.1 })).toBe(false);
expect(hotelIsTheStop(hotel)).toBe(false);
expect(hotelIsTheStop({ place_lat: null, place_lng: null }, { lat: 48.1, lng: 11.5 })).toBe(false);
expect(hotelIsTheStop({ place_lat: 48.1, place_lng: null }, { lat: 48.1, lng: 11.5 })).toBe(false);
expect(hotelIsTheStop(undefined, { lat: 48.1, lng: 11.5 })).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Which stay a day wakes up in and which one it goes to sleep in.
*
* The rule came from the client's Days view, where it decides the hotel legs of the
* day route, and moved here unchanged so that every reader of a day's nights makes the
* same choice. The types name only the fields the rule reads, so a client row, a
* server row and a test fixture all fit, and a caller gets its own stay objects back.
*/
/** The fields of a day its place in the trip is read from. */
export interface DayRef {
id: number;
day_number?: number | null;
}
/** The fields of a stay the choice reads: the days it spans and where it is. */
export interface StayRange {
start_day_id: number;
end_day_id: number;
place_lat?: number | null;
place_lng?: number | null;
}
/** A point on the day, such as its first or last stop, held against a stay. */
export interface StayEdgePoint {
lat?: number | null;
lng?: number | null;
}
/** The stay a day wakes up in and the one it sleeps in, with where each choice came from. */
export interface DayBookendHotels<A> {
morning?: A;
evening?: A;
morningIsSleptHere?: boolean;
eveningIsOvernight?: boolean;
}
/** A day's place in the trip: its day number, or its position in the list without one. */
export const getDayOrder = (day: DayRef, days: readonly DayRef[]): number => day.day_number ?? days.indexOf(day);
export const isDayInAccommodationRange = (
day: DayRef,
startDayId: number,
endDayId: number,
days: readonly DayRef[],
): boolean => {
const startDay = days.find((d) => d.id === startDayId);
const endDay = days.find((d) => d.id === endDayId);
if (!startDay || !endDay) {
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
// Fall back to numeric ID range. Acceptable since non-monotonic IDs only arise when
// both endpoints are present in a fully-loaded trip's days list.
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId);
}
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days));
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days));
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi;
};
/**
* The hotel and the day's edge waypoint are one and the same spot.
*
* Booking a night also puts its hotel on the check-in day as a stop, so that day's
* last waypoint IS the hotel far more often than not, and the bookend leg drawn to
* it would be a zero-kilometre round trip: in the drawn line, in the sidebar's leg
* list and in every exported directions link, which would carry the hotel twice in
* a row. Compared by coordinates, because the bookend comes off the stay row and
* the waypoint off the assignment, and the same tie holds for two places pinned at
* the same spot.
*/
export const hotelIsTheStop = (
hotel: Pick<StayRange, 'place_lat' | 'place_lng'> | undefined,
stop?: StayEdgePoint,
): boolean =>
!!hotel &&
hotel.place_lat != null &&
hotel.place_lng != null &&
stop?.lat != null &&
stop.lng != null &&
hotel.place_lat === stop.lat &&
hotel.place_lng === stop.lng;
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
// not end exactly on the transfer day.
export const getDayBookendHotels = <A extends StayRange>(
day: DayRef,
days: readonly DayRef[],
accommodations: readonly A[],
): DayBookendHotels<A> => {
const inRange = accommodations.filter(
(a) =>
a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
);
if (inRange.length === 0) return {};
const dayOrd = getDayOrder(day, days);
const orderOf = (id: number) => {
const d = days.find((x) => x.id === id);
return d ? getDayOrder(d, days) : dayOrd;
};
const checkIn = inRange.find((a) => a.start_day_id === day.id); // the hotel you arrive at tonight
const sleptHere = inRange.find((a) => orderOf(a.start_day_id) < dayOrd); // the hotel you woke up in
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
};
};
+1
View File
@@ -38,6 +38,7 @@ export * from './docsync/docsync.schema';
export * from './day/day.schema'; export * from './day/day.schema';
export * from './day/note-colors'; export * from './day/note-colors';
export * from './day/chrono-order'; export * from './day/chrono-order';
export * from './day/stay-bookends';
export * from './assignment/assignment.schema'; export * from './assignment/assignment.schema';
export * from './place/place.schema'; export * from './place/place.schema';
export * from './place/place-match'; export * from './place/place-match';