mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): a night booked into the next morning holds that morning
Every day was scheduled from nothing, so a booking that runs into the next day ended at midnight as far as the chain was concerned. Check in at 08:00 and out at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00 that morning, with no sign that the room was still yours. The day a check-out falls in now starts at the check-out rather than at nothing. A floor, never a move: a stop with a clock of its own keeps it and collects the late finding any leg it cannot make in time would give it, so a plan that says 07:00 still says 07:00 and merely admits it does not add up. A stop with no clock starts at the check-out instead of having no time at all. Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday alone, because you sleep there, you do not sit there. The departure chip takes the door-out mark instead of a clock face. Beside an arrival time, a second clock read as a second arrival.
This commit is contained in:
@@ -3,7 +3,7 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, LogOut,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
@@ -965,7 +965,10 @@ function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: Schedu
|
||||
if (stop.checkoutAt === undefined || !entry?.departure) return null
|
||||
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
|
||||
<span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted">
|
||||
<Clock size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
|
||||
{/* Leaving, not just a time of day: this chip is the one clock in the rail that
|
||||
says when you give the room back, and beside an arrival it read as a second
|
||||
arrival. The door-out mark is what a hotel desk calls check-out. */}
|
||||
<LogOut size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spurFor } from './accessSpur';
|
||||
import { pointAtMeters } from './corridor';
|
||||
import type { RoadtripDayBoundary } from './day-boundary.schema';
|
||||
import { planDayWindow, type DayWindow } from './dayWindow';
|
||||
import { spillChains } from './nightSpill';
|
||||
import { earliestFreeMinute, spillChains } from './nightSpill';
|
||||
import type {
|
||||
PlanDay,
|
||||
QuietDay,
|
||||
@@ -77,6 +77,9 @@ export function assembleRoadtrip({
|
||||
)
|
||||
: null;
|
||||
const automaticSchedule = !!timed && timed.issue === null;
|
||||
// Same floor the spill chains use: a night booked into the next morning holds the
|
||||
// traveller until check-out, so the day it lands in cannot start before that.
|
||||
const heldUntil = earliestFreeMinute([...plan, ...quietDays]);
|
||||
const displayChains = automaticSchedule
|
||||
? timed.chains
|
||||
: timed?.issue
|
||||
@@ -93,6 +96,7 @@ export function assembleRoadtrip({
|
||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||
})),
|
||||
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
|
||||
{ notBefore: heldUntil.get(d.dayNumber) ?? null },
|
||||
),
|
||||
}))
|
||||
: splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor);
|
||||
|
||||
@@ -276,4 +276,68 @@ describe('spillChains', () => {
|
||||
expect(chains[1]!.spills).toHaveLength(1);
|
||||
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
|
||||
});
|
||||
|
||||
describe('a night booked into the next morning holds that morning', () => {
|
||||
// Check in on day 1 at 08:00, check out on day 2 at 08:00. Until the room is
|
||||
// handed back the traveller is at the hotel, so day 2 cannot quietly start at
|
||||
// 07:00 somewhere else as if the night had ended at midnight.
|
||||
const hotel = () => stop({
|
||||
assignmentId: 1, name: 'Mercure Hamburg', checkInTime: '08:00',
|
||||
checkoutAt: 2 * 1440 + 480,
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-013: a stop pinned before check-out keeps its clock and is flagged late', () => {
|
||||
const plan = [
|
||||
day(1, 1, [hotel()]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Hotel Panorama', time: '07:00' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
|
||||
|
||||
// The hour the traveller chose is never moved.
|
||||
expect(dayTwo.schedule.entries[0]!.arrival).toBe('07:00');
|
||||
// But it cannot be reached: the room goes back an hour after it.
|
||||
expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'late', minutes: 60 });
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-014: a stop with no clock of its own starts at check-out, not at nothing', () => {
|
||||
const plan = [
|
||||
day(1, 1, [hotel()]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Bergedorf' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
|
||||
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBe('08:00');
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-015: the days between a longer stay are left alone', () => {
|
||||
// Monday to Thursday: you sleep there, you do not sit there. Only the day the
|
||||
// check-out falls in is held.
|
||||
const plan = [
|
||||
day(1, 1, [stop({ assignmentId: 1, name: 'Mercure Hamburg', checkInTime: '08:00', checkoutAt: 4 * 1440 + 480 })]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Speicherstadt' })]),
|
||||
day(4, 4, [stop({ assignmentId: 3, name: 'Bergedorf' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
|
||||
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
|
||||
expect(chains.find((c) => c.dayNumber === 4)!.schedule.entries[0]!.arrival).toBe('08:00');
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-016: a check-out on the same day it started binds nothing', () => {
|
||||
// A day room, or a stay whose end day is the day it started: the stops after it
|
||||
// already follow through the leg chain and need no floor of their own.
|
||||
const plan = [
|
||||
day(1, 1, [stop({ assignmentId: 1, name: 'Day room', checkInTime: '08:00', checkoutAt: 1 * 1440 + 480 })]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Bergedorf' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
|
||||
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,35 @@ interface Placed {
|
||||
from: RoadtripStop | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The minute each day is free to start on, for the days a booking still holds.
|
||||
*
|
||||
* A night booked into the next morning does not end at midnight: the room is given
|
||||
* back at check-out, and until then the traveller is at the hotel and not at the
|
||||
* first stop of the new day. Without this each day was scheduled from nothing, so a
|
||||
* stop pinned before check-out read as perfectly fine.
|
||||
*
|
||||
* Only the day the check-out falls in. A stay running Monday to Thursday leaves
|
||||
* Tuesday and Wednesday alone, which is the point: you sleep there, you do not sit
|
||||
* there. And the bound is a floor, never a move. A stop with a clock of its own
|
||||
* keeps it and collects the late warning any leg it cannot make would give it.
|
||||
*/
|
||||
export function earliestFreeMinute(days: PlanDay[]): Map<number, number> {
|
||||
const held = new Map<number, number>();
|
||||
for (const day of days) {
|
||||
for (const stop of day.stops) {
|
||||
if (stop.checkoutAt === undefined) continue;
|
||||
const checkoutDay = Math.floor(stop.checkoutAt / 1440);
|
||||
// A check-out on the stop's own day binds nothing: that is a day trip, and the
|
||||
// stops after it already follow through the leg chain.
|
||||
if (checkoutDay <= day.dayNumber) continue;
|
||||
const minute = stop.checkoutAt - checkoutDay * 1440;
|
||||
held.set(checkoutDay, Math.max(held.get(checkoutDay) ?? 0, minute));
|
||||
}
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
|
||||
const all: PlanDay[] = [
|
||||
...plan,
|
||||
@@ -66,6 +95,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
})),
|
||||
].sort((a, b) => a.dayNumber - b.dayNumber);
|
||||
const numbers = new Set(all.map((d) => d.dayNumber));
|
||||
const heldUntil = earliestFreeMinute(all);
|
||||
const landing = new Map<number, Placed[]>();
|
||||
for (const d of all) landing.set(d.dayNumber, []);
|
||||
|
||||
@@ -79,6 +109,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||
})),
|
||||
legs.map((l) => l?.duration),
|
||||
{ notBefore: heldUntil.get(d.dayNumber) ?? null },
|
||||
);
|
||||
|
||||
let day = 0;
|
||||
|
||||
@@ -154,13 +154,25 @@ function resolveArrival(
|
||||
};
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
/**
|
||||
* @param opts.notBefore Minute of this day before which the first stop cannot be
|
||||
* reached, because a booking still holds the traveller somewhere else: the night
|
||||
* before ran into this morning and its check-out is when the room is given back.
|
||||
* Behaves like the arrival of an imaginary stop just before the first, so a stop
|
||||
* pinned earlier keeps its clock and picks up the same late warning it would from
|
||||
* any other leg it cannot make in time.
|
||||
*/
|
||||
export function computeSchedule(
|
||||
stops: ScheduleStop[],
|
||||
legSeconds: (number | undefined)[],
|
||||
opts: { notBefore?: number | null } = {},
|
||||
): Schedule {
|
||||
const warnings: ScheduleWarning[] = [];
|
||||
|
||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
||||
const anchored: boolean[] = new Array(stops.length).fill(false);
|
||||
|
||||
let cursor: number | null = null;
|
||||
let cursor: number | null = opts.notBefore ?? null;
|
||||
let dayOffset = 0;
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
|
||||
Reference in New Issue
Block a user