feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there

A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
This commit is contained in:
Maurice
2026-09-23 14:42:08 +02:00
parent 514e2e62ee
commit 7e7d13c021
43 changed files with 890 additions and 32 deletions
+2
View File
@@ -114,6 +114,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'ربط الأيام',
'roadtrip.line.dayColors': 'لون لكل يوم',
'roadtrip.line.hint': 'يحسب أيضًا المسافة بين يومين ويحتسبها على يوم الوصول.',
'roadtrip.line.hotelBookends': 'ابدأ كل يوم وأنهِه في مكان إقامتك',
'roadtrip.line.hotelBookendsHint': 'بعد ليلة محجوزة يبدأ اليوم حيث نمت، وقبل ليلة محجوزة ينتهي هناك.',
'roadtrip.avoid.section': 'التجنب قدر الإمكان',
'roadtrip.avoid.toll': 'الطرق ذات الرسوم',
'roadtrip.avoid.motorway': 'الطرق السريعة',
+3
View File
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Conectar os dias',
'roadtrip.line.dayColors': 'Uma cor por dia',
'roadtrip.line.hint': 'Também calcula o trecho entre dois dias e conta no dia de chegada.',
'roadtrip.line.hotelBookends': 'Começar e terminar cada dia na hospedagem',
'roadtrip.line.hotelBookendsHint':
'Depois de uma noite reservada, o dia começa onde você dormiu, e antes de uma ele termina lá.',
'roadtrip.avoid.section': 'Evitar quando possível',
'roadtrip.avoid.toll': 'Pedágios',
'roadtrip.avoid.motorway': 'Rodovias',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Connecta els dies',
'roadtrip.line.dayColors': 'Un color per dia',
'roadtrip.line.hint': 'També calcula el tram entre dos dies i el compta al dia d’arribada.',
'roadtrip.line.hotelBookends': 'Comença i acaba cada dia a l’allotjament',
'roadtrip.line.hotelBookendsHint':
'Després d’una nit reservada, el dia comença on has dormit, i abans d’una acaba allà.',
'roadtrip.avoid.section': 'Evita quan sigui possible',
'roadtrip.avoid.toll': 'Peatges',
'roadtrip.avoid.motorway': 'Autopistes',
+2
View File
@@ -115,6 +115,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Propojit dny',
'roadtrip.line.dayColors': 'Barva na den',
'roadtrip.line.hint': 'Spočítá i úsek mezi dvěma dny a připočte ho ke dni příjezdu.',
'roadtrip.line.hotelBookends': 'Začínat a končit každý den v ubytování',
'roadtrip.line.hotelBookendsHint': 'Po zarezervované noci začíná den tam, kde jste spali, a před ní tam končí.',
'roadtrip.avoid.section': 'Vyhnout se, pokud to jde',
'roadtrip.avoid.toll': 'Mýtné silnice',
'roadtrip.avoid.motorway': 'Dálnice',
+3
View File
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Tage verbinden',
'roadtrip.line.dayColors': 'Farbe je Tag',
'roadtrip.line.hint': 'Verbindungsfahrten zählen zum Ankunftstag.',
'roadtrip.line.hotelBookends': 'Jeden Tag an der Unterkunft beginnen und beenden',
'roadtrip.line.hotelBookendsHint':
'Nach einer gebuchten Nacht beginnt der Tag dort, wo du geschlafen hast, und vor einer endet er dort.',
'roadtrip.avoid.section': 'Möglichst meiden',
'roadtrip.avoid.toll': 'Mautstraßen',
'roadtrip.avoid.motorway': 'Autobahnen',
+3
View File
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Connect the days',
'roadtrip.line.dayColors': 'A colour per day',
'roadtrip.line.hint': 'Connecting drives count toward the arrival day.',
'roadtrip.line.hotelBookends': 'Start and end each day at your stay',
'roadtrip.line.hotelBookendsHint':
'After a booked night the day starts where you slept, and before one it ends there.',
'roadtrip.avoid.section': 'Avoid where possible',
'roadtrip.avoid.toll': 'Toll roads',
'roadtrip.avoid.motorway': 'Motorways',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Conectar los días',
'roadtrip.line.dayColors': 'Un color por día',
'roadtrip.line.hint': 'También calcula el tramo entre dos días y lo cuenta en el día de llegada.',
'roadtrip.line.hotelBookends': 'Empezar y terminar cada día en tu alojamiento',
'roadtrip.line.hotelBookendsHint':
'Tras una noche reservada, el día empieza donde dormiste, y antes de una termina allí.',
'roadtrip.avoid.section': 'Evitar si es posible',
'roadtrip.avoid.toll': 'Peajes',
'roadtrip.avoid.motorway': 'Autopistas',
+3
View File
@@ -117,6 +117,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Relier les jours',
'roadtrip.line.dayColors': 'Une couleur par jour',
'roadtrip.line.hint': 'Calcule aussi le trajet entre deux jours et le compte au jour d’arrivée.',
'roadtrip.line.hotelBookends': 'Commencer et finir chaque jour à votre hébergement',
'roadtrip.line.hotelBookendsHint':
'Après une nuit réservée, la journée commence là où vous avez dormi, et avant une nuit réservée, elle s’y termine.',
'roadtrip.avoid.section': 'Éviter si possible',
'roadtrip.avoid.toll': 'Routes à péage',
'roadtrip.avoid.motorway': 'Autoroutes',
+3
View File
@@ -118,6 +118,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Σύνδεση των ημερών',
'roadtrip.line.dayColors': 'Ένα χρώμα ανά ημέρα',
'roadtrip.line.hint': 'Υπολογίζει και το τμήμα ανάμεσα σε δύο ημέρες, στην ημέρα άφιξης.',
'roadtrip.line.hotelBookends': 'Έναρξη και λήξη κάθε ημέρας στο κατάλυμα',
'roadtrip.line.hotelBookendsHint':
'Μετά από μια κρατημένη νύχτα η ημέρα ξεκινά εκεί όπου κοιμηθήκατε, και πριν από μία τελειώνει εκεί.',
'roadtrip.avoid.section': 'Αποφυγή όπου γίνεται',
'roadtrip.avoid.toll': 'Δρόμοι με διόδια',
'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Napok összekötése',
'roadtrip.line.dayColors': 'Naponta egy szín',
'roadtrip.line.hint': 'A két nap közti szakaszt is kiszámolja, és az érkezés napjához adja.',
'roadtrip.line.hotelBookends': 'Minden nap a szálláson kezdődjön és érjen véget',
'roadtrip.line.hotelBookendsHint':
'Egy lefoglalt éjszaka után a nap ott kezdődik, ahol aludtál, egy lefoglalt éjszaka előtt pedig ott ér véget.',
'roadtrip.avoid.section': 'Kerülés, ha lehet',
'roadtrip.avoid.toll': 'Fizetős utak',
'roadtrip.avoid.motorway': 'Autópályák',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Hubungkan hari',
'roadtrip.line.dayColors': 'Satu warna per hari',
'roadtrip.line.hint': 'Juga menghitung ruas antara dua hari dan memasukkannya ke hari kedatangan.',
'roadtrip.line.hotelBookends': 'Mulai dan akhiri setiap hari di penginapan Anda',
'roadtrip.line.hotelBookendsHint':
'Setelah malam yang dipesan, hari dimulai di tempat Anda menginap, dan sebelum malam yang dipesan, hari berakhir di sana.',
'roadtrip.avoid.section': 'Hindari bila bisa',
'roadtrip.avoid.toll': 'Jalan tol',
'roadtrip.avoid.motorway': 'Jalan bebas hambatan',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Collega i giorni',
'roadtrip.line.dayColors': 'Un colore per giorno',
'roadtrip.line.hint': 'Calcola anche il tratto tra due giorni e lo conta nel giorno di arrivo.',
'roadtrip.line.hotelBookends': 'Inizia e termina ogni giorno al tuo alloggio',
'roadtrip.line.hotelBookendsHint':
'Dopo una notte prenotata la giornata parte da dove hai dormito, e prima di una finisce lì.',
'roadtrip.avoid.section': 'Evita se possibile',
'roadtrip.avoid.toll': 'Strade a pedaggio',
'roadtrip.avoid.motorway': 'Autostrade',
+2
View File
@@ -116,6 +116,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '日をつなぐ',
'roadtrip.line.dayColors': '日ごとに色を分ける',
'roadtrip.line.hint': '日と日の間の区間も計算し、到着した日に加えます。',
'roadtrip.line.hotelBookends': '毎日を宿泊先で始めて宿泊先で終える',
'roadtrip.line.hotelBookendsHint': '予約した夜の翌日は泊まった宿から始まり、予約した夜の日はその宿で終わります。',
'roadtrip.avoid.section': 'できるだけ避ける',
'roadtrip.avoid.toll': '有料道路',
'roadtrip.avoid.motorway': '高速道路',
+3
View File
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '날짜 잇기',
'roadtrip.line.dayColors': '날짜별 색상',
'roadtrip.line.hint': '두 날 사이 구간도 계산해 도착한 날에 더합니다.',
'roadtrip.line.hotelBookends': '매일 숙소에서 시작하고 숙소에서 끝내기',
'roadtrip.line.hotelBookendsHint':
'예약한 밤 다음 날은 묵은 곳에서 시작하고, 예약한 밤이 있는 날은 그곳에서 끝납니다.',
'roadtrip.avoid.section': '가능하면 피하기',
'roadtrip.avoid.toll': '유료 도로',
'roadtrip.avoid.motorway': '고속도로',
+3
View File
@@ -117,6 +117,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Dagen verbinden',
'roadtrip.line.dayColors': 'Een kleur per dag',
'roadtrip.line.hint': 'Berekent ook het stuk tussen twee dagen en telt het bij de aankomstdag.',
'roadtrip.line.hotelBookends': 'Elke dag bij je verblijf beginnen en eindigen',
'roadtrip.line.hotelBookendsHint':
'Na een geboekte nacht begint de dag waar je sliep, en vóór een geboekte nacht eindigt hij daar.',
'roadtrip.avoid.section': 'Vermijden waar het kan',
'roadtrip.avoid.toll': 'Tolwegen',
'roadtrip.avoid.motorway': 'Snelwegen',
+3
View File
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Połącz dni',
'roadtrip.line.dayColors': 'Kolor na dzień',
'roadtrip.line.hint': 'Wyznacza też odcinek między dniami i liczy go do dnia przyjazdu.',
'roadtrip.line.hotelBookends': 'Zaczynaj i kończ każdy dzień w noclegu',
'roadtrip.line.hotelBookendsHint':
'Po zarezerwowanej nocy dzień zaczyna się w miejscu noclegu, a przed taką nocą tam się kończy.',
'roadtrip.avoid.section': 'Omijaj, jeśli się da',
'roadtrip.avoid.toll': 'Drogi płatne',
'roadtrip.avoid.motorway': 'Autostrady',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Соединить дни',
'roadtrip.line.dayColors': 'Цвет на день',
'roadtrip.line.hint': 'Также считает участок между двумя днями и относит его ко дню прибытия.',
'roadtrip.line.hotelBookends': 'Начинать и заканчивать каждый день в месте проживания',
'roadtrip.line.hotelBookendsHint':
'После забронированной ночи день начинается там, где вы ночевали, а перед ней заканчивается там.',
'roadtrip.avoid.section': 'Избегать по возможности',
'roadtrip.avoid.toll': 'Платные дороги',
'roadtrip.avoid.motorway': 'Автомагистрали',
+3
View File
@@ -114,6 +114,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Koppla ihop dagarna',
'roadtrip.line.dayColors': 'En färg per dag',
'roadtrip.line.hint': 'Beräknar även sträckan mellan två dagar och räknar den på ankomstdagen.',
'roadtrip.line.hotelBookends': 'Börja och avsluta varje dag vid ditt boende',
'roadtrip.line.hotelBookendsHint':
'Efter en bokad natt börjar dagen där du sov, och före en bokad natt slutar den där.',
'roadtrip.avoid.section': 'Undvik om det går',
'roadtrip.avoid.toll': 'Avgiftsvägar',
'roadtrip.avoid.motorway': 'Motorvägar',
+3
View File
@@ -116,6 +116,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Günleri birleştir',
'roadtrip.line.dayColors': 'Her güne bir renk',
'roadtrip.line.hint': 'İki gün arasındaki yolu da hesaplar ve varış gününe sayar.',
'roadtrip.line.hotelBookends': 'Her güne konaklama yerinde başla ve orada bitir',
'roadtrip.line.hotelBookendsHint':
'Rezerve edilmiş bir geceden sonra gün uyuduğunuz yerde başlar, böyle bir geceden önce de orada biter.',
'roadtrip.avoid.section': 'Mümkünse kaçın',
'roadtrip.avoid.toll': 'Paralı yollar',
'roadtrip.avoid.motorway': 'Otoyollar',
+3
View File
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'З’єднати дні',
'roadtrip.line.dayColors': 'Колір на день',
'roadtrip.line.hint': 'Також рахує ділянку між двома днями і зараховує її дню прибуття.',
'roadtrip.line.hotelBookends': 'Починати й завершувати кожен день у місці проживання',
'roadtrip.line.hotelBookendsHint':
'Після заброньованої ночі день починається там, де ви ночували, а перед нею там і закінчується.',
'roadtrip.avoid.section': 'Уникати за можливості',
'roadtrip.avoid.toll': 'Платні дороги',
'roadtrip.avoid.motorway': 'Автомагістралі',
+3
View File
@@ -115,6 +115,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': 'Nối các ngày',
'roadtrip.line.dayColors': 'Mỗi ngày một màu',
'roadtrip.line.hint': 'Cũng tính đoạn giữa hai ngày và cộng vào ngày tới nơi.',
'roadtrip.line.hotelBookends': 'Bắt đầu và kết thúc mỗi ngày tại nơi lưu trú',
'roadtrip.line.hotelBookendsHint':
'Sau một đêm đã đặt, ngày bắt đầu ở nơi bạn ngủ, và trước một đêm đã đặt, ngày kết thúc ở đó.',
'roadtrip.avoid.section': 'Tránh nếu có thể',
'roadtrip.avoid.toll': 'Đường thu phí',
'roadtrip.avoid.motorway': 'Đường cao tốc',
+2
View File
@@ -109,6 +109,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '連接各天',
'roadtrip.line.dayColors': '每天一種顏色',
'roadtrip.line.hint': '也會規劃兩天之間的路段,並計入抵達的那一天。',
'roadtrip.line.hotelBookends': '每天從住宿地點出發,並在住宿地點結束',
'roadtrip.line.hotelBookendsHint': '預訂的一晚之後,當天從你住的地方出發;預訂的一晚之前,當天在那裡結束。',
'roadtrip.avoid.section': '盡量避開',
'roadtrip.avoid.toll': '收費公路',
'roadtrip.avoid.motorway': '高速公路',
+2
View File
@@ -109,6 +109,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.line.connect': '连接各天',
'roadtrip.line.dayColors': '每天一种颜色',
'roadtrip.line.hint': '也会规划两天之间的路段,并计入抵达的那一天。',
'roadtrip.line.hotelBookends': '每天从住处出发,并在住处结束',
'roadtrip.line.hotelBookendsHint': '预订的一晚之后,当天从你住的地方出发;预订的一晚之前,当天在那里结束。',
'roadtrip.avoid.section': '尽量避开',
'roadtrip.avoid.toll': '收费公路',
'roadtrip.avoid.motorway': '高速公路',
@@ -20,6 +20,17 @@ describe('Roadtrip MCP contracts', () => {
])('rejects invalid driving preferences %j', (settings) => {
expect(roadtripPreferencesUpdateSchema.safeParse(settings).success).toBe(false);
});
it('takes the switch that starts and ends each day at the stay as a boolean, and nothing else', () => {
expect(roadtripPreferencesUpdateSchema.parse({ roadtrip_hotel_bookends: true })).toEqual({
roadtrip_hotel_bookends: true,
});
expect(roadtripPreferencesUpdateSchema.parse({ roadtrip_hotel_bookends: false })).toEqual({
roadtrip_hotel_bookends: false,
});
for (const value of ['true', 1, null, 'on']) {
expect(roadtripPreferencesUpdateSchema.safeParse({ roadtrip_hotel_bookends: value }).success).toBe(false);
}
});
it('allows clearing settings and preserves manual controls', () => {
expect(
roadtripPreferencesUpdateSchema.parse({
@@ -22,6 +22,10 @@ export const roadtripPreferencesSchema = z
roadtrip_service_stops_in_days: z.boolean().optional(),
roadtrip_day_colors: z.boolean().optional(),
roadtrip_show_hazards: z.boolean().optional(),
// Whether a day after a booked night starts at the stay and a day before one ends
// there (`hotelBookendsOn`). Missing means off, so a trip drives as it always has
// until somebody switches it on.
roadtrip_hotel_bookends: z.boolean().optional(),
roadtrip_avoid: z
.string()
.max(32)