feat(roadtrip): leave a stop at its End and show the stay that makes

A visit's End could be edited and was shown in road trip mode, but the
schedule never read it: the drive always left a stop after its stay, so
a traveller who knew when they had to leave but not how long they would
stand there had no way to say so.

The End is now the time the drive leaves. RoadtripStop.leaveAt is
filled from the visit's End (or its place's) on the client by
useRoadtripRoutes and on the server by roadtrip-plan.service, so
calculate_roadtrip and get_roadtrip_context agree with the planner, and
computeSchedule takes it as the departure it already knew how to hold.
With Start and End both set the stay is the time between them. With
daily travel times on, the stay is the time until then, and an End
before the window start starts the day early the way a Start does.
Before the first fixed time the chain works backwards through an End as
it does through a pinned Start.

An End is read against the drive: the last passing of that clock time
counts as missed when the drive went past it on the road or earlier
the same day, anything else is the next one, so a drive across
midnight neither waits a whole day nor pretends to be on time. A drive
that gets there after the End leaves on arrival and files a
missedLeave finding, shown beside the late badge in the rail and on the
phone.

The rail's STAY badge, the inspector's Stay tile, the phone stop sheet
and the phone chain show the stay the End makes, with an "until 14:00"
hint. It is read off the schedule and never written to
duration_minutes: the stay belongs to the place, the End to one visit,
and a stored copy would go stale as soon as the arrival moved. For such
a stop the stay dialog, desktop and phone, shows that stay read only
and offers to remove the End, through the visit's time route with an
offline path (assignmentRepo.setTimes, setAssignmentTimes). While the
road trip is on, the End field in both place forms says the drive
leaves at that time.

