diff --git a/client/src/utils/dayOrder.shared.test.ts b/client/src/utils/dayOrder.shared.test.ts new file mode 100644 index 000000000..8704637cf --- /dev/null +++ b/client/src/utils/dayOrder.shared.test.ts @@ -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) + }) +}) diff --git a/client/src/utils/dayOrder.ts b/client/src/utils/dayOrder.ts index 22cada00b..9c7fc7a1c 100644 --- a/client/src/utils/dayOrder.ts +++ b/client/src/utils/dayOrder.ts @@ -1,3 +1,4 @@ +import { getDayBookendHotels, getDayOrder, hotelIsTheStop, isDayInAccommodationRange } from '@trek/shared' import type { Day, Accommodation, RouteAnchors } from '../types' import { parseTimeToMinutes } from './dayMerge' import { haversineKm, withinDayTripRange } from './geo' @@ -12,8 +13,9 @@ import { haversineKm, withinDayTripRange } from './geo' */ export type CarrierEdge = 'departure' | 'arrival' | null -export const getDayOrder = (day: Day, days: Day[]): number => - day.day_number ?? days.indexOf(day) +// Which stay a day wakes up in and which one it sleeps in is read in @trek/shared, so every +// 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 } @@ -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 }) } -/** - * 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 // 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. @@ -183,22 +132,3 @@ export const shouldDrawEveningLeg = ( const stop = parseTimeToMinutes(lastStop.time) 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 -} diff --git a/shared/src/day/stay-bookends.spec.ts b/shared/src/day/stay-bookends.spec.ts new file mode 100644 index 000000000..77336c382 --- /dev/null +++ b/shared/src/day/stay-bookends.spec.ts @@ -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); + }); +}); diff --git a/shared/src/day/stay-bookends.ts b/shared/src/day/stay-bookends.ts new file mode 100644 index 000000000..330cb1994 --- /dev/null +++ b/shared/src/day/stay-bookends.ts @@ -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 { + 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 | 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 = ( + day: DayRef, + days: readonly DayRef[], + accommodations: readonly A[], +): DayBookendHotels => { + 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), + }; +}; diff --git a/shared/src/index.ts b/shared/src/index.ts index 06f12de60..e02483cd1 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -38,6 +38,7 @@ export * from './docsync/docsync.schema'; export * from './day/day.schema'; export * from './day/note-colors'; export * from './day/chrono-order'; +export * from './day/stay-bookends'; export * from './assignment/assignment.schema'; export * from './place/place.schema'; export * from './place/place-match';