fix(roadtrip): a late check-out morning says so, a road driven twice takes its via

The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
This commit is contained in:
Maurice
2026-09-23 16:01:40 +02:00
parent e6e520b78f
commit 05f533dd0d
34 changed files with 307 additions and 41 deletions
+1
View File
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'من {name}',
'roadtrip.bookend.back': 'العودة إلى {name}',
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
'roadtrip.bookend.afterCheckOut': 'الانطلاق بعد موعد تسجيل المغادرة',
'roadtrip.bookend.noVia': 'لا يمكن وضع نقطة عبور على الطريق من مكان إقامتك أو إليه. أضف توقفاً هناك بدلاً من ذلك.',
};
export default roadtrip;
+1
View File
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Saindo de {name}',
'roadtrip.bookend.back': 'Volta para {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Saída depois do check-out',
'roadtrip.bookend.noVia':
'Não dá para colocar um ponto de passagem no trajeto de ida ou volta da sua hospedagem. Adicione uma parada ali.',
};
+1
View File
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Des de {name}',
'roadtrip.bookend.back': 'Tornada a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.afterCheckOut': 'Marxa després de l’hora de sortida',
'roadtrip.bookend.noVia':
'No es pot posar cap punt de pas al trajecte fins al teu allotjament o des d’ell. Afegeix-hi una parada.',
};
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Z ubytování {name}',
'roadtrip.bookend.back': 'Zpět do ubytování {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Odjezd až po check-outu',
'roadtrip.bookend.noVia':
'Na trasu do ubytování ani z něj nelze přidat průjezdní bod. Přidejte tam místo toho zastávku.',
};
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Ab {name}',
'roadtrip.bookend.back': 'Zurück zu {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Abfahrt nach dem Check-out',
'roadtrip.bookend.noVia':
'Auf der Fahrt zur oder von der Unterkunft geht kein Zwischenpunkt. Füge dort stattdessen einen Stopp hinzu.',
};
+1
View File
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'From {name}',
'roadtrip.bookend.back': 'Back to {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Leaves after check-out',
'roadtrip.bookend.noVia': 'No via point on the drive to or from your stay. Add a stop there instead.',
};
export default roadtrip;
+1
View File
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Desde {name}',
'roadtrip.bookend.back': 'Vuelta a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.afterCheckOut': 'Parte después de la hora de salida',
'roadtrip.bookend.noVia':
'No se puede poner un punto de paso en el trayecto hacia tu alojamiento o desde él. Añade una parada allí.',
};
+1
View File
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Depuis {name}',
'roadtrip.bookend.back': 'Retour à {name}',
'roadtrip.bookend.checkIn': 'Arrivée · {name}',
'roadtrip.bookend.afterCheckOut': 'Part après l’heure limite de départ',
'roadtrip.bookend.noVia':
'Pas de point de passage sur le trajet vers ou depuis votre hébergement. Ajoutez plutôt un arrêt à cet endroit.',
};
+1
View File
@@ -301,6 +301,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Από {name}',
'roadtrip.bookend.back': 'Επιστροφή: {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Αναχώρηση μετά το check-out',
'roadtrip.bookend.noVia':
'Δεν μπαίνει ενδιάμεσο σημείο στη διαδρομή προς ή από το κατάλυμα. Προσθέστε εκεί μια στάση.',
};
+1
View File
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Indulás: {name}',
'roadtrip.bookend.back': 'Vissza: {name}',
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
'roadtrip.bookend.afterCheckOut': 'Indulás kijelentkezés után',
'roadtrip.bookend.noVia':
'A szálláshoz vezető vagy onnan induló útra nem kerülhet köztes pont. Adj hozzá inkább egy megállót ott.',
};
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Dari {name}',
'roadtrip.bookend.back': 'Kembali ke {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Berangkat setelah check-out',
'roadtrip.bookend.noVia':
'Titik lintas tidak bisa ditaruh di perjalanan ke atau dari penginapan Anda. Tambahkan perhentian di sana.',
};
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Da {name}',
'roadtrip.bookend.back': 'Ritorno a {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Partenza dopo il check-out',
'roadtrip.bookend.noVia':
'Nessun punto di passaggio sul tragitto verso o dal tuo alloggio. Aggiungi invece una sosta in quel punto.',
};
+1
View File
@@ -292,6 +292,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}から',
'roadtrip.bookend.back': '{name}へ戻る',
'roadtrip.bookend.checkIn': 'チェックイン · {name}',
'roadtrip.bookend.afterCheckOut': 'チェックアウト後に出発',
'roadtrip.bookend.noVia': '宿泊先との間の区間には経由地を置けません。代わりにそこへ立ち寄り先を追加してください。',
};
export default roadtrip;
+1
View File
@@ -289,6 +289,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}에서 출발',
'roadtrip.bookend.back': '{name}(으)로 돌아가기',
'roadtrip.bookend.checkIn': '체크인 · {name}',
'roadtrip.bookend.afterCheckOut': '체크아웃 후 출발',
'roadtrip.bookend.noVia': '숙소를 오가는 구간에는 경유지를 둘 수 없습니다. 대신 그곳에 정차 지점을 추가하세요.',
};
export default roadtrip;
+1
View File
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Vanaf {name}',
'roadtrip.bookend.back': 'Terug naar {name}',
'roadtrip.bookend.checkIn': 'Inchecken · {name}',
'roadtrip.bookend.afterCheckOut': 'Vertrek na het uitchecken',
'roadtrip.bookend.noVia':
'Geen tussenpunt op de rit naar of van je verblijf. Voeg daar in plaats daarvan een stop toe.',
};
+1
View File
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Wyjazd: {name}',
'roadtrip.bookend.back': 'Powrót: {name}',
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
'roadtrip.bookend.afterCheckOut': 'Wyjazd po godzinie wymeldowania',
'roadtrip.bookend.noVia':
'Na trasie do noclegu ani z niego nie można dodać punktu pośredniego. Dodaj tam zamiast tego postój.',
};
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Отправление: {name}',
'roadtrip.bookend.back': 'Возвращение: {name}',
'roadtrip.bookend.checkIn': 'Заезд · {name}',
'roadtrip.bookend.afterCheckOut': 'Отъезд позже расчётного часа',
'roadtrip.bookend.noVia':
'На маршрут до места проживания или от него нельзя поставить промежуточную точку. Добавьте там остановку.',
};
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Från {name}',
'roadtrip.bookend.back': 'Tillbaka till {name}',
'roadtrip.bookend.checkIn': 'Incheckning · {name}',
'roadtrip.bookend.afterCheckOut': 'Avresa efter utcheckning',
'roadtrip.bookend.noVia':
'Ingen mellanpunkt på resan till eller från ditt boende. Lägg till ett stopp där i stället.',
};
+1
View File
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Başlangıç: {name}',
'roadtrip.bookend.back': 'Dönüş: {name}',
'roadtrip.bookend.checkIn': 'Giriş · {name}',
'roadtrip.bookend.afterCheckOut': 'Çıkış saatinden sonra hareket',
'roadtrip.bookend.noVia':
'Konaklama yerinize giden ya da oradan çıkan yola ara nokta konamaz. Bunun yerine oraya bir mola ekleyin.',
};
+1
View File
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Відправлення: {name}',
'roadtrip.bookend.back': 'Повернення: {name}',
'roadtrip.bookend.checkIn': 'Заїзд · {name}',
'roadtrip.bookend.afterCheckOut': 'Від’їзд пізніше розрахункової години',
'roadtrip.bookend.noVia':
'На маршрут до місця проживання чи від нього не можна поставити проміжну точку. Додайте там зупинку.',
};
+1
View File
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Từ {name}',
'roadtrip.bookend.back': 'Về lại {name}',
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
'roadtrip.bookend.afterCheckOut': 'Khởi hành sau giờ trả phòng',
'roadtrip.bookend.noVia':
'Không thể đặt điểm trung gian trên chặng đi hoặc về nơi lưu trú. Hãy thêm một điểm dừng ở đó.',
};
+1
View File
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '從 {name} 出發',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.afterCheckOut': '退房後才出發',
'roadtrip.bookend.noVia': '往返住宿地點的路段無法加入途經點。請改在那裡新增一個停靠點。',
};
export default roadtrip;
+1
View File
@@ -279,6 +279,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '从 {name} 出发',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.afterCheckOut': '退房后才出发',
'roadtrip.bookend.noVia': '往返住处的路段不能添加途经点。请改为在那里添加一个停靠点。',
};
export default roadtrip;
+39
View File
@@ -89,6 +89,45 @@ describe('projectOntoRoute', () => {
expect(projectOntoRoute(BERLIN, [])).toBeNull();
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull();
});
describe('within a stretch of the drive', () => {
// Out along a straight road and back over it: every point of the road is on the
// line twice, once on the way out and once on the way back.
const out = { lat: 52, lng: 13 };
const tip = { lat: 52, lng: 14 };
const outAndBack = [out, tip, out];
const onRoad = { lat: 52, lng: 13.3 };
it('answers with the first pass for the whole line', () => {
const whole = projectOntoRoute(onRoad, outAndBack)!;
const oneWay = haversineKm(out, tip);
expect(whole.offRouteKm).toBeLessThan(0.01);
expect(whole.alongKm).toBeLessThan(oneWay / 2);
});
it('finds the second pass when asked for the way back', () => {
const oneWay = haversineKm(out, tip);
const back = projectOntoRoute(onRoad, outAndBack, { fromKm: oneWay, toKm: 2 * oneWay })!;
expect(back.offRouteKm).toBeLessThan(0.01);
expect(back.alongKm).toBeGreaterThan(oneWay);
expect(back.alongKm).toBeCloseTo(2 * oneWay - haversineKm(out, onRoad), 1);
});
it('measures to the edge of the stretch when the point lies beyond it', () => {
const straight = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 15 },
];
const length = haversineKm(straight[0]!, straight[1]!);
const hit = projectOntoRoute({ lat: 52, lng: 13.1 }, straight, { fromKm: length / 2, toKm: length })!;
expect(hit.alongKm).toBeCloseTo(length / 2, 3);
expect(hit.offRouteKm).toBeGreaterThan(55);
});
it('answers nothing for a stretch past the end of the line', () => {
expect(projectOntoRoute(onRoad, outAndBack, { fromKm: 10_000, toKm: 20_000 })).toBeNull();
});
});
});
describe('corridorTiles', () => {
+32 -4
View File
@@ -59,22 +59,50 @@ export interface CorridorHit {
alongKm: number;
}
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
/**
* Where `p` meets the line: how far off it is and how far into the drive the closest
* point comes.
*
* `within` keeps the answer to one stretch of the drive, in km from its start. A road
* driven twice in a day, out of a hotel and back past it later, is the same line twice
* over, and the answer for the whole line is always the first pass; a caller that needs
* the second one asks for the stretch it lies in. No part of that stretch on the line
* means no answer.
*/
export function projectOntoRoute(
p: LatLng,
line: LatLng[],
within?: { fromKm: number; toKm: number },
): CorridorHit | null {
if (line.length < 2) return null;
let best = Number.POSITIVE_INFINITY;
let bestAlong = 0;
let travelled = 0;
let measured = false;
for (let i = 0; i < line.length - 1; i++) {
const a = line[i]!;
const b = line[i + 1]!;
const segment = haversineKm(a, b);
const { distanceKm: d, t } = projectOnSegment(p, a, b);
const start = travelled;
travelled += segment;
if (within && (travelled < within.fromKm || start > within.toKm)) continue;
measured = true;
let { distanceKm: d, t } = projectOnSegment(p, a, b);
if (within && segment > 0) {
// A segment the stretch starts or ends inside counts only up to that edge.
const lo = Math.max(0, (within.fromKm - start) / segment);
const hi = Math.min(1, (within.toKm - start) / segment);
if (t < lo || t > hi) {
t = t < lo ? lo : hi;
d = haversineKm(p, { lat: a.lat + t * (b.lat - a.lat), lng: a.lng + t * (b.lng - a.lng) });
}
}
if (d < best) {
best = d;
bestAlong = travelled + t * segment;
bestAlong = start + t * segment;
}
travelled += segment;
}
if (within && !measured) return null;
return { offRouteKm: best, alongKm: bestAlong };
}