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 React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
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,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
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
|
if (stop.checkoutAt === undefined || !entry?.departure) return null
|
||||||
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
|
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">
|
<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>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { spurFor } from './accessSpur';
|
|||||||
import { pointAtMeters } from './corridor';
|
import { pointAtMeters } from './corridor';
|
||||||
import type { RoadtripDayBoundary } from './day-boundary.schema';
|
import type { RoadtripDayBoundary } from './day-boundary.schema';
|
||||||
import { planDayWindow, type DayWindow } from './dayWindow';
|
import { planDayWindow, type DayWindow } from './dayWindow';
|
||||||
import { spillChains } from './nightSpill';
|
import { earliestFreeMinute, spillChains } from './nightSpill';
|
||||||
import type {
|
import type {
|
||||||
PlanDay,
|
PlanDay,
|
||||||
QuietDay,
|
QuietDay,
|
||||||
@@ -77,6 +77,9 @@ export function assembleRoadtrip({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const automaticSchedule = !!timed && timed.issue === 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
|
const displayChains = automaticSchedule
|
||||||
? timed.chains
|
? timed.chains
|
||||||
: timed?.issue
|
: timed?.issue
|
||||||
@@ -93,6 +96,7 @@ export function assembleRoadtrip({
|
|||||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
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),
|
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);
|
: splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor);
|
||||||
|
|||||||
@@ -276,4 +276,68 @@ describe('spillChains', () => {
|
|||||||
expect(chains[1]!.spills).toHaveLength(1);
|
expect(chains[1]!.spills).toHaveLength(1);
|
||||||
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 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;
|
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[] {
|
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
|
||||||
const all: PlanDay[] = [
|
const all: PlanDay[] = [
|
||||||
...plan,
|
...plan,
|
||||||
@@ -66,6 +95,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
|||||||
})),
|
})),
|
||||||
].sort((a, b) => a.dayNumber - b.dayNumber);
|
].sort((a, b) => a.dayNumber - b.dayNumber);
|
||||||
const numbers = new Set(all.map((d) => d.dayNumber));
|
const numbers = new Set(all.map((d) => d.dayNumber));
|
||||||
|
const heldUntil = earliestFreeMinute(all);
|
||||||
const landing = new Map<number, Placed[]>();
|
const landing = new Map<number, Placed[]>();
|
||||||
for (const d of all) landing.set(d.dayNumber, []);
|
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,
|
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||||
})),
|
})),
|
||||||
legs.map((l) => l?.duration),
|
legs.map((l) => l?.duration),
|
||||||
|
{ notBefore: heldUntil.get(d.dayNumber) ?? null },
|
||||||
);
|
);
|
||||||
|
|
||||||
let day = 0;
|
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 warnings: ScheduleWarning[] = [];
|
||||||
|
|
||||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
||||||
const anchored: boolean[] = new Array(stops.length).fill(false);
|
const anchored: boolean[] = new Array(stops.length).fill(false);
|
||||||
|
|
||||||
let cursor: number | null = null;
|
let cursor: number | null = opts.notBefore ?? null;
|
||||||
let dayOffset = 0;
|
let dayOffset = 0;
|
||||||
|
|
||||||
for (let i = 0; i < stops.length; i++) {
|
for (let i = 0; i < stops.length; i++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user