mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -294,3 +294,47 @@ describe('assembleRoadtrip leave times', () => {
|
||||
expect(schedule.warnings).toContainEqual({ index: 2, code: 'late', minutes: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleRoadtrip booked nights', () => {
|
||||
// 90 km at the 40 seconds a kilometre `leg` drives is an hour.
|
||||
const hour = 90;
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-012: a day that is only its booked night stands among the days', () => {
|
||||
// The arrival day of a trip: nothing but the hotel, entered under Days with its
|
||||
// check-in. It used to be filed under the quiet days at the foot of the rail, which
|
||||
// reads as the hotel missing from the road trip altogether.
|
||||
const routes = assemble([stop({ ownerIndex: 0, night: true, checkInTime: '15:00', stopType: 'hotel' })], []);
|
||||
|
||||
expect(routes.quietDays).toEqual([]);
|
||||
expect(routes.days).toHaveLength(1);
|
||||
const day = routes.days[0]!;
|
||||
expect(day.legs).toEqual([]);
|
||||
expect(day.distance).toBe(0);
|
||||
expect(day.schedule.entries[0]!).toMatchObject({ arrival: '15:00', anchored: true });
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-013: a lone stop that is no night stays a quiet day', () => {
|
||||
const routes = assemble([stop({ ownerIndex: 0 })], []);
|
||||
|
||||
expect(routes.days).toEqual([]);
|
||||
expect(routes.quietDays).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-014: the check-in holds the night and the day lines up behind it', () => {
|
||||
// The day from Discord: two stops without an hour and the night booked for ten,
|
||||
// which the drive used to reach at a quarter past twelve. Seated first by the
|
||||
// server now, and held at ten by the schedule; the stops follow from there.
|
||||
const stops = [
|
||||
stop({ ownerIndex: 0, night: true, checkInTime: '10:00', dwellMinutes: 60, stopType: 'hotel' }),
|
||||
stop({ ownerIndex: 1, dwellMinutes: 30 }),
|
||||
stop({ ownerIndex: 2, dwellMinutes: 60 }),
|
||||
];
|
||||
|
||||
const routes = assemble(stops, [hour, hour]);
|
||||
|
||||
const { schedule } = routes.days[0]!;
|
||||
expect(schedule.entries.map((e) => e.arrival)).toEqual(['10:00', '12:00', '13:30']);
|
||||
expect(schedule.entries[0]!.anchored).toBe(true);
|
||||
expect(schedule.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
refuelsRange,
|
||||
isServiceStopType,
|
||||
scheduleStopOf,
|
||||
standsAsDay,
|
||||
type DryPoint,
|
||||
type DriveLimits,
|
||||
type VehicleKind,
|
||||
@@ -278,7 +279,7 @@ export function assembleRoadtrip({
|
||||
dayWarning: drive.day,
|
||||
});
|
||||
}
|
||||
const drives = out.filter((d) => d.stops.length > 1 || !!d.spills?.length || d.stops.some((s) => s.automaticNight));
|
||||
const drives = out.filter((d) => standsAsDay(d.stops) || !!d.spills?.length || d.stops.some((s) => s.automaticNight));
|
||||
const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops);
|
||||
const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => {
|
||||
const to = originalStops[position + 1]!;
|
||||
@@ -317,7 +318,7 @@ export function assembleRoadtrip({
|
||||
),
|
||||
|
||||
quietDays: out
|
||||
.filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight))
|
||||
.filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight))
|
||||
.map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
loading,
|
||||
};
|
||||
|
||||
@@ -394,32 +394,51 @@ describe('daily travel window', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for a check-in the drive overshoots rather than rushing it', () => {
|
||||
it('holds a check-in the drive reaches within the minute a rounded leg can add', () => {
|
||||
const stops = [
|
||||
stop(1, { time: '07:00', dwellMinutes: 60 }),
|
||||
stop(2, { checkInTime: '09:11' }),
|
||||
stop(3),
|
||||
];
|
||||
const plan = calculate(stops, [72.5, 29]);
|
||||
const plan = calculate(stops, [71.5, 29]);
|
||||
expect(plan.issue).toBeNull();
|
||||
// Arrives two minutes past the hour it may check in from, and is left there rather
|
||||
// than pulled back to the round number.
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13');
|
||||
// Half a minute over is the rounding of the drive, not being late: the night keeps
|
||||
// its check-in, the same grace a pinned stop gets.
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:11');
|
||||
const stops0 = plan.chains[0]!.stops;
|
||||
expect(stops0.at(-1)!.placeId).toBe(3);
|
||||
// And carries straight on from there: nothing holds the traveller at the stop.
|
||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42');
|
||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:40');
|
||||
});
|
||||
|
||||
it('waits for check-in without treating it as a fixed appointment', () => {
|
||||
it('does not rush a check-in the drive overshoots, and does not quietly move it either', () => {
|
||||
// Two minutes past the check-in is past it. A pinned time out of reach is a
|
||||
// conflict here, and a check-in is one of those now.
|
||||
const stops = [
|
||||
stop(1, { time: '07:00', dwellMinutes: 60 }),
|
||||
stop(2, { checkInTime: '09:11' }),
|
||||
stop(3),
|
||||
];
|
||||
expect(calculate(stops, [72.5, 29]).issue).toBe('conflict');
|
||||
});
|
||||
|
||||
it('waits for a check-in the drive reaches early', () => {
|
||||
const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]);
|
||||
expect(plan.issue).toBeNull();
|
||||
// The check-in is a door opening, not an appointment: the drive waits for it and then
|
||||
// carries straight on, rather than the day being rebuilt around it.
|
||||
// The night is held to its check-in the way a pinned stop is held to its time: the
|
||||
// drive waits for it and carries on from there.
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.anchored).toBe(true);
|
||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
|
||||
});
|
||||
|
||||
it('treats a check-in the drive cannot make as the conflict a missed pin is', () => {
|
||||
// A night booked for ten with two hours of driving pinned ahead of it. The window
|
||||
// planner does not invent a plan around a time it cannot keep; it hands back to the
|
||||
// plain schedule, which keeps the ten and says how late the drive is.
|
||||
const plan = calculate([stop(1, { time: '09:00' }), stop(2, { checkInTime: '10:00' })], [120]);
|
||||
expect(plan.issue).toBe('conflict');
|
||||
});
|
||||
|
||||
describe('a time set to leave a stop, with daily travel times', () => {
|
||||
it('spends the hours until then rather than the stay', () => {
|
||||
const plan = calculate(
|
||||
|
||||
@@ -207,7 +207,10 @@ export function planDayWindow(
|
||||
let previous: RoadtripStop | undefined;
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const { stop, day } = stops[i]!;
|
||||
const pin = parseClock(stop.time);
|
||||
// A booked night's check-in pins the stop the way its own time would: the day is
|
||||
// built to be there by then, and a drive that cannot make it is the same conflict
|
||||
// a pinned stop out of reach is.
|
||||
const pin = parseClock(stop.time) ?? parseClock(stop.checkInTime);
|
||||
const leave = parseClock(stop.leaveAt);
|
||||
// When the drive into this stop set out, on this day's clock: for one a night broke
|
||||
// up, the morning it went on.
|
||||
@@ -290,7 +293,6 @@ export function planDayWindow(
|
||||
const target = targets.get(number);
|
||||
if (target !== undefined && target < position) return failed('conflict');
|
||||
if (pin !== null) clock = pin;
|
||||
if (pin === null && number === day.dayNumber) clock = Math.max(clock, parseClock(stop.checkInTime) ?? 0);
|
||||
append(stop, clock);
|
||||
previous = stop;
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface RoadtripStop {
|
||||
|
||||
dwellMinutes: number | null;
|
||||
checkInTime?: string | null;
|
||||
/** A booked night's stop on its check-in day, whether or not the booking names an hour. */
|
||||
night?: boolean;
|
||||
endDay?: boolean;
|
||||
|
||||
legMode: string | null;
|
||||
|
||||
@@ -1064,7 +1064,6 @@ describe('scheduleStopOf', () => {
|
||||
it('turns a leave time into a departure', () => {
|
||||
expect(scheduleStopOf({ time: '10:00', checkInTime: null, dwellMinutes: 60, leaveAt: '14:00' })).toEqual({
|
||||
anchor: '10:00',
|
||||
earliest: null,
|
||||
dwellMinutes: 60,
|
||||
departureAt: 840,
|
||||
});
|
||||
@@ -1073,11 +1072,21 @@ describe('scheduleStopOf', () => {
|
||||
it('leaves the departure out when there is no usable leave time', () => {
|
||||
expect(scheduleStopOf({ time: null, dwellMinutes: 30 })).toEqual({
|
||||
anchor: null,
|
||||
earliest: null,
|
||||
dwellMinutes: 30,
|
||||
});
|
||||
expect(scheduleStopOf({ time: null, dwellMinutes: 30, leaveAt: 'soon' })).not.toHaveProperty('departureAt');
|
||||
});
|
||||
|
||||
it('anchors a booked night on its check-in', () => {
|
||||
expect(scheduleStopOf({ time: null, checkInTime: '10:00', dwellMinutes: 60 })).toEqual({
|
||||
anchor: '10:00',
|
||||
dwellMinutes: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a time pinned on the stop itself win over the check-in', () => {
|
||||
expect(scheduleStopOf({ time: '16:00', checkInTime: '15:00', dwellMinutes: 60 }).anchor).toBe('16:00');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1104,19 +1113,19 @@ describe('hasChosenArrival', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a check-in is a door opening, not an appointment', () => {
|
||||
// The hour a room becomes available. Reaching it later is reaching it; only a time
|
||||
// somebody pinned to a stop can be missed. Read as an anchor it did the opposite:
|
||||
// arriving at 16:14 was reported as 5 h 14 late against an 11:00 check-in, and the
|
||||
// hotel was pushed onto the next day because 11:00 reads as earlier than the 15:00
|
||||
// before it.
|
||||
describe('a check-in holds a booked night the way a pinned time does', () => {
|
||||
// The hour the traveller said they are at the hotel. It used to be a floor, waited
|
||||
// for when the drive got there first and silent otherwise, and once anything before
|
||||
// it set the clock it held nothing: a night booked for ten in the morning was
|
||||
// reported reached at a quarter past twelve, with the rest of the day lined up
|
||||
// behind that. The check-in goes in as the anchor now (see scheduleStopOf), so
|
||||
// these run on the anchor the way the rail does.
|
||||
it('waits for it when the drive gets there first', () => {
|
||||
const schedule = computeSchedule(
|
||||
[{ anchor: '09:00', dwellMinutes: 0 }, { anchor: null, earliest: '15:00', dwellMinutes: 0 }],
|
||||
[{ anchor: '09:00', dwellMinutes: 0 }, { anchor: '15:00', dwellMinutes: 0 }],
|
||||
[3600],
|
||||
);
|
||||
expect(schedule.entries[1]!.arrival).toBe('15:00');
|
||||
// Waiting for a door is waiting for a time somebody named, so it is printed as one.
|
||||
expect(schedule.entries[1]!.anchored).toBe(true);
|
||||
expect(schedule.warnings).toEqual([]);
|
||||
});
|
||||
@@ -1126,7 +1135,7 @@ describe('a check-in is a door opening, not an appointment', () => {
|
||||
// Read only as a floor, the chain worked the hotel backwards out of the afternoon
|
||||
// and put it at 13:46: true enough as arithmetic, and nothing anybody asked for.
|
||||
const schedule = computeSchedule(
|
||||
[{ anchor: null, earliest: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }],
|
||||
[{ anchor: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }],
|
||||
[14 * 60],
|
||||
);
|
||||
expect(schedule.entries[0]!.arrival).toBe('10:00');
|
||||
@@ -1135,18 +1144,21 @@ describe('a check-in is a door opening, not an appointment', () => {
|
||||
expect(schedule.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('is simply arrived at when the drive gets there later', () => {
|
||||
it('keeps its hour and says how late the drive is when it gets there later', () => {
|
||||
// Two stops, then the hotel booked for ten: the drive reaches it at 12:17. The
|
||||
// night stays at ten and the day is lined up behind that, and the finding says
|
||||
// by how much the drive misses it, the way it would for any pinned stop.
|
||||
const schedule = computeSchedule(
|
||||
[{ anchor: '15:00', dwellMinutes: 60 }, { anchor: null, earliest: '11:00', dwellMinutes: 60 }],
|
||||
[14 * 60],
|
||||
[{ anchor: '08:00', dwellMinutes: 30 }, { anchor: null, dwellMinutes: 60 }, { anchor: '10:00', dwellMinutes: 60 }],
|
||||
[89 * 60, 78 * 60],
|
||||
);
|
||||
expect(schedule.entries[1]!.arrival).toBe('16:14');
|
||||
// Nothing was decided here, so it reads as computed.
|
||||
expect(schedule.entries[1]!.anchored).toBe(false);
|
||||
// And nothing was missed.
|
||||
expect(schedule.warnings).toEqual([]);
|
||||
expect(schedule.entries[1]!.arrival).toBe('09:59');
|
||||
expect(schedule.entries[2]!.arrival).toBe('10:00');
|
||||
expect(schedule.entries[2]!.anchored).toBe(true);
|
||||
expect(schedule.entries[2]!.departure).toBe('11:00');
|
||||
expect(schedule.warnings).toEqual([{ index: 2, code: 'late', minutes: 137 }]);
|
||||
// Still the same day, which is what the day split reads.
|
||||
expect(schedule.entries[1]!.dayOffset).toBe(0);
|
||||
expect(schedule.entries[2]!.dayOffset).toBe(0);
|
||||
});
|
||||
|
||||
it('still reports a pinned time that cannot be made', () => {
|
||||
|
||||
@@ -42,12 +42,12 @@ export function formatDurationShort(seconds: number): string {
|
||||
* Whether this stop's arrival is a time somebody chose, rather than one the chain
|
||||
* worked out from the stop before it.
|
||||
*
|
||||
* A check-in counts. Every schedule already anchors on `time ?? checkInTime`, so a
|
||||
* booked night starts its day exactly like a pinned stop does; asking only about
|
||||
* `time` here made the rail print that chosen hour in the grey it reserves for
|
||||
* computed ones. One function because the answer was spelled out separately in each
|
||||
* of the three schedulers, and two of them spelled it differently from the anchor
|
||||
* they had just used.
|
||||
* A check-in counts. Every schedule anchors on `time ?? checkInTime`, so a booked
|
||||
* night holds its hour exactly like a pinned stop does; asking only about `time`
|
||||
* here made the rail print that chosen hour in the grey it reserves for computed
|
||||
* ones. One function because the answer was spelled out separately in each of the
|
||||
* three schedulers, and two of them spelled it differently from the anchor they had
|
||||
* just used.
|
||||
*/
|
||||
export function hasChosenArrival(stop: { time?: string | null; checkInTime?: string | null; automaticNight?: unknown }): boolean {
|
||||
if (stop.automaticNight) return false;
|
||||
@@ -83,16 +83,9 @@ export interface ScheduleStop {
|
||||
*/
|
||||
departureAt?: number;
|
||||
/** A time somebody fixed this stop to. The chain restarts from it, and arriving
|
||||
* after it is being late. */
|
||||
* after it is being late. A booked night's check-in is one of these. */
|
||||
anchor: string | null;
|
||||
|
||||
/**
|
||||
* The earliest this stop can be entered, when something says so — a check-in is
|
||||
* the hour a room becomes available, not an appointment. Arriving before it means
|
||||
* waiting for it; arriving after it means arriving, with nothing to report.
|
||||
*/
|
||||
earliest?: string | null;
|
||||
|
||||
dwellMinutes: number | null;
|
||||
}
|
||||
|
||||
@@ -222,6 +215,13 @@ export function leaveAfter(
|
||||
/**
|
||||
* The part of a road trip stop the schedule reads.
|
||||
*
|
||||
* A booked night's check-in is the hour the traveller said they are at the hotel, so
|
||||
* it holds the stop the way a pinned time does: the day is built around it, and a
|
||||
* drive that gets there later is late. Read as a mere floor it held nothing once
|
||||
* anything before it set the clock, and a night booked for ten in the morning was
|
||||
* reported reached at a quarter past twelve, with the rest of the day lined up
|
||||
* behind that. A time pinned on the stop itself still wins.
|
||||
*
|
||||
* A visit's end time is when the drive leaves it. It is the traveller's own statement
|
||||
* about this visit, unlike the check-out that used to feed `departureAt` (the LATEST a
|
||||
* room has to be handed back, which is why #2357 took it out of the drive).
|
||||
@@ -231,8 +231,7 @@ export function scheduleStopOf(
|
||||
): ScheduleStop {
|
||||
const leave = parseClock(stop.leaveAt);
|
||||
return {
|
||||
anchor: stop.time ?? null,
|
||||
earliest: stop.checkInTime ?? null,
|
||||
anchor: stop.time ?? stop.checkInTime ?? null,
|
||||
dwellMinutes: stop.dwellMinutes,
|
||||
...(leave === null ? {} : { departureAt: leave }),
|
||||
};
|
||||
@@ -267,22 +266,7 @@ export function computeSchedule(
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]!;
|
||||
const anchor = parseClock(stop.anchor);
|
||||
const resolved = resolveArrival(anchor, cursor, dayOffset);
|
||||
// A door that opens at eleven is not an appointment at eleven. Reaching the stop
|
||||
// later than that is simply reaching it; only a time somebody pinned can be missed.
|
||||
const opens = parseClock(stop.earliest ?? null);
|
||||
// With nothing before it deciding the hour, the door IS the hour: a night booked
|
||||
// to check in at ten starts the day at ten rather than being worked backwards out
|
||||
// of whatever comes after it. Reached later, it is only a floor, and a floor below
|
||||
// the arrival changes nothing.
|
||||
const waited = anchor === null && opens !== null
|
||||
? resolved.arrival === null
|
||||
? opens + dayOffset * DAY_MINUTES
|
||||
: opens + Math.round((resolved.arrival - opens) / DAY_MINUTES) * DAY_MINUTES
|
||||
: null;
|
||||
const held = waited !== null && (resolved.arrival === null || waited > resolved.arrival);
|
||||
const arrival = held ? waited : resolved.arrival;
|
||||
const lateBy = resolved.lateBy;
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset);
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy });
|
||||
|
||||
if (arrival === null) {
|
||||
@@ -302,9 +286,7 @@ export function computeSchedule(
|
||||
if (offset > dayOffset) dayOffset = offset;
|
||||
|
||||
arrivals[i]! = arrival;
|
||||
// Ink for a time somebody decided: a pinned one always, a check-in only where the
|
||||
// drive actually had to wait for it.
|
||||
anchored[i]! = anchor !== null || held;
|
||||
anchored[i]! = anchor !== null;
|
||||
|
||||
let departure = arrival + (stop.dwellMinutes ?? 0);
|
||||
if (stop.departureAt !== undefined) {
|
||||
@@ -385,6 +367,20 @@ export function computeSchedule(
|
||||
return { entries, warnings, endsAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the rail lists this day among the days, rather than as a quiet placeholder
|
||||
* asking for stops.
|
||||
*
|
||||
* Two stops make a drive. A booked night makes a day on its own: the day you arrive
|
||||
* somewhere and go no further, or the one you check in at ten and set out from. It
|
||||
* used to need company, and a hotel entered under Days for the arrival day was filed
|
||||
* at the bottom of the rail under "only X so far", which reads as the hotel missing
|
||||
* from the road trip altogether.
|
||||
*/
|
||||
export function standsAsDay(stops: readonly { night?: boolean }[]): boolean {
|
||||
return stops.length > 1 || stops.some((stop) => stop.night === true);
|
||||
}
|
||||
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
const runs: { stops: T[]; mode: string }[] = [];
|
||||
let current: T[] = [];
|
||||
|
||||
Reference in New Issue
Block a user