feat(tours): plan, create and change hikes on the server, for the assistant too

The hiking costing moves to shared, so the planner and the server ask the
router the same way. The road trip router learns to plan a hike on foot
with that costing (Valhalla only: OSRM knows no trail difficulty), to read
heights along a line in chunks and to read the OSM ways a line follows,
all through its one paced queue. A hike router builds on it: the way, its
heights, distance, climb and DIN walking time, and the SAC grades of its
stretches from the trail grade service, each part best effort.

New MCP tools in HikePlanMcp, in the assistant's tours group:
- propose_hike plans a hike without saving it.
- create_hike saves a confirmed hike through the planner's own creation;
  a router that does not answer leaves it pending for the planner, a way
  that cannot be planned is refused and nothing is saved.
- update_tour changes a saved hike against its revision, keeping linked
  stations and refusing what would silently drop drawn legs or stages.
- import_tour_gpx takes a GPX file's text in as tours, like the import.
Same permission checks as REST, broadcasts from the services.

In the assistant a new hike or city walk is an "Add tour" card with its
route and difficulty, undone through delete_place while the tour is
untouched, and a hike being planned shows as a live step.
This commit is contained in:
Maurice
2026-10-22 00:03:12 +00:00
parent 24b7e607c3
commit aea37d9914
71 changed files with 5041 additions and 124 deletions
+6
View File
@@ -67,6 +67,10 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
'addReservation',
'importBooking',
'rememberNote',
// A tour: a hike or a city walk created, a hike changed, tours taken in from a GPX file.
'addTour',
'updateTour',
'importTour',
// Any other tool: by what its annotations say it does.
'genericCreate',
'genericUpdate',
@@ -113,6 +117,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'stay',
'members',
'route',
// The hardest SAC grade a hike's way may take, T1 to T6.
'difficulty',
// A new order of a day's places or of the days, as the names in that order.
'order',
// An argument of a tool without a card of its own; the row names it in `field`.
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'التحقق من التكاليف',
'assistant.step.help': 'البحث في المساعدة',
'assistant.step.readingBooking': 'قراءة الحجز',
'assistant.step.planningHike': 'تخطيط رحلة المشي',
'assistant.step.planningHikeTo': 'تخطيط رحلة المشي إلى {place}',
'assistant.step.working': 'جارٍ العمل على ذلك',
'assistant.step.collapsed': '{count} خطوة',
'assistant.step.collapsed.zero': 'لا خطوات',
@@ -471,7 +473,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'إضافة',
'assistant.change.verb.genericUpdate': 'تغيير',
'assistant.change.verb.genericRemove': 'إزالة',
'assistant.change.verb.addTour': 'إضافة جولة',
'assistant.change.verb.updateTour': 'تعديل الجولة',
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
'assistant.change.label.field': 'القيمة',
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
'assistant.domain.tours': 'الجولات',
'assistant.domain.roadtrip': 'رحلة برية',
'assistant.domain.journey': 'يوميات السفر',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Xərclər yoxlanılır',
'assistant.step.help': 'Kömək bölməsində axtarılır',
'assistant.step.readingBooking': 'Bron oxunur',
'assistant.step.planningHike': 'Yürüş planlaşdırılır',
'assistant.step.planningHikeTo': '{place} istiqamətində yürüş planlaşdırılır',
'assistant.step.working': 'Üzərində işləyirəm',
'assistant.step.collapsed': '{count} addım',
'assistant.step.collapsed.one': '{count} addım',
@@ -391,7 +393,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Əlavə et',
'assistant.change.verb.genericUpdate': 'Dəyiş',
'assistant.change.verb.genericRemove': 'Sil',
'assistant.change.verb.addTour': 'Tur əlavə et',
'assistant.change.verb.updateTour': 'Turu dəyiş',
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
'assistant.change.label.field': 'Dəyər',
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Avtosəyahət',
'assistant.domain.journey': 'Səyahət gündəliyi',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Verificando os custos',
'assistant.step.help': 'Consultando a ajuda',
'assistant.step.readingBooking': 'Lendo a reserva',
'assistant.step.planningHike': 'Planejando a trilha',
'assistant.step.planningHikeTo': 'Planejando a trilha até {place}',
'assistant.step.working': 'Trabalhando nisso',
'assistant.step.collapsed': '{count} etapas',
'assistant.step.collapsed.one': '{count} etapa',
@@ -392,7 +394,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Adicionar',
'assistant.change.verb.genericUpdate': 'Alterar',
'assistant.change.verb.genericRemove': 'Remover',
'assistant.change.verb.addTour': 'Adicionar passeio',
'assistant.change.verb.updateTour': 'Alterar passeio',
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
'assistant.domain.tours': 'Passeios',
'assistant.domain.roadtrip': 'Viagem de carro',
'assistant.domain.journey': 'Jornada',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisant les despeses',
'assistant.step.help': 'Cercant a l’ajuda',
'assistant.step.readingBooking': 'Llegint la reserva',
'assistant.step.planningHike': 'Planificant l’excursió',
'assistant.step.planningHikeTo': 'Planificant l’excursió fins a {place}',
'assistant.step.working': 'Hi estic treballant',
'assistant.step.collapsed': '{count} passos',
'assistant.step.collapsed.one': '{count} pas',
@@ -394,7 +396,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Afegeix',
'assistant.change.verb.genericUpdate': 'Canvia',
'assistant.change.verb.genericRemove': 'Elimina',
'assistant.change.verb.addTour': 'Afegeix una excursió',
'assistant.change.verb.updateTour': 'Canvia l’excursió',
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
'assistant.domain.tours': 'Rutes',
'assistant.domain.roadtrip': 'Viatge per carretera',
'assistant.domain.journey': 'Diari de viatge',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontroluji náklady',
'assistant.step.help': 'Hledám v nápovědě',
'assistant.step.readingBooking': 'Čtu rezervaci',
'assistant.step.planningHike': 'Plánuji túru',
'assistant.step.planningHikeTo': 'Plánuji túru do místa {place}',
'assistant.step.working': 'Pracuji na tom',
'assistant.step.collapsed': '{count} kroků',
'assistant.step.collapsed.one': '{count} krok',
@@ -410,7 +412,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Přidat',
'assistant.change.verb.genericUpdate': 'Změnit',
'assistant.change.verb.genericRemove': 'Odebrat',
'assistant.change.verb.addTour': 'Přidat výlet',
'assistant.change.verb.updateTour': 'Změnit výlet',
'assistant.change.verb.importTour': 'Přidat výlet ze souboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximální obtížnost stezky',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autem',
'assistant.domain.journey': 'Cestovní deník',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Prüft die Kosten',
'assistant.step.help': 'Schaut in der Hilfe nach',
'assistant.step.readingBooking': 'Liest die Buchung',
'assistant.step.planningHike': 'Plant die Wanderung',
'assistant.step.planningHikeTo': 'Plant die Wanderung bis {place}',
'assistant.step.working': 'Arbeitet daran',
'assistant.step.collapsed': '{count} Schritte',
'assistant.step.collapsed.one': '{count} Schritt',
@@ -391,7 +393,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Hinzufügen',
'assistant.change.verb.genericUpdate': 'Ändern',
'assistant.change.verb.genericRemove': 'Entfernen',
'assistant.change.verb.addTour': 'Tour hinzufügen',
'assistant.change.verb.updateTour': 'Tour ändern',
'assistant.change.verb.importTour': 'Tour aus Datei hinzufügen',
'assistant.change.label.field': 'Wert',
'assistant.change.label.difficulty': 'Maximale Wegschwierigkeit',
'assistant.domain.tours': 'Touren',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisetagebuch',
+6
View File
@@ -110,6 +110,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Checking the costs',
'assistant.step.help': 'Looking it up in the help',
'assistant.step.readingBooking': 'Reading the booking',
'assistant.step.planningHike': 'Planning the hike',
'assistant.step.planningHikeTo': 'Planning the hike to {place}',
'assistant.step.working': 'Working on it',
'assistant.step.collapsed': '{count} steps',
'assistant.step.collapsed.one': '{count} step',
@@ -386,7 +388,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Add',
'assistant.change.verb.genericUpdate': 'Change',
'assistant.change.verb.genericRemove': 'Remove',
'assistant.change.verb.addTour': 'Add tour',
'assistant.change.verb.updateTour': 'Change tour',
'assistant.change.verb.importTour': 'Add tour from file',
'assistant.change.label.field': 'Value',
'assistant.change.label.difficulty': 'Maximum trail difficulty',
'assistant.domain.tours': 'Tours',
'assistant.domain.roadtrip': 'Road trip',
'assistant.domain.journey': 'Journey',
+6
View File
@@ -112,6 +112,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisando los gastos',
'assistant.step.help': 'Buscando en la ayuda',
'assistant.step.readingBooking': 'Leyendo la reserva',
'assistant.step.planningHike': 'Planificando la excursión',
'assistant.step.planningHikeTo': 'Planificando la excursión hasta {place}',
'assistant.step.working': 'Trabajando en ello',
'assistant.step.collapsed': '{count} pasos',
'assistant.step.collapsed.one': '{count} paso',
@@ -389,7 +391,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Añadir',
'assistant.change.verb.genericUpdate': 'Cambiar',
'assistant.change.verb.genericRemove': 'Quitar',
'assistant.change.verb.addTour': 'Añadir excursión',
'assistant.change.verb.updateTour': 'Cambiar excursión',
'assistant.change.verb.importTour': 'Añadir excursión desde archivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultad máxima del sendero',
'assistant.domain.tours': 'Rutas',
'assistant.domain.roadtrip': 'Viaje por carretera',
'assistant.domain.journey': 'Diario de viaje',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollin kulusid',
'assistant.step.help': 'Otsin abist',
'assistant.step.readingBooking': 'Loen broneeringut',
'assistant.step.planningHike': 'Planeerin matka',
'assistant.step.planningHikeTo': 'Planeerin matka sihtkohta {place}',
'assistant.step.working': 'Töötan selle kallal',
'assistant.step.collapsed': '{count} sammu',
'assistant.step.collapsed.one': '{count} samm',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Lisa',
'assistant.change.verb.genericUpdate': 'Muuda',
'assistant.change.verb.genericRemove': 'Eemalda',
'assistant.change.verb.addTour': 'Lisa retk',
'assistant.change.verb.updateTour': 'Muuda retke',
'assistant.change.verb.importTour': 'Lisa retk failist',
'assistant.change.label.field': 'Väärtus',
'assistant.change.label.difficulty': 'Raja suurim raskusaste',
'assistant.domain.tours': 'Matkad',
'assistant.domain.roadtrip': 'Autoreis',
'assistant.domain.journey': 'Reisipäevik',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Vérification des dépenses',
'assistant.step.help': 'Recherche dans l’aide',
'assistant.step.readingBooking': 'Lecture de la réservation',
'assistant.step.planningHike': 'Planification de la randonnée',
'assistant.step.planningHikeTo': 'Planification de la randonnée jusqu’à {place}',
'assistant.step.working': 'Travail en cours',
'assistant.step.collapsed': '{count} étapes',
'assistant.step.collapsed.one': '{count} étape',
@@ -396,7 +398,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Ajouter',
'assistant.change.verb.genericUpdate': 'Modifier',
'assistant.change.verb.genericRemove': 'Supprimer',
'assistant.change.verb.addTour': 'Ajouter une sortie',
'assistant.change.verb.updateTour': 'Modifier la sortie',
'assistant.change.verb.importTour': 'Ajouter une sortie depuis un fichier',
'assistant.change.label.field': 'Valeur',
'assistant.change.label.difficulty': 'Difficulté maximale du sentier',
'assistant.domain.tours': 'Randonnées',
'assistant.domain.roadtrip': 'Voyage en voiture',
'assistant.domain.journey': 'Carnet de voyage',
+6
View File
@@ -116,6 +116,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Έλεγχος εξόδων',
'assistant.step.help': 'Αναζήτηση στη βοήθεια',
'assistant.step.readingBooking': 'Διαβάζω την κράτηση',
'assistant.step.planningHike': 'Σχεδιάζω την πεζοπορία',
'assistant.step.planningHikeTo': 'Σχεδιάζω την πεζοπορία προς {place}',
'assistant.step.working': 'Δουλεύω πάνω σε αυτό',
'assistant.step.collapsed': '{count} βήματα',
'assistant.step.collapsed.one': '{count} βήμα',
@@ -395,7 +397,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Προσθήκη',
'assistant.change.verb.genericUpdate': 'Αλλαγή',
'assistant.change.verb.genericRemove': 'Αφαίρεση',
'assistant.change.verb.addTour': 'Προσθήκη εκδρομής',
'assistant.change.verb.updateTour': 'Αλλαγή εκδρομής',
'assistant.change.verb.importTour': 'Προσθήκη εκδρομής από αρχείο',
'assistant.change.label.field': 'Τιμή',
'assistant.change.label.difficulty': 'Μέγιστη δυσκολία μονοπατιού',
'assistant.domain.tours': 'Διαδρομές',
'assistant.domain.roadtrip': 'Οδικό ταξίδι',
'assistant.domain.journey': 'Ημερολόγιο ταξιδιού',
+6
View File
@@ -115,6 +115,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'A költségek ellenőrzése',
'assistant.step.help': 'Utánanézés a súgóban',
'assistant.step.readingBooking': 'A foglalás olvasása',
'assistant.step.planningHike': 'A túra tervezése',
'assistant.step.planningHikeTo': 'Túra tervezése ide: {place}',
'assistant.step.working': 'Dolgozom rajta',
'assistant.step.collapsed': '{count} lépés',
'assistant.step.collapsed.one': '{count} lépés',
@@ -396,7 +398,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Hozzáadás',
'assistant.change.verb.genericUpdate': 'Módosítás',
'assistant.change.verb.genericRemove': 'Eltávolítás',
'assistant.change.verb.addTour': 'Túra hozzáadása',
'assistant.change.verb.updateTour': 'Túra módosítása',
'assistant.change.verb.importTour': 'Túra hozzáadása fájlból',
'assistant.change.label.field': 'Érték',
'assistant.change.label.difficulty': 'Ösvény legnagyobb nehézsége',
'assistant.domain.tours': 'Túrák',
'assistant.domain.roadtrip': 'Autós körút',
'assistant.domain.journey': 'Útinapló',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Memeriksa biaya',
'assistant.step.help': 'Mencari di bantuan',
'assistant.step.readingBooking': 'Membaca pemesanan',
'assistant.step.planningHike': 'Merencanakan pendakian',
'assistant.step.planningHikeTo': 'Merencanakan pendakian ke {place}',
'assistant.step.working': 'Sedang dikerjakan',
'assistant.step.collapsed': '{count} langkah',
'assistant.change.title': 'Perubahan yang disarankan',
@@ -371,7 +373,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Tambah',
'assistant.change.verb.genericUpdate': 'Ubah',
'assistant.change.verb.genericRemove': 'Hapus',
'assistant.change.verb.addTour': 'Tambah tur',
'assistant.change.verb.updateTour': 'Ubah tur',
'assistant.change.verb.importTour': 'Tambah tur dari file',
'assistant.change.label.field': 'Nilai',
'assistant.change.label.difficulty': 'Tingkat kesulitan jalur maksimum',
'assistant.domain.tours': 'Tur',
'assistant.domain.roadtrip': 'Perjalanan darat',
'assistant.domain.journey': 'Jurnal perjalanan',
+6
View File
@@ -115,6 +115,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Controllo i costi',
'assistant.step.help': 'Cerco nella guida',
'assistant.step.readingBooking': 'Leggo la prenotazione',
'assistant.step.planningHike': 'Pianifico l’escursione',
'assistant.step.planningHikeTo': 'Pianifico l’escursione fino a {place}',
'assistant.step.working': 'Ci sto lavorando',
'assistant.step.collapsed': '{count} passaggi',
'assistant.step.collapsed.one': '{count} passaggio',
@@ -397,7 +399,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Aggiungi',
'assistant.change.verb.genericUpdate': 'Modifica',
'assistant.change.verb.genericRemove': 'Rimuovi',
'assistant.change.verb.addTour': 'Aggiungi escursione',
'assistant.change.verb.updateTour': 'Modifica escursione',
'assistant.change.verb.importTour': 'Aggiungi escursione da file',
'assistant.change.label.field': 'Valore',
'assistant.change.label.difficulty': 'Difficoltà massima del sentiero',
'assistant.domain.tours': 'Percorsi',
'assistant.domain.roadtrip': 'Viaggio su strada',
'assistant.domain.journey': 'Diario di viaggio',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '費用を確認中',
'assistant.step.help': 'ヘルプを検索中',
'assistant.step.readingBooking': '予約を読み込み中',
'assistant.step.planningHike': 'ハイキングを計画中',
'assistant.step.planningHikeTo': '{place}までのハイキングを計画中',
'assistant.step.working': '処理中',
'assistant.step.collapsed': '{count} ステップ',
'assistant.change.title': '提案された変更',
@@ -366,7 +368,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '追加',
'assistant.change.verb.genericUpdate': '変更',
'assistant.change.verb.genericRemove': '削除',
'assistant.change.verb.addTour': 'ツアーを追加',
'assistant.change.verb.updateTour': 'ツアーを変更',
'assistant.change.verb.importTour': 'ファイルからツアーを追加',
'assistant.change.label.field': '値',
'assistant.change.label.difficulty': '登山道の難易度上限',
'assistant.domain.tours': 'ツアー',
'assistant.domain.roadtrip': 'ロードトリップ',
'assistant.domain.journey': '旅行記',
+6
View File
@@ -110,6 +110,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '비용을 확인하는 중',
'assistant.step.help': '도움말을 찾는 중',
'assistant.step.readingBooking': '예약을 읽는 중',
'assistant.step.planningHike': '하이킹 계획 중',
'assistant.step.planningHikeTo': '{place}까지 하이킹 계획 중',
'assistant.step.working': '작업 중',
'assistant.step.collapsed': '{count}단계',
'assistant.change.title': '제안된 변경 사항',
@@ -363,7 +365,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '추가',
'assistant.change.verb.genericUpdate': '변경',
'assistant.change.verb.genericRemove': '삭제',
'assistant.change.verb.addTour': '투어 추가',
'assistant.change.verb.updateTour': '투어 변경',
'assistant.change.verb.importTour': '파일에서 투어 추가',
'assistant.change.label.field': '값',
'assistant.change.label.difficulty': '탐방로 최대 난이도',
'assistant.domain.tours': '투어',
'assistant.domain.roadtrip': '로드 트립',
'assistant.domain.journey': '여행 일지',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'De kosten controleren',
'assistant.step.help': 'In de help opzoeken',
'assistant.step.readingBooking': 'Boeking lezen',
'assistant.step.planningHike': 'Wandeling plannen',
'assistant.step.planningHikeTo': 'Plant de wandeling naar {place}',
'assistant.step.working': 'Bezig',
'assistant.step.collapsed': '{count} stappen',
'assistant.step.collapsed.one': '{count} stap',
@@ -392,7 +394,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Toevoegen',
'assistant.change.verb.genericUpdate': 'Wijzigen',
'assistant.change.verb.genericRemove': 'Verwijderen',
'assistant.change.verb.addTour': 'Tocht toevoegen',
'assistant.change.verb.updateTour': 'Tocht wijzigen',
'assistant.change.verb.importTour': 'Tocht uit bestand toevoegen',
'assistant.change.label.field': 'Waarde',
'assistant.change.label.difficulty': 'Maximale moeilijkheid van het pad',
'assistant.domain.tours': 'Routes',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisdagboek',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Sprawdzam koszty',
'assistant.step.help': 'Szukam w pomocy',
'assistant.step.readingBooking': 'Czytam rezerwację',
'assistant.step.planningHike': 'Planuję wędrówkę',
'assistant.step.planningHikeTo': 'Planuje wędrówkę do celu: {place}',
'assistant.step.working': 'Pracuję nad tym',
'assistant.step.collapsed': '{count} kroku',
'assistant.step.collapsed.one': '{count} krok',
@@ -434,7 +436,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Dodaj',
'assistant.change.verb.genericUpdate': 'Zmień',
'assistant.change.verb.genericRemove': 'Usuń',
'assistant.change.verb.addTour': 'Dodaj wycieczkę',
'assistant.change.verb.updateTour': 'Zmień wycieczkę',
'assistant.change.verb.importTour': 'Dodaj wycieczkę z pliku',
'assistant.change.label.field': 'Wartość',
'assistant.change.label.difficulty': 'Maksymalna trudność szlaku',
'assistant.domain.tours': 'Trasy',
'assistant.domain.roadtrip': 'Podróż samochodem',
'assistant.domain.journey': 'Dziennik podróży',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Проверяю расходы',
'assistant.step.help': 'Ищу в справке',
'assistant.step.readingBooking': 'Читаю бронирование',
'assistant.step.planningHike': 'Планирую поход',
'assistant.step.planningHikeTo': 'Планирую поход, цель: {place}',
'assistant.step.working': 'Работаю над этим',
'assistant.step.collapsed': '{count} шага',
'assistant.step.collapsed.one': '{count} шаг',
@@ -434,7 +436,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Добавить',
'assistant.change.verb.genericUpdate': 'Изменить',
'assistant.change.verb.genericRemove': 'Удалить',
'assistant.change.verb.addTour': 'Добавить тур',
'assistant.change.verb.updateTour': 'Изменить тур',
'assistant.change.verb.importTour': 'Добавить тур из файла',
'assistant.change.label.field': 'Значение',
'assistant.change.label.difficulty': 'Максимальная сложность тропы',
'assistant.domain.tours': 'Маршруты',
'assistant.domain.roadtrip': 'Автопутешествие',
'assistant.domain.journey': 'Дневник путешествий',
+6
View File
@@ -113,6 +113,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrolujem náklady',
'assistant.step.help': 'Hľadám v pomocníkovi',
'assistant.step.readingBooking': 'Čítam rezerváciu',
'assistant.step.planningHike': 'Plánujem túru',
'assistant.step.planningHikeTo': 'Plánujem túru do cieľa {place}',
'assistant.step.working': 'Pracujem na tom',
'assistant.step.collapsed': '{count} krokov',
'assistant.step.collapsed.one': '{count} krok',
@@ -410,7 +412,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Pridať',
'assistant.change.verb.genericUpdate': 'Zmeniť',
'assistant.change.verb.genericRemove': 'Odstrániť',
'assistant.change.verb.addTour': 'Pridať túru',
'assistant.change.verb.updateTour': 'Zmeniť túru',
'assistant.change.verb.importTour': 'Pridať túru zo súboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximálna náročnosť chodníka',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autom',
'assistant.domain.journey': 'Cestovný denník',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollerar kostnaderna',
'assistant.step.help': 'Letar i hjälpen',
'assistant.step.readingBooking': 'Läser bokningen',
'assistant.step.planningHike': 'Planerar vandringen',
'assistant.step.planningHikeTo': 'Planerar vandringen till {place}',
'assistant.step.working': 'Arbetar på det',
'assistant.step.collapsed': '{count} steg',
'assistant.step.collapsed.one': '{count} steg',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Lägg till',
'assistant.change.verb.genericUpdate': 'Ändra',
'assistant.change.verb.genericRemove': 'Ta bort',
'assistant.change.verb.addTour': 'Lägg till tur',
'assistant.change.verb.updateTour': 'Ändra tur',
'assistant.change.verb.importTour': 'Lägg till tur från fil',
'assistant.change.label.field': 'Värde',
'assistant.change.label.difficulty': 'Högsta svårighetsgrad för leden',
'assistant.domain.tours': 'Turer',
'assistant.domain.roadtrip': 'Bilresa',
'assistant.domain.journey': 'Resedagbok',
+6
View File
@@ -109,6 +109,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'กำลังตรวจสอบค่าใช้จ่าย',
'assistant.step.help': 'กำลังค้นในความช่วยเหลือ',
'assistant.step.readingBooking': 'กำลังอ่านการจอง',
'assistant.step.planningHike': 'กำลังวางแผนเดินป่า',
'assistant.step.planningHikeTo': 'กำลังวางแผนเดินป่าไปยัง {place}',
'assistant.step.working': 'กำลังดำเนินการ',
'assistant.step.collapsed': '{count} ขั้นตอน',
'assistant.change.title': 'การเปลี่ยนแปลงที่แนะนำ',
@@ -363,7 +365,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'เพิ่ม',
'assistant.change.verb.genericUpdate': 'เปลี่ยน',
'assistant.change.verb.genericRemove': 'ลบ',
'assistant.change.verb.addTour': 'เพิ่มทัวร์',
'assistant.change.verb.updateTour': 'แก้ไขทัวร์',
'assistant.change.verb.importTour': 'เพิ่มทัวร์จากไฟล์',
'assistant.change.label.field': 'ค่า',
'assistant.change.label.difficulty': 'ระดับความยากสูงสุดของทางเดิน',
'assistant.domain.tours': 'ทัวร์',
'assistant.domain.roadtrip': 'โรดทริป',
'assistant.domain.journey': 'บันทึกการเดินทาง',
+6
View File
@@ -112,6 +112,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Maliyetler kontrol ediliyor',
'assistant.step.help': 'Yardımda aranıyor',
'assistant.step.readingBooking': 'Rezervasyon okunuyor',
'assistant.step.planningHike': 'Yürüyüş planlanıyor',
'assistant.step.planningHikeTo': '{place} için yürüyüş planlanıyor',
'assistant.step.working': 'Üzerinde çalışılıyor',
'assistant.step.collapsed': '{count} adım',
'assistant.step.collapsed.one': '{count} adım',
@@ -390,7 +392,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Ekle',
'assistant.change.verb.genericUpdate': 'Değiştir',
'assistant.change.verb.genericRemove': 'Kaldır',
'assistant.change.verb.addTour': 'Tur ekle',
'assistant.change.verb.updateTour': 'Turu değiştir',
'assistant.change.verb.importTour': 'Dosyadan tur ekle',
'assistant.change.label.field': 'Değer',
'assistant.change.label.difficulty': 'En yüksek patika zorluğu',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Yolculuk',
'assistant.domain.journey': 'Seyahat günlüğü',
+6
View File
@@ -114,6 +114,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Перевіряю витрати',
'assistant.step.help': 'Шукаю в довідці',
'assistant.step.readingBooking': 'Читаю бронювання',
'assistant.step.planningHike': 'Планую похід',
'assistant.step.planningHikeTo': 'Планую похід, ціль: {place}',
'assistant.step.working': 'Працюю над цим',
'assistant.step.collapsed': '{count} кроку',
'assistant.step.collapsed.one': '{count} крок',
@@ -432,7 +434,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Додати',
'assistant.change.verb.genericUpdate': 'Змінити',
'assistant.change.verb.genericRemove': 'Видалити',
'assistant.change.verb.addTour': 'Додати тур',
'assistant.change.verb.updateTour': 'Змінити тур',
'assistant.change.verb.importTour': 'Додати тур із файлу',
'assistant.change.label.field': 'Значення',
'assistant.change.label.difficulty': 'Максимальна складність стежки',
'assistant.domain.tours': 'Маршрути',
'assistant.domain.roadtrip': 'Автоподорож',
'assistant.domain.journey': 'Щоденник подорожей',
+6
View File
@@ -111,6 +111,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Đang kiểm tra chi phí',
'assistant.step.help': 'Đang tra trong trợ giúp',
'assistant.step.readingBooking': 'Đang đọc đặt chỗ',
'assistant.step.planningHike': 'Đang lên kế hoạch đi bộ',
'assistant.step.planningHikeTo': 'Đang lên kế hoạch đi bộ đến {place}',
'assistant.step.working': 'Đang xử lý',
'assistant.step.collapsed': '{count} bước',
'assistant.change.title': 'Thay đổi được đề xuất',
@@ -366,7 +368,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': 'Thêm',
'assistant.change.verb.genericUpdate': 'Thay đổi',
'assistant.change.verb.genericRemove': 'Xóa',
'assistant.change.verb.addTour': 'Thêm chuyến đi',
'assistant.change.verb.updateTour': 'Sửa chuyến đi',
'assistant.change.verb.importTour': 'Thêm chuyến đi từ tệp',
'assistant.change.label.field': 'Giá trị',
'assistant.change.label.difficulty': 'Độ khó tối đa của đường mòn',
'assistant.domain.tours': 'Tuyến đi',
'assistant.domain.roadtrip': 'Đi đường bộ',
'assistant.domain.journey': 'Nhật ký du lịch',
+6
View File
@@ -108,6 +108,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在檢查費用',
'assistant.step.help': '正在說明中查找',
'assistant.step.readingBooking': '正在讀取預訂',
'assistant.step.planningHike': '正在規劃健行路線',
'assistant.step.planningHikeTo': '正在規劃前往{place}的健行路線',
'assistant.step.working': '處理中',
'assistant.step.collapsed': '{count} 個步驟',
'assistant.change.title': '建議的變更',
@@ -360,7 +362,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '新增',
'assistant.change.verb.genericUpdate': '變更',
'assistant.change.verb.genericRemove': '刪除',
'assistant.change.verb.addTour': '新增路線',
'assistant.change.verb.updateTour': '修改路線',
'assistant.change.verb.importTour': '從檔案新增路線',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高難度',
'assistant.domain.tours': '路線',
'assistant.domain.roadtrip': '公路旅行',
'assistant.domain.journey': '旅行日誌',
+6
View File
@@ -108,6 +108,8 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在检查费用',
'assistant.step.help': '正在帮助中查找',
'assistant.step.readingBooking': '正在读取预订',
'assistant.step.planningHike': '正在规划徒步路线',
'assistant.step.planningHikeTo': '正在规划前往{place}的徒步路线',
'assistant.step.working': '正在处理',
'assistant.step.collapsed': '{count} 个步骤',
'assistant.change.title': '建议的更改',
@@ -360,7 +362,11 @@ const assistant: TranslationStrings = {
'assistant.change.verb.genericCreate': '添加',
'assistant.change.verb.genericUpdate': '更改',
'assistant.change.verb.genericRemove': '删除',
'assistant.change.verb.addTour': '添加路线',
'assistant.change.verb.updateTour': '修改路线',
'assistant.change.verb.importTour': '从文件添加路线',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高难度',
'assistant.domain.tours': '路线',
'assistant.domain.roadtrip': '自驾游',
'assistant.domain.journey': '旅行日记',
+3
View File
@@ -80,6 +80,9 @@ export * from './tours/walk-hours';
export * from './tours/walk-remaining';
export * from './tours/walk-ferry';
export * from './tours/tours.metrics';
export * from './tours/hiking-costing';
export * from './tours/hike-plan.schema';
export * from './tours/tour-hike.schema';
export * from './tours/tour-gpx-import.schema';
export * from './tours/paddle-plan.schema';
export * from './tours/paddle-plan';
+114
View File
@@ -0,0 +1,114 @@
import {
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_MAX_WAYPOINTS,
HIKE_PLAN_PROFILE_SAMPLES,
hikeLinePointSchema,
hikePlanRequestSchema,
hikePlanSchema,
hikeProfileSampleSchema,
} from './hike-plan.schema';
import { describe, expect, it } from 'vitest';
const point = (index: number) => ({ lat: 47.4 + index / 100, lng: 11.1 });
const routed = {
kind: 'routed',
max_hiking_difficulty: 3,
hiking_preferences: null,
distance_m: 8200,
ascent_m: 640,
descent_m: 640,
duration_minutes: 200,
duration_basis: 'din33466',
has_heights: true,
line: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
[0, 720],
[8200, 1360],
],
grades: { segments: [{ from_m: 0, to_m: 1200, sac: 2 }], highest: 2, unknown_share: 0.4 },
};
describe('a hike the server plans through its points', () => {
it('HIKE-PLAN-SCHEMA-001: takes two points and nothing else, and the limit, the preferences and the names it can be given', () => {
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0), point(1)] }).success).toBe(true);
const full = {
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
};
expect(hikePlanRequestSchema.parse(full)).toEqual(full);
expect(hikePlanRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-002: refuses a single point, too many, a limit off the scale, an unknown preference and an unknown key', () => {
const two = [point(0), point(1)];
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0)] }).success).toBe(false);
const many = Array.from({ length: HIKE_PLAN_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
expect(hikePlanRequestSchema.safeParse({ waypoints: many }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, max_hiking_difficulty: 7 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, max_hiking_difficulty: 0 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, hiking_preferences: { avoid_snow: true } }).success).toBe(
false,
);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, tripId: 1 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: [{ lat: 91, lng: 0 }, point(1)] }).success).toBe(false);
});
it('HIKE-PLAN-SCHEMA-003: reads a routed plan with or without heights and grades', () => {
expect(hikePlanSchema.parse(routed)).toEqual(routed);
const flat = {
...routed,
ascent_m: null,
descent_m: null,
duration_basis: 'router',
has_heights: false,
line: [
[47.4, 11.1],
[47.41, 11.1],
],
profile: [],
grades: null,
};
expect(hikePlanSchema.safeParse(flat).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-004: keeps a routed plan within what it shows: a line, a profile and grades within their bounds', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikePlanSchema.safeParse({ ...routed, line: long }).success).toBe(false);
const samples = Array.from({ length: HIKE_PLAN_PROFILE_SAMPLES + 1 }, (_, index) => [index, 700]);
expect(hikePlanSchema.safeParse({ ...routed, profile: samples }).success).toBe(false);
const hard = { ...routed.grades, segments: [{ from_m: 0, to_m: 10, sac: 7 }] };
expect(hikePlanSchema.safeParse({ ...routed, grades: hard }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...routed, grades: { ...routed.grades, unknown_share: 1.2 } }).success).toBe(
false,
);
expect(hikePlanSchema.safeParse({ ...routed, duration_basis: 'guess' }).success).toBe(false);
});
it('HIKE-PLAN-SCHEMA-005: says why no way was planned, with the point no way reaches where that is the reason', () => {
const off = { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 3, snapped_m: 520 };
expect(hikePlanSchema.parse(off)).toEqual(off);
const none = { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_valhalla', position: null, snapped_m: null };
expect(hikePlanSchema.safeParse(none).success).toBe(true);
expect(hikePlanSchema.safeParse({ ...none, why: 'tired' }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...off, position: 0 }).success).toBe(false);
expect(hikePlanSchema.safeParse({ ...none, why: 'too_long' }).success).toBe(true);
});
it('HIKE-PLAN-SCHEMA-006: reads a point of the line with or without its height, inside the globe only', () => {
expect(hikeLinePointSchema.safeParse([47.4, 11.1]).success).toBe(true);
expect(hikeLinePointSchema.safeParse([47.4, 11.1, 812.5]).success).toBe(true);
expect(hikeLinePointSchema.safeParse([91, 11.1]).success).toBe(false);
expect(hikeLinePointSchema.safeParse([47.4, 181, 800]).success).toBe(false);
expect(hikeLinePointSchema.safeParse([47.4, 11.1, Number.NaN]).success).toBe(false);
expect(hikeProfileSampleSchema.safeParse([-1, 800]).success).toBe(false);
// A line that long takes some twenty height questions, and stays far below a tour write.
expect(HIKE_MAX_LINE_POINTS).toBe(10_000);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { hikingPreferencesSchema, tourMaxHikingDifficultySchema, tourWaypointSchema } from './tours.schema';
import { z } from 'zod';
/**
* A hike the server plans through the points it is given: the way on foot
* under the hike's hiking limit and preferences, its heights, its length,
* climb, descent and walking time, and the SAC grades OpenStreetMap records
* for the paths it takes. The plan reads only; a hike created from it keeps
* the whole line, the plan shows a thinned one.
*/
/** The most points one hike is planned through, its start and end included. */
export const HIKE_PLAN_MAX_WAYPOINTS = 30;
/** The most points of the line a plan shows; the tour created from it keeps every point. */
export const HIKE_PLAN_LINE_POINTS = 300;
/** The most samples of the height profile a plan shows. */
export const HIKE_PLAN_PROFILE_SAMPLES = 200;
/**
* The most points a hike's routed line may hold before it is planned no
* further. Its heights are read 499 points a question through the router's
* one paced queue, about a second each, which every road trip and walk of the
* instance waits behind: this many points take some twenty questions. A line
* on paths carries a point every few metres to every few tens of metres, so
* this is a hike of fifty kilometres and more, and at some fifty bytes a point
* with its height it stays far below what one tour write carries
* (TOUR_WRITE_MAX_BYTES).
*/
export const HIKE_MAX_LINE_POINTS = 10_000;
const { lat, lng, name } = tourWaypointSchema.shape;
export const hikePlanPointSchema = z
.object({
lat,
lng,
name: name.describe('What the point is, such as a car park, a hut or a summit, where it has a name'),
})
.strict();
export type HikePlanPoint = z.infer<typeof hikePlanPointSchema>;
export const hikePlanRequestSchema = z
.object({
waypoints: z
.array(hikePlanPointSchema)
.min(2)
.max(HIKE_PLAN_MAX_WAYPOINTS)
.describe('The points the hike goes through in order: its start, the points on the way and its end'),
max_hiking_difficulty: tourMaxHikingDifficultySchema
.optional()
.describe('The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6); 2 when left out'),
hiking_preferences: hikingPreferencesSchema
.nullable()
.optional()
.describe('Steep climbs, steps or ferries the way avoids where there is a way round'),
})
.strict();
export type HikePlanRequest = z.infer<typeof hikePlanRequestSchema>;
/** A step of the SAC hiking scale, T1 to T6 as 1 to 6. */
const sacStepSchema = z.number().int().min(1).max(6);
/** A stretch of the line whose paths OpenStreetMap grades with one SAC step, in metres along the line. */
export const hikeSacSegmentSchema = z.object({
from_m: z.number().nonnegative(),
to_m: z.number().nonnegative(),
sac: sacStepSchema,
});
export type HikeSacSegment = z.infer<typeof hikeSacSegmentSchema>;
/**
* The SAC grades along the line: the graded stretches in order, the hardest
* step among them, and how much of the line has no grade at all. A path
* without a grade is unknown, never easy.
*/
export const hikeTrailGradesSchema = z.object({
segments: z.array(hikeSacSegmentSchema),
highest: sacStepSchema.nullable(),
/** Of the whole line, 0 to 1. */
unknown_share: z.number().min(0).max(1),
});
export type HikeTrailGrades = z.infer<typeof hikeTrailGradesSchema>;
/** A point of a hike's line, `[lat, lng]`, or `[lat, lng, ele]` where its height is known. */
export const hikeLinePointSchema = z.union([z.tuple([lat, lng]), z.tuple([lat, lng, z.number().finite()])]);
/** A sample of a height profile, `[metres along the line, height in metres]`. */
export const hikeProfileSampleSchema = z.tuple([z.number().nonnegative(), z.number().finite()]);
/** Where the walking time comes from: the signposts' rule over the heights, or the router's own time without them. */
export const HIKE_DURATION_BASES = ['din33466', 'router'] as const;
export const routedHikePlanSchema = z.object({
kind: z.literal('routed'),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
distance_m: z.number().int().nonnegative(),
/** Null where the heights are unknown. */
ascent_m: z.number().int().nonnegative().nullable(),
descent_m: z.number().int().nonnegative().nullable(),
duration_minutes: z.number().int().nonnegative(),
duration_basis: z.enum(HIKE_DURATION_BASES),
/** Whether every point of the line has its height; without them the line is flat and the profile empty. */
has_heights: z.boolean(),
/** The line thinned for display, `[lat, lng, ele]` where the heights are known. */
line: z.array(hikeLinePointSchema).min(2).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the line, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
/** Null where the grades could not be read this time, or were not asked for. */
grades: hikeTrailGradesSchema.nullable(),
});
export type RoutedHikePlan = z.infer<typeof routedHikePlanSchema>;
/**
* Why no way was planned: no Valhalla to ask (an own OSRM without one, which
* knows no hiking limit), a router that did not answer, the router's word
* that no way within the limit joins the points, a point further from any
* way than a tour allows, or a way longer than one hike is planned for (the
* router's own distance limit, or more than HIKE_MAX_LINE_POINTS points).
*/
export const HIKE_UNROUTED_REASONS = [
'no_valhalla',
'router_unavailable',
'no_way',
'point_off_the_way',
'too_long',
] as const;
export const unroutedHikePlanSchema = z.object({
kind: z.literal('unrouted'),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
why: z.enum(HIKE_UNROUTED_REASONS),
/** The point no way reaches, counted from 1; null for every other reason. */
position: z.number().int().positive().nullable(),
/** How far the router had to move that point to reach a way, in metres; null for every other reason. */
snapped_m: z.number().int().nonnegative().nullable(),
});
export type UnroutedHikePlan = z.infer<typeof unroutedHikePlanSchema>;
export const hikePlanSchema = z.discriminatedUnion('kind', [routedHikePlanSchema, unroutedHikePlanSchema]);
export type HikePlan = z.infer<typeof hikePlanSchema>;
+32
View File
@@ -0,0 +1,32 @@
import { hikingCosting, hikingCostingOptions, TOURS_MAX_HIKING_DIFFICULTY } from './hiking-costing';
import { describe, expect, it } from 'vitest';
describe('hikingCostingOptions (MF-08, RTE-01)', () => {
it('HIKE-COST-001: asks the router only for what was switched on, in the options it was checked with', () => {
expect(hikingCostingOptions(null)).toEqual({});
expect(hikingCostingOptions(undefined)).toEqual({});
expect(hikingCostingOptions({ avoid_steps: false })).toEqual({});
expect(hikingCostingOptions({ avoid_climbs: true, avoid_steps: true, avoid_ferries: true })).toEqual({
use_hills: 0.2,
step_penalty: 600,
use_ferry: 0,
});
});
});
describe('hikingCosting (RTE-02)', () => {
it('HIKE-COST-002: weighs the whole tour and one of its sections alike: the limit with the preferences', () => {
expect(hikingCosting()).toEqual({ max_hiking_difficulty: 2 });
expect(hikingCosting(4, { avoid_steps: true })).toEqual({ max_hiking_difficulty: 4, step_penalty: 600 });
});
it('HIKE-COST-003: falls back to T2 only where no limit is given, and names the limit first', () => {
expect(TOURS_MAX_HIKING_DIFFICULTY).toBe(2);
expect(Object.keys(hikingCosting(6, { avoid_ferries: true, avoid_climbs: true }))).toEqual([
'max_hiking_difficulty',
'use_hills',
'use_ferry',
]);
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { HikingPreferences } from './tours.schema';
/**
* Defensive fallback only. Normal planner calls pass the persisted/draft value.
* TREK exposes 1..6 and never exposes Valhalla's internal 0.
*/
export const TOURS_MAX_HIKING_DIFFICULTY = 2;
/**
* What a hike asks Valhalla's pedestrian costing for: its hiking limit, and
* the weightings its preferences switch on. The planner in the browser and
* the server's hike routing both build it here, so a hike is weighed alike
* wherever it is planned.
*/
export type HikingCosting = Record<string, number> & { max_hiking_difficulty: number };
/**
* MF-08, RTE-01: a hike's preferences as Valhalla's pedestrian costing
* options. Each one was checked against the public instance before it was
* offered, and is a weighting that gives way where there is no other path:
* - avoid_climbs, `use_hills` 0.2: Freiburg Martinstor to the Schlossberg went
* from 1.64 km with a 37 % pitch and 30 m over 25 % to 1.73 km whose steepest
* stretch is 23 % (heights every 30 m). It weighs the grade, not the climb in
* sum: the same height is still gained. 0.1 and below sent Garmisch station
* to the Eckbauer round a 10 km detour with more steep metres, 0.25 and up
* left Freiburg as it was; 0.2 does neither.
* - avoid_steps, `step_penalty` 600 s: Würzburg Altstadt to the Festung went
* from four flights of steps over 1.49 km to one over 2.67 km.
* - avoid_ferries, `use_ferry` 0: Kiel Reventlou to Dietrichsdorf went from the
* Förde ferry (2.7 km) round the fjord on land (8.3 km); Hamburg
* Landungsbrücken to Steinwerder through the old Elbe tunnel instead of the ferry.
* The router has no option for marked, paved or rough ways on foot.
*/
export function hikingCostingOptions(preferences: HikingPreferences | null | undefined): Record<string, number> {
return {
...(preferences?.avoid_climbs ? { use_hills: 0.2 } : {}),
...(preferences?.avoid_steps ? { step_penalty: 600 } : {}),
...(preferences?.avoid_ferries ? { use_ferry: 0 } : {}),
};
}
/**
* Everything a hike asks the pedestrian costing for: its limit and its
* preferences. One reading for the whole tour and for the ways offered for one
* of its sections (RTE-02), so both are weighed by the same rules.
*/
export function hikingCosting(
maxHikingDifficulty: number = TOURS_MAX_HIKING_DIFFICULTY,
preferences: HikingPreferences | null = null,
): HikingCosting {
return { max_hiking_difficulty: maxHikingDifficulty, ...hikingCostingOptions(preferences) };
}
+182
View File
@@ -0,0 +1,182 @@
import { HIKE_PLAN_LINE_POINTS } from './hike-plan.schema';
import {
HIKE_MAX_WAYPOINTS,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeCreateRequestSchema,
hikeProposalSchema,
hikeProposeRequestSchema,
hikeRouteOutcomeSchema,
TOUR_GPX_TEXT_MAX,
tourGpxImportRequestSchema,
tourUpdateRequestSchema,
} from './tour-hike.schema';
import { describe, expect, it } from 'vitest';
const point = (index: number) => ({ lat: 47.4 + index / 100, lng: 11.1 });
const two = [point(0), point(1)];
const proposal = {
name: 'Herzogstand',
variant_label: 'short and steep',
route: { planned: true },
unreachable: null,
distance_m: 8200,
ascent_m: 640,
descent_m: 640,
duration_minutes: 200,
duration_basis: 'din33466',
heights_known: true,
min_ele_m: 720,
max_ele_m: 1360,
line: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
[0, 720],
[8200, 1360],
],
sac: { highest: 2, segments: [{ from_m: 0, to_m: 1200, sac: 2 }], unknown_share: 0.4 },
waypoints: [
{ lat: 47.4, lng: 11.1, name: 'Parking', at_m: 0 },
{ lat: 47.41, lng: 11.1, name: null, at_m: 8200 },
],
max_hiking_difficulty: 3,
hiking_preferences: null,
round_trip: false,
date: '2027-07-10',
start_time: '08:30',
};
describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-001: takes two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ waypoints: two }).success).toBe(true);
const full = {
name: 'Herzogstand',
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
round_trip: true,
variant_label: 'by the lake',
date: '2027-07-10',
start_time: '08:30',
};
expect(hikeProposeRequestSchema.parse(full)).toEqual(full);
expect(hikeProposeRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-002: refuses one point, too many, a long label, a date or a time it cannot read, and a key it does not know', () => {
const many = Array.from({ length: HIKE_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
for (const request of [
{ waypoints: [point(0)] },
{ waypoints: many },
{ waypoints: two, variant_label: 'x'.repeat(41) },
{ waypoints: two, variant_label: ' ' },
{ waypoints: two, date: '10.07.2027' },
{ waypoints: two, start_time: '8:30' },
{ waypoints: two, start_time: '24:00' },
{ waypoints: two, max_hiking_difficulty: 7 },
{ waypoints: two, tripId: 1 },
]) {
expect(hikeProposeRequestSchema.safeParse(request).success, JSON.stringify(request).slice(0, 80)).toBe(false);
}
});
it('TOUR-HIKE-SCHEMA-003: reads a proposal with its way, and one without a way', () => {
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
const unrouted = {
...proposal,
route: { planned: false, why: 'point_off_the_way', position: 2 },
unreachable: { position: 2, snapped_m: 420 },
distance_m: null,
ascent_m: null,
descent_m: null,
duration_minutes: null,
duration_basis: null,
heights_known: false,
min_ele_m: null,
max_ele_m: null,
line: [],
profile: [],
sac: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null })),
};
expect(hikeProposalSchema.safeParse(unrouted).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-004: keeps a proposal within what a card draws', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikeProposalSchema.safeParse({ ...proposal, line: long }).success).toBe(false);
const segments = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
from_m: index,
to_m: index + 1,
sac: 2,
}));
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, segments } }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, highest: 7 } }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, sac: { ...proposal.sac, unknown_share: 1.5 } }).success).toBe(
false,
);
expect(hikeProposalSchema.safeParse({ ...proposal, duration_basis: 'guess' }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line: [[47.4, 191]] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], lat: -91 }] }).success,
).toBe(false);
});
it('TOUR-HIKE-SCHEMA-005: says why a way was not planned only with a reason the plan knows', () => {
expect(hikeRouteOutcomeSchema.safeParse({ planned: true }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'no_way', position: null }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'too_long', position: null }).success).toBe(true);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'ferry_only', position: null }).success).toBe(false);
expect(hikeRouteOutcomeSchema.safeParse({ planned: false, why: 'no_way' }).success).toBe(false);
});
});
describe('a hike created, changed or taken in from a file over MCP', () => {
it('TOUR-HIKE-SCHEMA-006: creates a hike with a name and its points', () => {
expect(hikeCreateRequestSchema.safeParse({ name: 'Herzogstand', waypoints: two }).success).toBe(true);
expect(
hikeCreateRequestSchema.safeParse({
name: 'Herzogstand',
description: null,
waypoints: two,
max_hiking_difficulty: 3,
hiking_preferences: { avoid_climbs: true },
round_trip: true,
planned_duration_minutes: 360,
}).success,
).toBe(true);
expect(hikeCreateRequestSchema.safeParse({ waypoints: two }).success).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: ' ', waypoints: two }).success).toBe(false);
expect(
hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, planned_duration_minutes: 1441 }).success,
).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, variant_label: 'a' }).success).toBe(false);
});
it('TOUR-HIKE-SCHEMA-007: changes a hike only against the revision it was read at', () => {
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, name: 'New' }).success).toBe(true);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, waypoints: two }).success).toBe(true);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, name: 'New' }).success).toBe(false);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 0 }).success).toBe(false);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, round_trip: true }).success).toBe(
false,
);
});
it('TOUR-HIKE-SCHEMA-008: takes a GPX text as a hike by default, and a type with what that type needs', () => {
const parsed = tourGpxImportRequestSchema.parse({ gpx: '<gpx/>' });
expect(parsed).toEqual({ gpx: '<gpx/>', tour_type: 'hike' });
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', tour_type: 'bike' }).success).toBe(false);
expect(
tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', tour_type: 'bike', bike_profile: 'road' }).success,
).toBe(true);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', bike_profile: 'road' }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '' }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: 'x'.repeat(TOUR_GPX_TEXT_MAX + 1) }).success).toBe(false);
expect(tourGpxImportRequestSchema.safeParse({ gpx: '<gpx/>', file_name: 'x'.repeat(201) }).success).toBe(false);
expect(Object.keys(tourGpxImportRequestSchema.shape)).toContain('tour_type');
});
});
+222
View File
@@ -0,0 +1,222 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import {
HIKE_DURATION_BASES,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_PROFILE_SAMPLES,
HIKE_UNROUTED_REASONS,
hikeLinePointSchema,
hikePlanPointSchema,
hikeProfileSampleSchema,
hikeSacSegmentSchema,
hikeTrailGradesSchema,
} from './hike-plan.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import {
hikingPreferencesSchema,
plannedTourDurationMinutesSchema,
tourDescriptionSchema,
tourMaxHikingDifficultySchema,
tourNameSchema,
tourRevisionSchema,
} from './tours.schema';
import { z } from 'zod';
/**
* A hike an assistant plans with the traveller over MCP: proposed first, read
* only, as often as they want to compare ways, then created once they said
* yes, changed later, or taken in from a GPX file. The server plans the way
* on foot through the points (HikeRoutingService), with its heights, walking
* time and SAC grades; nothing here carries a line from the caller.
*/
/** The most points a hike is asked through, its start and end included; a round trip adds its way back. */
export const HIKE_MAX_WAYPOINTS = 25;
/** The longest label a variant is given, short enough for a chip that compares two ways. */
export const HIKE_VARIANT_LABEL_MAX = 40;
/** The most graded stretches a proposal shows; more are joined, the harder grade kept. */
export const HIKE_PROPOSAL_SAC_SEGMENTS = 200;
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
export const TOUR_GPX_TEXT_MAX = 1_000_000;
const hikeWaypointsSchema = z
.array(hikePlanPointSchema)
.min(2)
.max(HIKE_MAX_WAYPOINTS)
.describe(
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip',
);
const hikeLimitSchema = tourMaxHikingDifficultySchema.describe(
'The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6, difficult alpine hiking); 2 (T2, mountain hiking) when left out',
);
const hikePreferencesSchema = hikingPreferencesSchema
.nullable()
.describe('Steep climbs, steps or ferries the way avoids where there is a way round');
const roundTripSchema = z.boolean().describe('The hike ends back at its start: the way back is planned too');
const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
);
export const hikeProposeRequestSchema = z
.object({
name: tourNameSchema
.optional()
.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
variant_label: z
.string()
.trim()
.min(1)
.max(HIKE_VARIANT_LABEL_MAX)
.optional()
.describe(
'A short name for this way when several are compared, such as "short and steep" or "by the lake"; given back as it is',
),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('The day the hike is planned for, YYYY-MM-DD; given back as it is'),
start_time: clockTimeSchema.optional().describe('When the hike starts, HH:MM; given back as it is'),
})
.strict();
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
/** Whether the way was planned, and why not: the plan's reasons, with the point no way reaches. */
export const hikeRouteOutcomeSchema = z.union([
z.object({ planned: z.literal(true) }),
z.object({
planned: z.literal(false),
why: z.enum(HIKE_UNROUTED_REASONS),
/** The point no way reaches, counted from 1; null for every other reason. */
position: z.number().int().positive().nullable(),
}),
]);
export type HikeRouteOutcome = z.infer<typeof hikeRouteOutcomeSchema>;
const { lat, lng } = hikePlanPointSchema.shape;
/**
* The SAC grades along a proposed way, as the plan reads them (the hardest
* grade, the graded stretches in metres along the line, the share that is
* unknown and never easy), with at most HIKE_PROPOSAL_SAC_SEGMENTS stretches.
*/
export const hikeProposalSacSchema = hikeTrailGradesSchema.extend({
segments: z.array(hikeSacSegmentSchema).max(HIKE_PROPOSAL_SAC_SEGMENTS),
});
export type HikeProposalSac = z.infer<typeof hikeProposalSacSchema>;
/**
* What propose_hike answers, and what a chat card for the hike is drawn
* from: the way, its measures, its height profile and grades, the points it
* was asked through with where each lies along the way, and what the request
* named for the schedule, given back as it came.
*/
export const hikeProposalSchema = z.object({
name: z.string().nullable(),
variant_label: z.string().nullable(),
route: hikeRouteOutcomeSchema,
/** Where a point lies further from any way than a tour allows: which one, and how far it was off. */
unreachable: z
.object({ position: z.number().int().positive(), snapped_m: z.number().int().nonnegative() })
.nullable(),
/** The length of the way in metres; null where none was planned. */
distance_m: z.number().int().nonnegative().nullable(),
ascent_m: z.number().int().nonnegative().nullable(),
descent_m: z.number().int().nonnegative().nullable(),
/** The walking time in minutes, breaks left out: by DIN 33466 where the heights are known, else the router's. */
duration_minutes: z.number().int().nonnegative().nullable(),
duration_basis: z.enum(HIKE_DURATION_BASES).nullable(),
heights_known: z.boolean(),
min_ele_m: z.number().int().nullable(),
max_ele_m: z.number().int().nullable(),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; empty where none was planned. */
line: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the way, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
sac: hikeProposalSacSchema.nullable(),
/** The points in the order walked, a round trip's way back included; at_m null where no way was planned. */
waypoints: z.array(
z.object({
lat,
lng,
name: z.string().nullable(),
at_m: z.number().int().nonnegative().nullable(),
}),
),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
round_trip: z.boolean(),
date: z.string().nullable(),
start_time: z.string().nullable(),
});
export type HikeProposal = z.infer<typeof hikeProposalSchema>;
export const hikeCreateRequestSchema = z
.object({
name: tourNameSchema.describe('What the hike is called'),
description: tourDescriptionSchema.describe('A few words about the hike for the travellers, up to 2000 characters'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
})
.strict();
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
export const tourUpdateRequestSchema = z
.object({
placeId: idSchema.describe('The place id of the hike, as list_tours gives it'),
expected_revision: tourRevisionSchema.describe(
'The revision of the hike as last read (get_tour_plan or list_tours); a hike changed since is refused, not overwritten',
),
name: tourNameSchema.optional().describe('A new name for the hike'),
description: tourDescriptionSchema.describe('A new description; null clears it'),
waypoints: hikeWaypointsSchema
.optional()
.describe(
'New points for the hike in order, start to end (a round trip ends on its start); the way is planned anew through them. Left out, the points stay',
),
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
})
.strict();
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
/**
* A GPX file's text taken in as tours, one for each track or route it holds,
* with the fields the multipart import takes beside the file. The fields keep
* their own rules (a bike import names its profile, a paddle import its boat
* and water), checked here as the import checks them.
*/
export const tourGpxImportRequestSchema = z
.object({
gpx: z.string().min(1).max(TOUR_GPX_TEXT_MAX).describe('The GPX file as text, its XML as it is'),
file_name: z
.string()
.trim()
.min(1)
.max(200)
.optional()
.describe('The file name the travellers know it by, such as "herzogstand.gpx"'),
...tourGpxImportFieldsSchema.shape,
})
.strict()
.superRefine((value, ctx) => {
const { gpx: _gpx, file_name: _fileName, ...fields } = value;
const checked = tourGpxImportFieldsSchema.safeParse(fields);
for (const issue of checked.error?.issues ?? []) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: issue.path, message: issue.message });
}
});
export type TourGpxImportRequest = z.infer<typeof tourGpxImportRequestSchema>;