The dead checkoutAt in the plan service is gone. New strings in all 23
locales.
This commit is contained in:
Maurice
2026-09-19 19:24:52 +02:00
parent cc1c16ec34
commit 154c88b48a
67 changed files with 2445 additions and 155 deletions
+6
View File
@@ -239,8 +239,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'مدة التوقف هنا',
'roadtrip.stop.pinned': 'الوقت الذي حددته',
'roadtrip.stop.computed': 'محسوب من زمن القيادة',
'roadtrip.stop.endIsLeave': 'في الرحلة البرية، تنطلق الرحلة في هذا الوقت.',
'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة',
'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
'roadtrip.warn.missedLeave': 'الوصول بعد {minutes} دقيقة من وقت المغادرة المحدد',
'roadtrip.warn.overnight': 'اليوم التالي',
'roadtrip.spill.title': 'من اليوم {number}',
'roadtrip.spill.departs': 'المغادرة {time}',
@@ -250,6 +252,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'دقائق',
'roadtrip.stay.arrive': 'الوصول',
'roadtrip.stay.leave': 'المغادرة',
'roadtrip.stay.until': 'حتى {time}',
'roadtrip.stay.leavesAt': 'لهذه الزيارة وقت انتهاء، لذلك تنطلق الرحلة في {time}.',
'roadtrip.stay.dayEndsFirst': 'ينتهي يوم السفر قبل {time}، لذلك تتابع الرحلة صباح اليوم التالي.',
'roadtrip.stay.clearLeave': 'إزالة وقت الانتهاء',
'roadtrip.alt.noMotorway': 'بدون طريق سريع',
'roadtrip.alt.noToll': 'بدون رسوم',
'roadtrip.alt.noFerry': 'بدون عبّارة',
+6
View File
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Tempo nesta parada',
'roadtrip.stop.pinned': 'Horário definido por você',
'roadtrip.stop.computed': 'Calculado a partir do trajeto',
'roadtrip.stop.endIsLeave': 'Na viagem de carro, a partida é neste horário.',
'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover',
'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
'roadtrip.warn.missedLeave': 'Chega {minutes} min depois do horário de partida definido',
'roadtrip.warn.overnight': 'Dia seguinte',
'roadtrip.spill.title': 'Do dia {number}',
'roadtrip.spill.departs': 'saída {time}',
@@ -255,6 +257,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minutos',
'roadtrip.stay.arrive': 'Chegada',
'roadtrip.stay.leave': 'Partida',
'roadtrip.stay.until': 'até {time}',
'roadtrip.stay.leavesAt': 'Esta visita tem um horário de término, então a viagem segue às {time}.',
'roadtrip.stay.dayEndsFirst': 'O dia de viagem termina antes das {time}, então a viagem segue na manhã seguinte.',
'roadtrip.stay.clearLeave': 'Remover horário de término',
'roadtrip.alt.noMotorway': 'Sem rodovia',
'roadtrip.alt.noToll': 'Sem pedagio',
'roadtrip.alt.noFerry': 'Sem balsa',
+7
View File
@@ -246,8 +246,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Temps en aquesta parada',
'roadtrip.stop.pinned': 'Hora que has fixat',
'roadtrip.stop.computed': 'Calculat a partir del trajecte',
'roadtrip.stop.endIsLeave': 'En el viatge per carretera, se surt a aquesta hora.',
'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar',
'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
'roadtrip.warn.missedLeave': 'Arriba {minutes} min després de l’hora de sortida fixada',
'roadtrip.warn.overnight': 'Dia següent',
'roadtrip.spill.title': 'Del dia {number}',
'roadtrip.spill.departs': 'surt {time}',
@@ -257,6 +259,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuts',
'roadtrip.stay.arrive': 'Arribada',
'roadtrip.stay.leave': 'Sortida',
'roadtrip.stay.until': 'fins a les {time}',
'roadtrip.stay.leavesAt': 'Aquesta visita té una hora de fi, així que el viatge surt a les {time}.',
'roadtrip.stay.dayEndsFirst':
'El dia de viatge acaba abans de les {time}, així que el viatge continua l’endemà al matí.',
'roadtrip.stay.clearLeave': 'Treu l’hora de fi',
'roadtrip.alt.noMotorway': 'Sense autopista',
'roadtrip.alt.noToll': 'Sense peatges',
'roadtrip.alt.noFerry': 'Sense ferri',
+6
View File
@@ -242,8 +242,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Čas na této zastávce',
'roadtrip.stop.pinned': 'Čas, který jste nastavili',
'roadtrip.stop.computed': 'Vypočteno z doby jízdy',
'roadtrip.stop.endIsLeave': 'Na cestě autem se v tento čas odjíždí.',
'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete',
'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase',
'roadtrip.warn.missedLeave': 'Příjezd {minutes} min po nastaveném čase odjezdu',
'roadtrip.warn.overnight': 'Následující den',
'roadtrip.spill.title': 'Ze dne {number}',
'roadtrip.spill.departs': 'odjezd {time}',
@@ -253,6 +255,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuty',
'roadtrip.stay.arrive': 'Prijezd',
'roadtrip.stay.leave': 'Odjezd',
'roadtrip.stay.until': 'do {time}',
'roadtrip.stay.leavesAt': 'Tato návštěva má čas konce, proto cesta pokračuje v {time}.',
'roadtrip.stay.dayEndsFirst': 'Cestovní den končí před {time}, proto cesta pokračuje až další ráno.',
'roadtrip.stay.clearLeave': 'Odebrat čas konce',
'roadtrip.alt.noMotorway': 'Bez dalnice',
'roadtrip.alt.noToll': 'Bez mytneho',
'roadtrip.alt.noFerry': 'Bez trajektu',
+6
View File
@@ -241,8 +241,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
'roadtrip.stop.endIsLeave': 'Im Roadtrip geht die Fahrt zu dieser Zeit weiter.',
'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt',
'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
'roadtrip.warn.missedLeave': 'Ankunft {minutes} min nach der gesetzten Abfahrtszeit',
'roadtrip.warn.overnight': 'Folgetag',
'roadtrip.spill.title': 'Von Tag {number}',
'roadtrip.spill.departs': 'ab {time}',
@@ -252,6 +254,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuten',
'roadtrip.stay.arrive': 'Ankunft',
'roadtrip.stay.leave': 'Abfahrt',
'roadtrip.stay.until': 'bis {time}',
'roadtrip.stay.leavesAt': 'Dieser Besuch hat eine Endzeit, deshalb geht die Fahrt um {time} weiter.',
'roadtrip.stay.dayEndsFirst': 'Der Reisetag endet vor {time}, deshalb geht die Fahrt am nächsten Morgen weiter.',
'roadtrip.stay.clearLeave': 'Endzeit entfernen',
'roadtrip.alt.noMotorway': 'Ohne Autobahn',
'roadtrip.alt.noToll': 'Ohne Maut',
'roadtrip.alt.noFerry': 'Ohne Fähre',
+6
View File
@@ -238,8 +238,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Time at this stop',
'roadtrip.stop.pinned': 'Time you set',
'roadtrip.stop.computed': 'Calculated from the drive',
'roadtrip.stop.endIsLeave': 'On the road trip, the drive leaves at this time.',
'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove',
'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
'roadtrip.warn.missedLeave': 'Arrives {minutes} min after the time you set to leave',
'roadtrip.warn.overnight': 'Next day',
'roadtrip.spill.title': 'From day {number}',
'roadtrip.spill.departs': 'leaves {time}',
@@ -249,6 +251,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minutes',
'roadtrip.stay.arrive': 'Arrive',
'roadtrip.stay.leave': 'Leave',
'roadtrip.stay.until': 'until {time}',
'roadtrip.stay.leavesAt': 'This visit has an end time, so the drive leaves at {time}.',
'roadtrip.stay.dayEndsFirst': 'The travel day ends before {time}, so the drive goes on the next morning.',
'roadtrip.stay.clearLeave': 'Remove end time',
'roadtrip.alt.noMotorway': 'No motorway',
'roadtrip.alt.noToll': 'No tolls',
'roadtrip.alt.noFerry': 'No ferry',
+7
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Tiempo en la parada',
'roadtrip.stop.pinned': 'Hora fijada por ti',
'roadtrip.stop.computed': 'Calculado a partir del trayecto',
'roadtrip.stop.endIsLeave': 'En el viaje por carretera, se sale a esta hora.',
'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar',
'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
'roadtrip.warn.missedLeave': 'Llega {minutes} min después de la hora de salida fijada',
'roadtrip.warn.overnight': 'Día siguiente',
'roadtrip.spill.title': 'Del día {number}',
'roadtrip.spill.departs': 'sale {time}',
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minutos',
'roadtrip.stay.arrive': 'Llegada',
'roadtrip.stay.leave': 'Salida',
'roadtrip.stay.until': 'hasta las {time}',
'roadtrip.stay.leavesAt': 'Esta visita tiene hora de fin, así que el viaje sale a las {time}.',
'roadtrip.stay.dayEndsFirst':
'El día de viaje termina antes de las {time}, así que el viaje sigue a la mañana siguiente.',
'roadtrip.stay.clearLeave': 'Quitar la hora de fin',
'roadtrip.alt.noMotorway': 'Sin autopista',
'roadtrip.alt.noToll': 'Sin peajes',
'roadtrip.alt.noFerry': 'Sin ferri',
+7
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Temps sur place',
'roadtrip.stop.pinned': 'Heure que vous avez fixée',
'roadtrip.stop.computed': 'Calculé d’après le trajet',
'roadtrip.stop.endIsLeave': 'Pendant le road trip, le départ se fait à cette heure.',
'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer',
'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée',
'roadtrip.warn.missedLeave': 'Arrivée {minutes} min après l’heure de départ fixée',
'roadtrip.warn.overnight': 'Jour suivant',
'roadtrip.spill.title': 'Du jour {number}',
'roadtrip.spill.departs': 'départ {time}',
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minutes',
'roadtrip.stay.arrive': 'Arrivee',
'roadtrip.stay.leave': 'Depart',
'roadtrip.stay.until': 'jusqu’à {time}',
'roadtrip.stay.leavesAt': 'Cette visite a une heure de fin, le trajet repart donc à {time}.',
'roadtrip.stay.dayEndsFirst':
'La journée de route se termine avant {time}, le trajet reprend donc le lendemain matin.',
'roadtrip.stay.clearLeave': 'Supprimer l’heure de fin',
'roadtrip.alt.noMotorway': 'Sans autoroute',
'roadtrip.alt.noToll': 'Sans peage',
'roadtrip.alt.noFerry': 'Sans ferry',
+7
View File
@@ -247,8 +247,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
'roadtrip.stop.endIsLeave': 'Στο οδικό ταξίδι, η αναχώρηση γίνεται αυτή την ώρα.',
'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση',
'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
'roadtrip.warn.missedLeave': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα αναχώρησης',
'roadtrip.warn.overnight': 'Επόμενη μέρα',
'roadtrip.spill.title': 'Από την ημέρα {number}',
'roadtrip.spill.departs': 'αναχώρηση {time}',
@@ -258,6 +260,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Λεπτα',
'roadtrip.stay.arrive': 'Αφιξη',
'roadtrip.stay.leave': 'Αναχωρηση',
'roadtrip.stay.until': 'έως {time}',
'roadtrip.stay.leavesAt': 'Αυτή η επίσκεψη έχει ώρα λήξης, οπότε η διαδρομή συνεχίζει στις {time}.',
'roadtrip.stay.dayEndsFirst':
'Η ημέρα ταξιδιού τελειώνει πριν από τις {time}, οπότε η διαδρομή συνεχίζει το επόμενο πρωί.',
'roadtrip.stay.clearLeave': 'Αφαίρεση ώρας λήξης',
'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο',
'roadtrip.alt.noToll': 'Χωρις διοδια',
'roadtrip.alt.noFerry': 'Χωρίς φέρι',
+6
View File
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Idő ezen a megállón',
'roadtrip.stop.pinned': 'Az általad megadott időpont',
'roadtrip.stop.computed': 'A menetidőből számolva',
'roadtrip.stop.endIsLeave': 'Az autós úton ekkor indul tovább az út.',
'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható',
'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
'roadtrip.warn.missedLeave': 'Érkezés {minutes} perccel a megadott indulási idő után',
'roadtrip.warn.overnight': 'Következő nap',
'roadtrip.spill.title': 'A(z) {number}. napról',
'roadtrip.spill.departs': 'indulás {time}',
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Perc',
'roadtrip.stay.arrive': 'Erkezes',
'roadtrip.stay.leave': 'Indulas',
'roadtrip.stay.until': '{time}-ig',
'roadtrip.stay.leavesAt': 'Ennek a látogatásnak van befejezési ideje, ezért az út {time}-kor folytatódik.',
'roadtrip.stay.dayEndsFirst': 'Az utazási nap {time} előtt véget ér, ezért az út másnap reggel folytatódik.',
'roadtrip.stay.clearLeave': 'Befejezési idő törlése',
'roadtrip.alt.noMotorway': 'Autopalya nelkul',
'roadtrip.alt.noToll': 'Dijmentes',
'roadtrip.alt.noFerry': 'Komp nélkül',
+7
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Waktu di perhentian ini',
'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
'roadtrip.stop.endIsLeave': 'Dalam perjalanan darat, keberangkatan pada waktu ini.',
'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus',
'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
'roadtrip.warn.missedLeave': 'Tiba {minutes} menit setelah waktu berangkat yang ditetapkan',
'roadtrip.warn.overnight': 'Hari berikutnya',
'roadtrip.spill.title': 'Dari hari {number}',
'roadtrip.spill.departs': 'berangkat {time}',
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Menit',
'roadtrip.stay.arrive': 'Tiba',
'roadtrip.stay.leave': 'Berangkat',
'roadtrip.stay.until': 'sampai {time}',
'roadtrip.stay.leavesAt': 'Kunjungan ini punya waktu selesai, jadi perjalanan berlanjut pukul {time}.',
'roadtrip.stay.dayEndsFirst':
'Hari perjalanan berakhir sebelum pukul {time}, jadi perjalanan berlanjut keesokan paginya.',
'roadtrip.stay.clearLeave': 'Hapus waktu selesai',
'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat',
'roadtrip.alt.noToll': 'Tanpa tol',
'roadtrip.alt.noFerry': 'Tanpa feri',
+7
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Tempo alla sosta',
'roadtrip.stop.pinned': 'Orario impostato da te',
'roadtrip.stop.computed': 'Calcolato dal tragitto',
'roadtrip.stop.endIsLeave': 'Nel viaggio su strada si riparte a quest’ora.',
'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere',
'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
'roadtrip.warn.missedLeave': 'Arrivo {minutes} min dopo l’orario di partenza impostato',
'roadtrip.warn.overnight': 'Giorno successivo',
'roadtrip.spill.title': 'Dal giorno {number}',
'roadtrip.spill.departs': 'partenza {time}',
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuti',
'roadtrip.stay.arrive': 'Arrivo',
'roadtrip.stay.leave': 'Partenza',
'roadtrip.stay.until': 'fino alle {time}',
'roadtrip.stay.leavesAt': 'Questa visita ha un orario di fine, quindi il viaggio riparte alle {time}.',
'roadtrip.stay.dayEndsFirst':
'La giornata di viaggio finisce prima delle {time}, quindi il viaggio riparte la mattina dopo.',
'roadtrip.stay.clearLeave': 'Rimuovi l’orario di fine',
'roadtrip.alt.noMotorway': 'Senza autostrada',
'roadtrip.alt.noToll': 'Senza pedaggi',
'roadtrip.alt.noFerry': 'Senza traghetto',
+6
View File
@@ -241,8 +241,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
'roadtrip.stop.pinned': '設定した時刻',
'roadtrip.stop.computed': '運転時間からの計算値',
'roadtrip.stop.endIsLeave': 'ロードトリップでは、この時刻に出発します。',
'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除',
'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
'roadtrip.warn.missedLeave': '設定した出発時刻より {minutes} 分遅く到着します',
'roadtrip.warn.overnight': '翌日',
'roadtrip.spill.title': '{number}日目から',
'roadtrip.spill.departs': '{time} 発',
@@ -252,6 +254,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': '分',
'roadtrip.stay.arrive': '到着',
'roadtrip.stay.leave': '出発',
'roadtrip.stay.until': '{time} まで',
'roadtrip.stay.leavesAt': 'この訪問には終了時刻があるため、{time} に出発します。',
'roadtrip.stay.dayEndsFirst': '移動時間が {time} より前に終わるため、翌朝に出発します。',
'roadtrip.stay.clearLeave': '終了時刻を削除',
'roadtrip.alt.noMotorway': '高速道路なし',
'roadtrip.alt.noToll': '有料道路なし',
'roadtrip.alt.noFerry': 'フェリーなし',
+6
View File
@@ -239,8 +239,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': '이 경유지 체류 시간',
'roadtrip.stop.pinned': '직접 지정한 시간',
'roadtrip.stop.computed': '주행 시간으로 계산됨',
'roadtrip.stop.endIsLeave': '로드트립에서는 이 시간에 출발합니다.',
'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제',
'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
'roadtrip.warn.missedLeave': '지정한 출발 시간보다 {minutes}분 늦게 도착',
'roadtrip.warn.overnight': '다음 날',
'roadtrip.spill.title': '{number}일차에서',
'roadtrip.spill.departs': '{time} 출발',
@@ -250,6 +252,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': '분',
'roadtrip.stay.arrive': '도착',
'roadtrip.stay.leave': '출발',
'roadtrip.stay.until': '{time}까지',
'roadtrip.stay.leavesAt': '이 방문에는 종료 시간이 있어 {time}에 출발합니다.',
'roadtrip.stay.dayEndsFirst': '이동 시간이 {time} 전에 끝나므로 다음 날 아침에 출발합니다.',
'roadtrip.stay.clearLeave': '종료 시간 삭제',
'roadtrip.alt.noMotorway': '고속도로 제외',
'roadtrip.alt.noToll': '통행료 없음',
'roadtrip.alt.noFerry': '페리 없음',
+6
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Tijd bij deze stop',
'roadtrip.stop.pinned': 'Door jou gezette tijd',
'roadtrip.stop.computed': 'Berekend uit de rit',
'roadtrip.stop.endIsLeave': 'Op de roadtrip vertrek je op dit tijdstip.',
'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen',
'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
'roadtrip.warn.missedLeave': 'Aankomst {minutes} min na de gezette vertrektijd',
'roadtrip.warn.overnight': 'Volgende dag',
'roadtrip.spill.title': 'Van dag {number}',
'roadtrip.spill.departs': 'vertrek {time}',
@@ -256,6 +258,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuten',
'roadtrip.stay.arrive': 'Aankomst',
'roadtrip.stay.leave': 'Vertrek',
'roadtrip.stay.until': 'tot {time}',
'roadtrip.stay.leavesAt': 'Dit bezoek heeft een eindtijd, dus de rit gaat om {time} verder.',
'roadtrip.stay.dayEndsFirst': 'De reisdag eindigt vóór {time}, dus de rit gaat de volgende ochtend verder.',
'roadtrip.stay.clearLeave': 'Eindtijd verwijderen',
'roadtrip.alt.noMotorway': 'Zonder snelweg',
'roadtrip.alt.noToll': 'Zonder tol',
'roadtrip.alt.noFerry': 'Zonder veerboot',
+6
View File
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Czas na tym przystanku',
'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
'roadtrip.stop.endIsLeave': 'W podróży samochodowej odjazd następuje o tej godzinie.',
'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć',
'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
'roadtrip.warn.missedLeave': 'Przyjazd {minutes} min po ustawionej godzinie odjazdu',
'roadtrip.warn.overnight': 'Następny dzień',
'roadtrip.spill.title': 'Z dnia {number}',
'roadtrip.spill.departs': 'odjazd {time}',
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuty',
'roadtrip.stay.arrive': 'Przyjazd',
'roadtrip.stay.leave': 'Odjazd',
'roadtrip.stay.until': 'do {time}',
'roadtrip.stay.leavesAt': 'Ta wizyta ma godzinę zakończenia, więc podróż rusza dalej o {time}.',
'roadtrip.stay.dayEndsFirst': 'Dzień podróży kończy się przed {time}, więc podróż rusza dalej następnego ranka.',
'roadtrip.stay.clearLeave': 'Usuń godzinę zakończenia',
'roadtrip.alt.noMotorway': 'Bez autostrady',
'roadtrip.alt.noToll': 'Bez oplat',
'roadtrip.alt.noFerry': 'Bez promu',
+6
View File
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Время на остановке',
'roadtrip.stop.pinned': 'Заданное вами время',
'roadtrip.stop.computed': 'Рассчитано по времени в пути',
'roadtrip.stop.endIsLeave': 'В автопутешествии отправление будет в это время.',
'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить',
'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
'roadtrip.warn.missedLeave': 'Прибытие на {minutes} мин позже заданного времени отправления',
'roadtrip.warn.overnight': 'Следующий день',
'roadtrip.spill.title': 'Со дня {number}',
'roadtrip.spill.departs': 'выезд {time}',
@@ -256,6 +258,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Минуты',
'roadtrip.stay.arrive': 'Прибытие',
'roadtrip.stay.leave': 'Отправление',
'roadtrip.stay.until': 'до {time}',
'roadtrip.stay.leavesAt': 'У этого визита есть время окончания, поэтому поездка продолжится в {time}.',
'roadtrip.stay.dayEndsFirst': 'День в пути заканчивается раньше {time}, поэтому поездка продолжится следующим утром.',
'roadtrip.stay.clearLeave': 'Убрать время окончания',
'roadtrip.alt.noMotorway': 'Без автомагистрали',
'roadtrip.alt.noToll': 'Без платных дорог',
'roadtrip.alt.noFerry': 'Без парома',
+6
View File
@@ -242,8 +242,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Tid på stoppet',
'roadtrip.stop.pinned': 'Tid du har satt',
'roadtrip.stop.computed': 'Beräknad utifrån körningen',
'roadtrip.stop.endIsLeave': 'På bilresan åker ni vidare vid den här tiden.',
'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort',
'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
'roadtrip.warn.missedLeave': 'Ankomst {minutes} min efter den satta avfärdstiden',
'roadtrip.warn.overnight': 'Nästa dag',
'roadtrip.spill.title': 'Från dag {number}',
'roadtrip.spill.departs': 'avgång {time}',
@@ -253,6 +255,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Minuter',
'roadtrip.stay.arrive': 'Ankomst',
'roadtrip.stay.leave': 'Avfard',
'roadtrip.stay.until': 'till {time}',
'roadtrip.stay.leavesAt': 'Besöket har en sluttid, så resan fortsätter {time}.',
'roadtrip.stay.dayEndsFirst': 'Resdagen tar slut före {time}, så resan fortsätter nästa morgon.',
'roadtrip.stay.clearLeave': 'Ta bort sluttid',
'roadtrip.alt.noMotorway': 'Utan motorvag',
'roadtrip.alt.noToll': 'Utan vagavgifter',
'roadtrip.alt.noFerry': 'Utan färja',
+7
View File
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Bu duraktaki süre',
'roadtrip.stop.pinned': 'Belirlediğiniz saat',
'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
'roadtrip.stop.endIsLeave': 'Yol gezisinde bu saatte yola çıkılır.',
'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın',
'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
'roadtrip.warn.missedLeave': 'Belirlenen kalkış saatinden {minutes} dk sonra varış',
'roadtrip.warn.overnight': 'Ertesi gün',
'roadtrip.spill.title': '{number}. günden',
'roadtrip.spill.departs': 'kalkış {time}',
@@ -255,6 +257,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Dakika',
'roadtrip.stay.arrive': 'Varis',
'roadtrip.stay.leave': 'Kalkis',
'roadtrip.stay.until': '{time} saatine kadar',
'roadtrip.stay.leavesAt': 'Bu ziyaretin bitiş saati var, bu yüzden yolculuk {time} saatinde devam eder.',
'roadtrip.stay.dayEndsFirst':
'Yolculuk günü {time} saatinden önce bitiyor, bu yüzden yolculuk ertesi sabah devam eder.',
'roadtrip.stay.clearLeave': 'Bitiş saatini kaldır',
'roadtrip.alt.noMotorway': 'Otoyolsuz',
'roadtrip.alt.noToll': 'Ucretsiz',
'roadtrip.alt.noFerry': 'Feribotsuz',
+6
View File
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Час на зупинці',
'roadtrip.stop.pinned': 'Час, який ви задали',
'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
'roadtrip.stop.endIsLeave': 'В автоподорожі відʼїзд відбувається в цей час.',
'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити',
'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
'roadtrip.warn.missedLeave': 'Прибуття на {minutes} хв пізніше заданого часу відʼїзду',
'roadtrip.warn.overnight': 'Наступний день',
'roadtrip.spill.title': 'З дня {number}',
'roadtrip.spill.departs': 'виїзд {time}',
@@ -255,6 +257,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Хвилини',
'roadtrip.stay.arrive': 'Прибуття',
'roadtrip.stay.leave': 'Відʼїзд',
'roadtrip.stay.until': 'до {time}',
'roadtrip.stay.leavesAt': 'Цей візит має час завершення, тому поїздка продовжиться о {time}.',
'roadtrip.stay.dayEndsFirst': 'День подорожі закінчується раніше {time}, тому поїздка продовжиться наступного ранку.',
'roadtrip.stay.clearLeave': 'Прибрати час завершення',
'roadtrip.alt.noMotorway': 'Без автомагістралі',
'roadtrip.alt.noToll': 'Без платних доріг',
'roadtrip.alt.noFerry': 'Без порома',
+6
View File
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': 'Thời gian tại điểm dừng',
'roadtrip.stop.pinned': 'Giờ bạn đặt',
'roadtrip.stop.computed': 'Tính từ thời gian lái xe',
'roadtrip.stop.endIsLeave': 'Trong chuyến đi đường bộ, sẽ khởi hành vào giờ này.',
'roadtrip.via.hint': 'Kéo để định hình lại lộ trình, chuột phải để xóa',
'roadtrip.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt',
'roadtrip.warn.missedLeave': 'Đến muộn {minutes} phút so với giờ rời đi đã đặt',
'roadtrip.warn.overnight': 'Ngày hôm sau',
'roadtrip.spill.title': 'Từ ngày {number}',
'roadtrip.spill.departs': 'khởi hành {time}',
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': 'Phut',
'roadtrip.stay.arrive': 'Den noi',
'roadtrip.stay.leave': 'Roi di',
'roadtrip.stay.until': 'đến {time}',
'roadtrip.stay.leavesAt': 'Lượt ghé này có giờ kết thúc, nên chuyến đi tiếp tục lúc {time}.',
'roadtrip.stay.dayEndsFirst': 'Ngày di chuyển kết thúc trước {time}, nên chuyến đi tiếp tục vào sáng hôm sau.',
'roadtrip.stay.clearLeave': 'Xóa giờ kết thúc',
'roadtrip.alt.noMotorway': 'Khong di cao toc',
'roadtrip.alt.noToll': 'Khong thu phi',
'roadtrip.alt.noFerry': 'Không phà',
+6
View File
@@ -230,8 +230,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': '在此停留時間',
'roadtrip.stop.pinned': '你設定的時間',
'roadtrip.stop.computed': '依行駛時間推算',
'roadtrip.stop.endIsLeave': '在公路旅行中,將在此時間出發。',
'roadtrip.via.hint': '拖曳可改變路線,右鍵刪除',
'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘',
'roadtrip.warn.missedLeave': '比設定的出發時間晚到 {minutes} 分鐘',
'roadtrip.warn.overnight': '次日',
'roadtrip.spill.title': '來自第 {number} 天',
'roadtrip.spill.departs': '{time} 出發',
@@ -241,6 +243,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': '分鐘',
'roadtrip.stay.arrive': '抵達',
'roadtrip.stay.leave': '出發',
'roadtrip.stay.until': '到 {time}',
'roadtrip.stay.leavesAt': '此行程設有結束時間,因此將在 {time} 出發。',
'roadtrip.stay.dayEndsFirst': '當天的出行時間在 {time} 之前結束,因此將於隔天早上繼續出發。',
'roadtrip.stay.clearLeave': '移除結束時間',
'roadtrip.alt.noMotorway': '不走高速',
'roadtrip.alt.noToll': '免收費',
'roadtrip.alt.noFerry': '不搭渡輪',
+6
View File
@@ -231,8 +231,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stop.stay': '在此停留时间',
'roadtrip.stop.pinned': '你设定的时间',
'roadtrip.stop.computed': '根据行驶时间推算',
'roadtrip.stop.endIsLeave': '在公路旅行中,将在此时间出发。',
'roadtrip.via.hint': '拖动可改变路线,右键删除',
'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟',
'roadtrip.warn.missedLeave': '比设定的出发时间晚到 {minutes} 分钟',
'roadtrip.warn.overnight': '次日',
'roadtrip.spill.title': '来自第 {number} 天',
'roadtrip.spill.departs': '{time} 出发',
@@ -242,6 +244,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.custom': '分钟',
'roadtrip.stay.arrive': '到达',
'roadtrip.stay.leave': '出发',
'roadtrip.stay.until': '到 {time}',
'roadtrip.stay.leavesAt': '此行程设有结束时间,因此将在 {time} 出发。',
'roadtrip.stay.dayEndsFirst': '当天的出行时间在 {time} 之前结束,因此将于次日早上继续出发。',
'roadtrip.stay.clearLeave': '移除结束时间',
'roadtrip.alt.noMotorway': '不走高速',
'roadtrip.alt.noToll': '免收费',
'roadtrip.alt.noFerry': '不乘轮渡',
+39 -2
View File
@@ -11,6 +11,7 @@
* at the charger, and a run of warnings that should collapse to one did not.
*/
import { assembleRoadtrip } from './assemble';
import type { DayWindow } from './dayWindow';
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
import { describe, it, expect } from 'vitest';
@@ -53,7 +54,7 @@ const stopKey = (s: RoadtripStop): string =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`;
function assemble(stops: RoadtripStop[], legs: number[]) {
function assemble(stops: RoadtripStop[], legs: number[], window: DayWindow | null = null) {
const day: PlanDay = { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops };
const allLegs: Record<string, RoutedLeg> = {};
legs.forEach((km, i) => {
@@ -62,7 +63,7 @@ function assemble(stops: RoadtripStop[], legs: number[]) {
return assembleRoadtrip({
plan: [day],
quietDays: [],
window: null,
window,
distanceUnit: 'metric',
allLegs,
snapByDay: {},
@@ -257,3 +258,39 @@ describe('assembleRoadtrip connected days', () => {
expect(routes.lineJoins).toEqual([false, false, true, false]);
});
});
describe('assembleRoadtrip leave times', () => {
// 90 km at the 40 seconds a kilometre `leg` drives is an hour.
const hour = 90;
it('ROADTRIP-ASSEMBLE-010: a stop left at a set time holds the day until then', () => {
const stops = [
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
stop({ ownerIndex: 1, dwellMinutes: 30, leaveAt: '14:00' }),
stop({ ownerIndex: 2 }),
];
const { schedule } = assemble(stops, [hour, hour]).days[0]!;
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
expect(schedule.entries[2]!.arrival).toBe('15:00');
});
it('ROADTRIP-ASSEMBLE-011: the plain schedule a conflicting window falls back to still reads it', () => {
// Leaving at two with an hour to drive cannot make a stop pinned at half past two,
// which is a conflict for the daily window. The day is then scheduled without it, and
// that schedule has to keep the departure and report the pin it makes late.
const stops = [
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
stop({ ownerIndex: 1, dwellMinutes: 30, leaveAt: '14:00' }),
stop({ ownerIndex: 2, time: '14:30' }),
];
const routes = assemble(stops, [hour, hour], { start: 480, end: 1200 });
expect(routes.dayWindowIssue).toBe('conflict');
const { schedule } = routes.days[0]!;
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
expect(schedule.warnings).toContainEqual({ index: 2, code: 'late', minutes: 30 });
});
});
+2 -5
View File
@@ -20,6 +20,7 @@ import {
deriveDriveWarnings,
refuelsRange,
isServiceStopType,
scheduleStopOf,
type DryPoint,
type DriveLimits,
type VehicleKind,
@@ -86,11 +87,7 @@ export function assembleRoadtrip({
...d,
spills: [],
schedule: computeSchedule(
d.stops.map((s) => ({
anchor: s.time ?? null,
earliest: s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
})),
d.stops.map(scheduleStopOf),
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
),
}))
@@ -28,4 +28,35 @@ describe('continuing after a moved day group', () => {
expect(completed.entries[2]).toEqual(original.entries[2]);
expect(completed.entries[3]!.arrival).toBe('09:00');
});
it('holds a stop with a leave time until then, and reports one it reaches too late', () => {
const leaving = [
{ dwellMinutes: 0 },
{ dwellMinutes: 60, leaveAt: '23:30' },
{ dwellMinutes: 60, leaveAt: '00:30' },
{ dwellMinutes: 0 },
] as RoadtripStop[];
const completed = continueSchedule(leaving, schedule(), [3600, 3600, 3600], 2);
// In at 19:15 and out at 23:30, not at 20:15.
expect(completed.entries[1]).toMatchObject({ arrival: '19:15', departure: '23:30' });
// Reached on the minute it is meant to be left at: gone at once, and not late.
expect(completed.entries[2]).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
expect(completed.entries[3]!.arrival).toBe('01:30');
expect(completed.warnings).not.toContainEqual(expect.objectContaining({ code: 'missedLeave' }));
// Another hour on the road and it is reached an hour after it should have been left.
const late = continueSchedule(leaving, schedule(), [3600, 7200, 3600], 2);
expect(late.warnings).toContainEqual({ index: 2, code: 'missedLeave', minutes: 60 });
});
it('reports a leave time the drive went past before midnight rather than waiting for the next one', () => {
const leaving = [
{ dwellMinutes: 0 },
{ dwellMinutes: 60, leaveAt: '23:30' },
{ dwellMinutes: 60 },
{ dwellMinutes: 0 },
] as RoadtripStop[];
// Out of Munich at 18:15, six and a quarter hours to go: in at half past midnight.
const completed = continueSchedule(leaving, schedule(), [375 * 60, 3600, 3600], 2);
expect(completed.entries[1]).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
expect(completed.entries[2]!.arrival).toBe('01:30');
expect(completed.warnings).toContainEqual({ index: 1, code: 'missedLeave', minutes: 60 });
});
});
+5 -2
View File
@@ -1,5 +1,5 @@
import type { RoadtripStop } from './planning-types';
import { formatClock, parseClock, type Schedule } from './roadtripModel';
import { formatClock, leaveAfter, parseClock, type Schedule } from './roadtripModel';
export function continueSchedule(
stops: RoadtripStop[],
@@ -23,7 +23,10 @@ export function continueSchedule(
previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
const arrivalAt = departureAt + Math.round(duration / 60);
const stop = stops[i]!;
const leaveAt = arrivalAt + (stop.dwellMinutes ?? 0);
const leave = parseClock(stop.leaveAt);
const left = leave === null ? null : leaveAfter(arrivalAt, leave, departureAt);
if (left && left.missedBy !== null) warnings.push({ index: i, code: 'missedLeave', minutes: left.missedBy });
const leaveAt = left ? left.departure : arrivalAt + (stop.dwellMinutes ?? 0);
entry.arrival = formatClock(arrivalAt);
entry.departure = formatClock(leaveAt);
entry.dayOffset = Math.floor(arrivalAt / 1440);
+91
View File
@@ -416,3 +416,94 @@ it('waits for check-in without treating it as a fixed appointment', () => {
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
});
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(
[stop(1, { time: '09:00' }), stop(2, { dwellMinutes: 30, leaveAt: '14:00' }), stop(3)],
[60, 60],
);
expect(plan.issue).toBeNull();
const entries = plan.chains[0]!.schedule.entries;
expect(entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
expect(entries[2]!.arrival).toBe('15:00');
expect(plan.chains[0]!.schedule.warnings).toEqual([]);
});
it('pins both ends of the stay when the visit has a start as well', () => {
const plan = calculate(
[stop(1, { time: '09:00' }), stop(2, { time: '10:30', dwellMinutes: 60, leaveAt: '14:00' }), stop(3)],
[60, 60],
);
const entries = plan.chains[0]!.schedule.entries;
expect(entries[1]!).toMatchObject({ arrival: '10:30', departure: '14:00', anchored: true });
expect(entries[2]!.arrival).toBe('15:00');
});
it('leaves on arrival and says so when the drive gets there after it', () => {
const plan = calculate(
[stop(1, { time: '13:30' }), stop(2, { dwellMinutes: 30, leaveAt: '14:00' }), stop(3)],
[60, 60],
);
expect(plan.issue).toBeNull();
const chain = plan.chains[0]!;
expect(chain.schedule.entries[1]!).toMatchObject({ arrival: '14:30', departure: '14:30' });
expect(chain.schedule.entries[2]!.arrival).toBe('15:30');
expect(chain.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
});
it('starts the day early for a first stop left before the travel hours begin', () => {
// A ferry at seven on a day whose hours begin at eight. A Start at seven already
// started the day then; an End was read against eight and reported an hour missed.
const plan = calculate([stop(1, { dwellMinutes: 30, leaveAt: '07:00' }), stop(2)], [60]);
expect(plan.issue).toBeNull();
const chain = plan.chains[0]!;
expect(chain.schedule.entries[0]!).toMatchObject({ arrival: '07:00', departure: '07:00' });
expect(chain.schedule.entries[1]!.arrival).toBe('08:00');
expect(chain.schedule.warnings).toEqual([]);
});
it('starts the morning after a night early enough to leave the first stop in time', () => {
const a = stop(1);
const b = stop(2, { ownerDayId: 2, ownerIndex: 0, leaveAt: '07:00' });
const plan = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels);
expect(plan.issue).toBeNull();
const morning = plan.chains[1]!;
expect(morning.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
['06:00', '06:00'],
['07:00', '07:00'],
]);
expect(morning.schedule.warnings).toEqual([]);
});
it('leaves a first stop whose time is inside the travel hours to them', () => {
const plan = calculate([stop(1, { dwellMinutes: 30, leaveAt: '09:00' }), stop(2)], [60]);
expect(plan.chains[0]!.schedule.entries[0]!).toMatchObject({ arrival: '08:00', departure: '09:00' });
});
it('reports a leave time a drive past midnight went by, on a day ended by hand', () => {
// Out at eight in the evening, four and a half hours on the road, the day ended by hand
// at the stop: in at half past midnight, an hour after the half past eleven it was
// meant to be left at, which is missed rather than the next evening's.
const stops = [stop(1, { time: '20:00' }), stop(2, { leaveAt: '23:30' }), stop(3)];
const plan = planDayWindow(
[day(1, stops), day(2)],
hours,
(a, b) => leg(a === stops[0] ? 270 : 60, a, b),
'metric',
labels,
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }],
);
expect(plan.issue).toBeNull();
const chain = plan.chains[0]!;
expect(chain.schedule.entries[1]!).toMatchObject({ arrival: '00:30', departure: '00:30' });
expect(chain.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 60 }]);
});
it('never hands the leave time on to the night it closes the day with', () => {
const plan = calculate([stop(1, { leaveAt: '09:00' }), stop(2, { lng: 12 })]);
const night = plan.chains[0]!.stops.find((s) => s.automaticNight);
expect(night).toBeDefined();
expect(night!.leaveAt).toBeUndefined();
});
});
+30 -5
View File
@@ -3,7 +3,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema';
import type { SpillChain } from './nightSpill';
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types';
import type { DistanceUnit } from './planning-types';
import { formatClock, hasChosenArrival, parseClock } from './roadtripModel';
import { formatClock, hasChosenArrival, leaveAfter, parseClock } from './roadtripModel';
import { formatDurationShort } from './roadtripModel';
import { formatDistance } from './units';
@@ -180,6 +180,7 @@ export function planDayWindow(
assignmentId: -2000000000 - number * 2,
name: labels.end,
time: null,
leaveAt: undefined,
dwellMinutes: 0,
checkInTime: undefined,
stopType: null,
@@ -206,6 +207,10 @@ export function planDayWindow(
for (let i = 0; i < stops.length; i++) {
const { stop, day } = stops[i]!;
const pin = parseClock(stop.time);
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.
let setOut: number | null = null;
if (previous && number < day.dayNumber && !targets.has(number)) {
if (window.endMode !== 'stop') clock = Math.max(clock, window.end);
previous = night(previous);
@@ -215,7 +220,10 @@ export function planDayWindow(
}
} else if (!previous) {
number = day.dayNumber;
clock = pin ?? window.start;
// A time set to leave the first stop starts the day early the way a pinned arrival
// does. Otherwise a ferry at seven on a day whose travel hours begin at nine was
// reported missed by two hours, when nothing but the preference made it late.
clock = pin ?? (leave === null ? window.start : Math.min(window.start, leave));
}
if (issue) return failed(issue);
if (pin !== null && day.dayNumber !== number) return failed('conflict');
@@ -223,8 +231,15 @@ export function planDayWindow(
if (previous) {
const leg = legs[i - 1]!;
const minutes = leg.seg.duration / 60;
if (pin !== null && chainAt(number).stops.every((s) => s.automaticNight)) {
clock = Math.min(clock, pin - minutes);
// The same for the first stop after a night: the morning starts early enough to get
// there by its pinned time, or to leave it by the time it is set to be left at. A
// pin that cannot be made is a conflict. A leave time is only ever missed, so one
// out of reach even from midnight moves nothing and says so once the stop is reached.
let early: number | null = null;
if (pin !== null) early = pin - minutes;
else if (leave !== null && leave - minutes >= 0) early = leave - minutes;
if (early !== null && early < clock && chainAt(number).stops.every((s) => s.automaticNight)) {
clock = early;
if (clock < 0) return failed('conflict');
const startEntry = chainAt(number).schedule.entries[0]!;
startEntry.arrival = formatClock(Math.round(clock));
@@ -265,6 +280,7 @@ export function planDayWindow(
if (issue) return failed(issue);
}
const remainder = portion(leg, fraction, 1, unit);
setOut = clock;
clock += minutes * (1 - fraction);
putLeg(previous, stop, remainder);
}
@@ -281,8 +297,17 @@ export function planDayWindow(
// driving hours. The night between two of them is not a pause in the stay: standing
// somewhere for twenty-four hours takes twenty-four hours, and counting only the
// hours the window is open stretched a single night over three days.
//
// A leave time decides the stay on its own: the traveller is here until then, however
// long the stop usually takes. Reached after it, the drive leaves at once and the
// stop says by how much it missed.
const overnight = Math.max(0, 1440 - window.end + window.start);
let dwell = Math.max(0, stop.dwellMinutes ?? 0);
const left = leave === null ? null : leaveAfter(clock, leave, setOut);
if (left && left.missedBy !== null) {
const chain = chainAt(number);
chain.schedule.warnings.push({ index: chain.stops.length - 1, code: 'missedLeave', minutes: left.missedBy });
}
let dwell = left ? left.departure - clock : Math.max(0, stop.dwellMinutes ?? 0);
while (dwell > 0) {
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
const spend = Math.min(dwell, remaining);
+80
View File
@@ -277,6 +277,86 @@ describe('spillChains', () => {
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
});
it('FE-NIGHTSPILL-018: a stop left at a set time holds the drive until then', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 0 }),
stop({ assignmentId: 2, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }),
stop({ assignmentId: 3, name: 'Celle' }),
]),
];
const chains = spillChains(plan, [], everyLeg(60));
expect(chains[0]!.schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
expect(chains[0]!.schedule.entries[2]!.arrival).toBe('15:00');
expect(chains[0]!.schedule.warnings).toEqual([]);
});
it('FE-NIGHTSPILL-019: a missed leave time keeps its minutes when the stop moves', () => {
// Hamburg is left at 22:30 and the road takes three hours, so Neuruppin is reached at
// half past one: an hour after the half past midnight it was meant to be left at.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin', leaveAt: '00:30' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
];
const chains = spillChains(plan, [], everyLeg(180));
expect(chains[1]!.stops[0]!.name).toBe('Neuruppin');
expect(chains[1]!.schedule.warnings).toContainEqual({ index: 0, code: 'missedLeave', minutes: 60 });
});
it('FE-NIGHTSPILL-020: a leave time the night drive went past is missed, not held until the next evening', () => {
// Hamburg at eight, four and a half hours to Neuruppin: in at half past midnight, an
// hour after the half past eleven it was meant to be left at. Read on the day of the
// arrival it held the drive there for twenty-three hours and Potsdam fell a day behind.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '20:00', dwellMinutes: 0 }),
stop({ assignmentId: 2, name: 'Neuruppin', leaveAt: '23:30' }),
stop({ assignmentId: 3, name: 'Potsdam' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
day(3, 3, [stop({ assignmentId: 5, name: 'Leipzig', time: '10:00' })]),
];
const chains = spillChains(plan, [], everyLeg(270));
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
expect(dayTwo.stops.map((s) => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg']);
expect(dayTwo.schedule.entries[0]!).toMatchObject({ arrival: '00:30', departure: '00:30' });
expect(dayTwo.schedule.entries[1]!.arrival).toBe('05:00');
expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'missedLeave', minutes: 60 });
expect(chains.find((c) => c.dayNumber === 3)!.stops.map((s) => s.name)).toEqual(['Leipzig']);
});
it('FE-NIGHTSPILL-021: a first stop left in the morning after a long stay keeps its day together', () => {
// A hotel stayed at for twelve hours and left at eight. Worked back from its stay it
// was reached the evening before, and the rest of the day moved onto the next card.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hotel', dwellMinutes: 720, leaveAt: '08:00' }),
stop({ assignmentId: 2, name: 'Celle' }),
stop({ assignmentId: 3, name: 'Hannover' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Hildesheim' }), stop({ assignmentId: 5, name: 'Goslar' })]),
];
const chains = spillChains(plan, [], everyLeg(60));
expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hotel', 'Celle', 'Hannover']);
expect(chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['00:00', '09:00', '10:00']);
expect(chains[0]!.schedule.warnings).toEqual([]);
// Nothing reaches into day two either: the day ended at ten in the morning.
expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Hildesheim', 'Goslar']);
expect(chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual([null, null]);
expect(chains[1]!.spills).toEqual([]);
});
describe('a stay that runs past midnight carries the clock into the next day', () => {
// Standing somewhere for twenty-four hours is not over when the date changes. The
// day after used to begin at nothing, so a trip whose second day had no pinned time
+13 -15
View File
@@ -1,6 +1,13 @@
import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types';
import type { RouteSegment } from './planning-types';
import { computeSchedule, parseClock, type Schedule, hasChosenArrival } from './roadtripModel';
import {
computeSchedule,
parseClock,
scheduleStopOf,
type Schedule,
type ScheduleWarning,
hasChosenArrival,
} from './roadtripModel';
export interface SpillMark {
automatic?: boolean;
@@ -44,8 +51,8 @@ interface Placed {
dayOffset: number;
codes: ('late' | 'overnight' | 'leg' | 'range')[];
lateMinutes: number | null;
/** The findings the day's own schedule filed at this stop, minutes and all. */
marks: ScheduleWarning[];
fromDayNumber: number | null;
departedAt: string | null;
@@ -93,11 +100,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!));
const legs = routed.map((l) => l?.seg);
const schedule = computeSchedule(
d.stops.map((s) => ({
anchor: s.time ?? null,
earliest: s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
})),
d.stops.map(scheduleStopOf),
legs.map((l) => l?.duration),
{ notBefore },
);
@@ -145,8 +148,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
dayOffset: moved ? 0 : offset,
codes: marks.map((w) => w.code).filter((c) => !(moved && c === 'overnight')),
lateMinutes: marks.find((w) => w.code === 'late')?.minutes ?? null,
marks: marks.filter((w) => !(moved && w.code === 'overnight')),
fromDayNumber: moved ? d.dayNumber : null,
departedAt: moved ? (schedule.entries[i - 1]?.departure ?? null) : null,
leg: moved ? legs[i - 1] : undefined,
@@ -192,11 +194,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
anchored: hasChosenArrival(p.stop),
dayOffset: p.dayOffset,
})),
warnings: placed.flatMap((p, i) =>
p.codes.map((code) =>
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code },
),
),
warnings: placed.flatMap((p, i) => p.marks.map((w) => ({ ...w, index: i }))),
},
spills,
});
+6
View File
@@ -17,6 +17,12 @@ export interface RoadtripStop {
time: string | null;
/**
* When the traveller leaves this stop: the visit's own end time, or the place's.
* The stay then runs until it rather than for `dwellMinutes`.
*/
leaveAt?: string | null;
dwellMinutes: number | null;
checkInTime?: string | null;
endDay?: boolean;
+263 -7
View File
@@ -12,6 +12,8 @@ import {
splitIntoRuns,
sumLegSeconds,
refuelStopTypeFor,
leaveAfter,
scheduleStopOf,
} from './roadtripModel';
import { describe, it, expect } from 'vitest';
@@ -808,20 +810,274 @@ describe('checkout without daily travel times', () => {
});
});
it('uses checkout as departure even without an arrival or daily start', () => {
it('uses a set departure even without an arrival or daily start', () => {
// Nothing before the stop says when it is reached, so its own stay does: an hour
// before eight. The stop ahead of it is worked back from there like from a pin.
const schedule = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
{ anchor: null, dwellMinutes: 60, departureAt: 1440 + 480 },
{ anchor: null, dwellMinutes: 60, departureAt: 480 },
{ anchor: null, dwellMinutes: 0 },
],
[3600, 7200],
);
expect(schedule.entries[0]!.arrival).toBeNull();
expect(schedule.entries[1]!.arrival).toBeNull();
expect(schedule.entries[1]!.departure).toBe('08:00');
expect(schedule.entries[2]!.arrival).toBe('10:00');
expect(schedule.entries[2]!.dayOffset).toBe(1);
expect(schedule.entries[0]!).toMatchObject({ arrival: '05:00', departure: '06:00', anchored: false });
expect(schedule.entries[1]!).toMatchObject({ arrival: '07:00', departure: '08:00', anchored: false });
expect(schedule.entries[2]!).toMatchObject({ arrival: '10:00', dayOffset: 0 });
expect(schedule.warnings).toEqual([]);
});
describe('a time set to leave a stop', () => {
const hour = 3600;
it('holds the stop until then, whatever its stay', () => {
// Arrive at ten, stay half an hour by default, but leave at two: the next stop is
// reached at three, not at half past eleven.
const schedule = computeSchedule(
[
{ anchor: '09:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
expect(schedule.entries[2]!).toMatchObject({ arrival: '15:00' });
expect(schedule.warnings).toEqual([]);
expect(schedule.endsAt).toBe(15 * 60);
});
it('is kept when the stop is reached later, as long as it is still ahead', () => {
const schedule = computeSchedule(
[
{ anchor: '12:30', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 60, departureAt: 14 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
// Half an hour there, because that is what is left until two, not the hour it takes.
expect(schedule.entries[1]!).toMatchObject({ arrival: '13:30', departure: '14:00' });
expect(schedule.entries[2]!.arrival).toBe('15:00');
});
it('says so when the drive gets there after it, and leaves at once', () => {
const schedule = computeSchedule(
[
{ anchor: '13:30', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '14:30', departure: '14:30' });
expect(schedule.entries[2]!.arrival).toBe('15:30');
expect(schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
});
it('pins both ends of the stay when the arrival is set too', () => {
// Start and End on one visit: arrive at ten, leave at two. The stay is the four hours
// between them, not the hour the place carries.
const schedule = computeSchedule(
[
{ anchor: '09:00', dwellMinutes: 0 },
{ anchor: '10:00', dwellMinutes: 60, departureAt: 14 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true });
expect(schedule.entries[2]!.arrival).toBe('15:00');
expect(schedule.warnings).toEqual([]);
});
it('works backwards from it like from a pinned arrival', () => {
// Nothing is pinned before the stop. Leaving at two with half an hour there means
// arriving at half past one, and the stop before it is left at half past twelve.
const schedule = computeSchedule(
[
{ anchor: null, dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
{ anchor: '15:00', dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[0]!).toMatchObject({ arrival: '12:30', departure: '12:30', anchored: false });
expect(schedule.entries[1]!).toMatchObject({ arrival: '13:30', departure: '14:00', anchored: false });
expect(schedule.entries[2]!).toMatchObject({ arrival: '15:00', anchored: true });
expect(schedule.warnings).toEqual([]);
});
it('makes the next pinned stop late when it is left too late to reach it', () => {
const schedule = computeSchedule(
[
{ anchor: '09:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0, departureAt: 14 * 60 },
{ anchor: '14:30', dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.warnings).toEqual([{ index: 2, code: 'late', minutes: 30 }]);
});
it('counts on the day the stop is reached', () => {
// Reached after midnight on a drive through the night: the ten o'clock it is left at
// is that morning's, not the morning before.
const schedule = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0, departureAt: 10 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[6 * hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '02:00', departure: '10:00', dayOffset: 1 });
expect(schedule.entries[2]!).toMatchObject({ arrival: '11:00', dayOffset: 1 });
expect(schedule.warnings).toEqual([{ index: 1, code: 'overnight' }]);
});
it('is missed when the drive passes it on the way, midnight or not', () => {
// Left at eight in the evening, four and a half hours on the road: the half past
// eleven the stop was to be left at went by on the way. It is not the half past
// eleven of the next evening, which held the drive there for twenty-three hours.
const schedule = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0, departureAt: 23 * 60 + 30 },
{ anchor: null, dwellMinutes: 0 },
],
[4.5 * hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
expect(schedule.entries[2]!).toMatchObject({ arrival: '01:30', dayOffset: 1 });
expect(schedule.warnings).toEqual([
{ index: 1, code: 'missedLeave', minutes: 60 },
{ index: 1, code: 'overnight' },
]);
});
it('waits all day for a time late in the evening', () => {
const schedule = computeSchedule(
[
{ anchor: '08:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 60, departureAt: 21 * 60 + 30 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: '09:00', departure: '21:30', dayOffset: 0 });
expect(schedule.entries[2]!.arrival).toBe('22:30');
expect(schedule.warnings).toEqual([]);
});
it('takes a time just after midnight as that night, with or without a start', () => {
// Reached at nine in the evening, left at one: four hours, not a departure twenty
// hours in the past. The same visit with a start at ten (which only MCP can write,
// the form refuses an end before its start) runs past midnight the same way.
for (const anchor of [null, '22:00']) {
const schedule = computeSchedule(
[
{ anchor: '20:00', dwellMinutes: 0 },
{ anchor, dwellMinutes: 30, departureAt: 60 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries[1]!.departure).toBe('01:00');
expect(schedule.entries[2]!).toMatchObject({ arrival: '02:00', dayOffset: 1 });
expect(schedule.warnings).toEqual([{ index: 2, code: 'overnight' }]);
}
});
it('lets the first stop of the day start at midnight at the earliest', () => {
// A hotel stayed at for twelve hours and left at eight. Worked back from its stay it
// was reached at eight the evening before, which pushed the whole day onto the next.
const schedule = computeSchedule(
[
{ anchor: null, dwellMinutes: 720, departureAt: 8 * 60 },
{ anchor: null, dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0 },
],
[hour, hour],
);
expect(schedule.entries.map((e) => [e.arrival, e.departure, e.dayOffset])).toEqual([
['00:00', '08:00', 0],
['09:00', '09:00', 0],
['10:00', '10:00', 0],
]);
expect(schedule.warnings).toEqual([]);
expect(schedule.endsAt).toBe(10 * 60);
});
it('keeps a departure it cannot place yet, after a leg that never routed', () => {
const schedule = computeSchedule(
[
{ anchor: '09:00', dwellMinutes: 0 },
{ anchor: null, dwellMinutes: 0, departureAt: 14 * 60 },
{ anchor: null, dwellMinutes: 0 },
],
[undefined, hour],
);
expect(schedule.entries[1]!).toMatchObject({ arrival: null, departure: '14:00' });
expect(schedule.entries[2]!.arrival).toBe('15:00');
});
});
describe('leaveAfter', () => {
it('waits for a time still ahead of the arrival', () => {
expect(leaveAfter(600, 840)).toEqual({ departure: 840, missedBy: null });
});
it('leaves on arrival for a time already past, and says by how much', () => {
expect(leaveAfter(870, 840)).toEqual({ departure: 870, missedBy: 30 });
});
it('forgives the minute a rounded drive can add', () => {
expect(leaveAfter(841, 840)).toEqual({ departure: 841, missedBy: null });
});
it('places the time on the day of the arrival', () => {
expect(leaveAfter(1440 + 120, 600)).toEqual({ departure: 1440 + 600, missedBy: null });
});
it('counts a time the drive went past on the way as missed, across midnight too', () => {
// Set out at 20:00, in at 00:30, meant to leave at 23:30.
expect(leaveAfter(1470, 1410, 1200)).toEqual({ departure: 1470, missedBy: 60 });
});
it('waits for a time more than half a day ahead rather than calling the last one missed', () => {
expect(leaveAfter(540, 1290, 480)).toEqual({ departure: 1290, missedBy: null });
});
it('takes a time after midnight as that night when the one this morning is long gone', () => {
expect(leaveAfter(1320, 60)).toEqual({ departure: 1500, missedBy: null });
});
it('still calls a time missed that went by before the drive set out, the same afternoon', () => {
// The stop before was left at half past two for a stop meant to be left at two. That
// is late, not a reason to stand there until two tomorrow.
expect(leaveAfter(930, 840, 870)).toEqual({ departure: 930, missedBy: 90 });
});
});
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,
});
});
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');
});
});
+114 -24
View File
@@ -1,4 +1,4 @@
import type { RouteAvoidClass } from './planning-types';
import type { RoadtripStop, RouteAvoidClass } from './planning-types';
export const SERVICE_STOP_TYPES = [
'fuel',
@@ -73,6 +73,14 @@ export function formatClock(minutes: number): string {
}
export interface ScheduleStop {
/**
* Minute this stop is left at. A time of day is put on a day against the arrival (see
* `leaveAfter`); past 1440 it is counted from the start of the day the stop is reached
* on and reaches into the days after.
*
* The stay then lasts until then instead of for `dwellMinutes`, and the chain carries
* on from it.
*/
departureAt?: number;
/** A time somebody fixed this stop to. The chain restarts from it, and arriving
* after it is being late. */
@@ -90,7 +98,8 @@ export interface ScheduleStop {
export interface ScheduleWarning {
index: number;
code: 'late' | 'overnight' | 'leg' | 'range';
/** `missedLeave` is a stop reached after the time it was set to be left at. */
code: 'late' | 'overnight' | 'leg' | 'range' | 'missedLeave';
minutes?: number;
@@ -172,6 +181,63 @@ function resolveArrival(
};
}
/**
* When a stop that is left at a set time is left, reached at `arrival`.
*
* Getting there after the time is not a reason to wait for the same time tomorrow: the
* traveller is late for a departure they chose themselves, so the drive leaves on arrival
* and `missedBy` says how late. The question is only which passing of the clock counts.
*
* The one the clock last showed before the arrival counts when the drive went past it on
* the road, which is what `setOut` (when the drive towards the stop left the one before,
* counted like the arrival) is for: a 23:30 reached at 00:30 is missed, midnight or not.
* It also counts when it went by earlier the same day and less than half a day ago, like
* a stop meant to be left at two and reached at half past.
*
* Anything else is the next one. Ten in the morning after a night drive is that
* morning's, one at night after an evening arrival is that night's, and a stop reached
* at nine with an End of half past nine in the evening is stood at all day.
*/
export function leaveAfter(
arrival: number,
departureAt: number,
setOut: number | null = null,
): { departure: number; missedBy: number | null } {
// A departure a day or more out is on its day already, counted from the start of the one
// the stop is reached on. Only a time of day has to be put on one.
if (departureAt >= DAY_MINUTES) {
return { departure: departureAt + Math.floor(arrival / DAY_MINUTES) * DAY_MINUTES, missedBy: null };
}
const before = departureAt + Math.floor((arrival - departureAt) / DAY_MINUTES) * DAY_MINUTES;
const late = arrival - before;
// The minute a rounded drive can add is not being late.
if (late <= 1) return { departure: arrival, missedBy: null };
const drivenPast = setOut !== null && before >= setOut;
const earlierToday =
Math.floor(before / DAY_MINUTES) === Math.floor(arrival / DAY_MINUTES) && late <= DAY_MINUTES / 2;
if (drivenPast || earlierToday) return { departure: arrival, missedBy: Math.round(late) };
return { departure: before + DAY_MINUTES, missedBy: null };
}
/**
* The part of a road trip stop the schedule reads.
*
* 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).
*/
export function scheduleStopOf(
stop: Pick<RoadtripStop, 'time' | 'checkInTime' | 'dwellMinutes' | 'leaveAt'>,
): ScheduleStop {
const leave = parseClock(stop.leaveAt);
return {
anchor: stop.time ?? null,
earliest: stop.checkInTime ?? null,
dwellMinutes: stop.dwellMinutes,
...(leave === null ? {} : { departureAt: leave }),
};
}
/**
* @param opts.notBefore Minute of this day the first stop cannot be reached before,
* because the day before is still running into it: a stop stood at past midnight ends
@@ -187,9 +253,15 @@ export function computeSchedule(
const warnings: ScheduleWarning[] = [];
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
// Counted the same way as the arrivals. Set with every arrival, and on its own for a
// stop whose leave time is known before anything says when it is reached.
const departures: (number | null)[] = new Array(stops.length).fill(null);
const anchored: boolean[] = new Array(stops.length).fill(false);
let cursor: number | null = opts.notBefore ?? null;
// When the drive the cursor stands for left the stop before, which is what tells a leave
// time passed on the road from one still ahead.
let setOut: number | null = null;
let dayOffset = 0;
for (let i = 0; i < stops.length; i++) {
@@ -215,7 +287,14 @@ export function computeSchedule(
if (arrival === null) {
const leg = legSeconds[i];
if (stop.departureAt !== undefined && leg !== undefined) cursor = stop.departureAt + Math.round(leg / 60);
if (stop.departureAt !== undefined) {
const departure = stop.departureAt + dayOffset * DAY_MINUTES;
departures[i]! = departure;
if (leg !== undefined) {
cursor = departure + Math.round(leg / 60);
setOut = departure;
}
}
continue;
}
@@ -227,21 +306,39 @@ export function computeSchedule(
// drive actually had to wait for it.
anchored[i]! = anchor !== null || held;
let departure = arrival + (stop.dwellMinutes ?? 0);
if (stop.departureAt !== undefined) {
const left = leaveAfter(arrival, stop.departureAt, setOut);
departure = left.departure;
if (left.missedBy !== null) warnings.push({ index: i, code: 'missedLeave', minutes: left.missedBy });
}
departures[i]! = departure;
const leg = legSeconds[i];
cursor =
leg === undefined
? null
: (stop.departureAt === undefined ? arrival + (stop.dwellMinutes ?? 0) : Math.max(arrival, stop.departureAt)) +
Math.round(leg / 60);
cursor = leg === undefined ? null : departure + Math.round(leg / 60);
setOut = leg === undefined ? null : departure;
}
const firstKnown = arrivals.findIndex((a) => a !== null);
for (let i = firstKnown - 1; i >= 0; i--) {
if (stops[i]!.departureAt !== undefined) break;
// Before the first time anything fixes, the chain runs backwards: every stop is reached
// early enough to stay its length and still make the next one. A stop left at a set time
// is reached its stay before that time, the way a pinned arrival is left a stay after.
// The first stop of the day is not reached before the day began, though: a hotel left at
// eight after a twelve hour stay is where the day starts, not a stop reached the evening
// before, and reading it that way moved everything after it onto tomorrow's card.
const firstKnown = stops.findIndex((_, i) => arrivals[i] !== null || departures[i] !== null);
for (let i = firstKnown; i >= 0; i--) {
if (arrivals[i] !== null) continue;
const dwell = stops[i]!.dwellMinutes ?? 0;
const fixed = departures[i]!;
if (fixed !== null) {
arrivals[i]! = i === 0 ? Math.max(0, fixed - dwell) : fixed - dwell;
continue;
}
const leg = legSeconds[i];
const next = arrivals[i + 1]!;
const next = arrivals[i + 1] ?? null;
if (leg === undefined || next === null) break;
arrivals[i]! = next - Math.round(leg / 60) - (stops[i]!.dwellMinutes ?? 0);
arrivals[i]! = next - Math.round(leg / 60) - dwell;
departures[i]! = next - Math.round(leg / 60);
}
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null);
@@ -251,10 +348,11 @@ export function computeSchedule(
let lastOffset = 0;
for (let i = 0; i < stops.length; i++) {
const raw = arrivals[i]!;
const left = departures[i]!;
if (raw === null) {
entries.push({
arrival: null,
departure: stops[i]!.departureAt === undefined ? null : formatClock(stops[i]!.departureAt!),
departure: left === null ? null : formatClock(left + shift),
anchored: false,
dayOffset: 0,
});
@@ -267,11 +365,7 @@ export function computeSchedule(
lastOffset = offset;
entries.push({
arrival: formatClock(arrival),
departure: formatClock(
stops[i]!.departureAt === undefined
? arrival + (stops[i]!.dwellMinutes ?? 0)
: Math.max(arrival, stops[i]!.departureAt! + shift),
),
departure: formatClock(left! + shift),
anchored: anchored[i]!,
dayOffset: offset,
});
@@ -284,11 +378,7 @@ export function computeSchedule(
for (let i = stops.length - 1; i >= 0; i--) {
const raw = arrivals[i];
if (raw === null || raw === undefined) continue;
const arrival = raw + shift;
endsAt =
stops[i]!.departureAt === undefined
? arrival + (stops[i]!.dwellMinutes ?? 0)
: Math.max(arrival, stops[i]!.departureAt! + shift);
endsAt = departures[i]! + shift;
break;
}