feat(assistant): plan and save every tour type, bike tours, runs, paddle and ski tours included

The assistant can now plan and save every type the Tours tab plans. A bike
tour or a run is proposed and created through the hike's own plan and save
(propose_tour, create_tour), routed on the server with the costing of its
profile, now kept once in shared and read by the planner as well. A paddle
or ski tour planned with plan_paddle_route or plan_ski_route is saved with
create_paddle_tour or create_ski_tour, and "Plan this" takes those plans,
bike tours and runs too. Every create has its card and its way back.
This commit is contained in:
Maurice
2026-10-23 18:59:37 +02:00
parent 79fb71bc92
commit 6e43490ba0
79 changed files with 3664 additions and 168 deletions
@@ -157,6 +157,24 @@ describe('tourRouteCardSchema', () => {
});
});
describe('tourRouteCardSchema: bike tours and runs', () => {
it('TOURCARD-008: a bike tour or a run names its profile, a run its pace; a card drawn before reads without both', () => {
const bike = hike({
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
});
expect(tourRouteCardSchema.parse(bike).routingProfile).toBe('gravel');
const run = hike({ activity: 'run', routingProfile: 'trail', minutesBasis: 'pace', paceSecondsPerKm: 330 });
expect(tourRouteCardSchema.parse(run).paceSecondsPerKm).toBe(330);
expect(tourRouteCardSchema.parse(hike()).routingProfile).toBeUndefined();
expect(tourRouteCardSchema.safeParse(hike({ routingProfile: 'tandem' as never })).success).toBe(false);
expect(tourRouteCardSchema.safeParse(hike({ paceSecondsPerKm: 5 })).success).toBe(false);
});
});
describe('tourListCardSchema', () => {
const row = {
placeId: 61,
+18 -2
View File
@@ -3,7 +3,12 @@ import { skiColourSchemeSchema, skiRunDifficultySchema, skiSegmentKindSchema } f
import { HIKE_UNROUTED_REASONS } from '../../tours/hike-plan.schema';
import { HIKE_WARNING_KINDS } from '../../tours/hike-schedule';
import { tourPoiCategorySchema } from '../../tours/tour-poi-category';
import { tourMaxHikingDifficultySchema, tourTypeKeySchema } from '../../tours/tours.schema';
import {
BIKE_PROFILES,
RUN_PROFILES,
tourMaxHikingDifficultySchema,
tourTypeKeySchema,
} from '../../tours/tours.schema';
import { cardClockSchema, cardDateSchema, cardLatSchema, cardLngSchema, cardPointSchema, cardText } from './card-parts';
import { z } from 'zod';
@@ -95,6 +100,9 @@ export const TOUR_WARNING_KINDS = [...HIKE_WARNING_KINDS, 'legs_failed'] as cons
*/
export const TOUR_ROUTE_PROBLEMS = [...HIKE_UNROUTED_REASONS, 'ferry_only', 'not_planned'] as const;
/** The profile a bike tour or a run is planned for, as the Tours tab names it. */
export const TOUR_ROUTE_PROFILES = [...BIKE_PROFILES, ...RUN_PROFILES] as const;
export const tourRouteScheduleSchema = z.object({
date: cardDateSchema,
dayId: idSchema.nullable(),
@@ -136,7 +144,15 @@ export const tourRouteCardSchema = z.object({
minEleM: height.nullable(),
maxEleM: height.nullable(),
minutes: minutes.nullable(),
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste']).nullable(),
/** pace: a run's distance at the runner's pace. */
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste', 'pace']).nullable(),
/**
* A bike tour's or a run's profile (road, gravel, trail ...); null for every
* other activity. Left out by a card drawn before it was sent.
*/
routingProfile: z.enum(TOUR_ROUTE_PROFILES).nullable().optional(),
/** A run's pace in seconds per kilometre; null without one. Left out like the profile. */
paceSecondsPerKm: z.number().int().min(60).max(3_600).nullable().optional(),
/** The hiking limit the way was planned under, a setting rather than a grade of the path. */
limit: tourMaxHikingDifficultySchema.nullable(),
grades: z.object({ highest: sacStep.nullable(), unknownShare: z.number().min(0).max(1) }).nullable(),
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'جارٍ التخطيط…',
'assistant.card.tour.added': 'أُضيفت إلى الرحلة',
'assistant.card.tour.askAgain': 'لم يعد بالإمكان التخطيط لهذا من هنا. اطلبه مرة أخرى.',
'assistant.card.tour.needsPace': 'أخبر المساعد بوتيرتك لحفظ هذا الجري.',
'assistant.card.tour.open': 'فتح في الجولات',
'assistant.card.tour.openPhone': 'فتح الجولة',
'assistant.card.tour.unsaved': 'احفظ الجولة التي تعدّلها أو تجاهلها أولًا',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planlaşdırılır…',
'assistant.card.tour.added': 'Səyahətə əlavə edildi',
'assistant.card.tour.askAgain': 'Bunu artıq buradan planlaşdırmaq olmur. Yenidən soruşun.',
'assistant.card.tour.needsPace': 'Bu qaçışı yadda saxlamaq üçün köməkçiyə tempinizi deyin.',
'assistant.card.tour.open': 'Turlarda aç',
'assistant.card.tour.openPhone': 'Turu aç',
'assistant.card.tour.unsaved': 'Əvvəlcə redaktə etdiyiniz turu yadda saxlayın və ya ləğv edin',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planejando…',
'assistant.card.tour.added': 'Adicionado à viagem',
'assistant.card.tour.askAgain': 'Isso não pode mais ser planejado daqui. Peça de novo.',
'assistant.card.tour.needsPace': 'Diga ao assistente o seu ritmo para salvar esta corrida.',
'assistant.card.tour.open': 'Abrir em Passeios',
'assistant.card.tour.openPhone': 'Abrir passeio',
'assistant.card.tour.unsaved': 'Salve ou descarte primeiro o passeio que você está editando',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': "S'està planificant…",
'assistant.card.tour.added': 'Afegit al viatge',
'assistant.card.tour.askAgain': 'Això ja no es pot planificar des d’aquí. Torna-ho a demanar.',
'assistant.card.tour.needsPace': 'Digues el teu ritme a l’assistent per desar aquesta cursa.',
'assistant.card.tour.open': 'Obre a Excursions',
'assistant.card.tour.openPhone': 'Obre l’excursió',
'assistant.card.tour.unsaved': 'Primer desa o descarta l’excursió que estàs editant',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Plánuje se…',
'assistant.card.tour.added': 'Přidáno do cesty',
'assistant.card.tour.askAgain': 'Odsud to už naplánovat nejde. Zeptej se na to znovu.',
'assistant.card.tour.needsPace': 'Řekni asistentovi své tempo, aby se tento běh mohl uložit.',
'assistant.card.tour.open': 'Otevřít ve Výletech',
'assistant.card.tour.openPhone': 'Otevřít výlet',
'assistant.card.tour.unsaved': 'Nejprve uložte nebo zahoďte výlet, který upravujete',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Wird eingeplant…',
'assistant.card.tour.added': 'Zur Reise hinzugefügt',
'assistant.card.tour.askAgain': 'Das lässt sich hier nicht mehr einplanen. Frag einfach noch einmal danach.',
'assistant.card.tour.needsPace': 'Nenn dem Assistenten dein Tempo, um diesen Lauf zu speichern.',
'assistant.card.tour.open': 'In Touren öffnen',
'assistant.card.tour.openPhone': 'Tour öffnen',
'assistant.card.tour.unsaved': 'Speichere oder verwirf zuerst die Tour, die du gerade bearbeitest',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planning it…',
'assistant.card.tour.added': 'Added to the trip',
'assistant.card.tour.askAgain': 'This can no longer be planned from here. Ask for it again.',
'assistant.card.tour.needsPace': 'Tell the assistant your pace to save this run.',
'assistant.card.tour.open': 'Open in Tours',
'assistant.card.tour.openPhone': 'Open tour',
'assistant.card.tour.unsaved': 'Save or discard the tour you are editing first',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planificando…',
'assistant.card.tour.added': 'Añadido al viaje',
'assistant.card.tour.askAgain': 'Esto ya no se puede planificar desde aquí. Vuelve a pedirlo.',
'assistant.card.tour.needsPace': 'Dile al asistente tu ritmo para guardar esta carrera.',
'assistant.card.tour.open': 'Abrir en Excursiones',
'assistant.card.tour.openPhone': 'Abrir excursión',
'assistant.card.tour.unsaved': 'Primero guarda o descarta la excursión que estás editando',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planeerin…',
'assistant.card.tour.added': 'Reisile lisatud',
'assistant.card.tour.askAgain': 'Seda ei saa siit enam planeerida. Küsi seda uuesti.',
'assistant.card.tour.needsPace': 'Ütle assistendile oma tempo, et see jooks salvestada.',
'assistant.card.tour.open': 'Ava retkedes',
'assistant.card.tour.openPhone': 'Ava retk',
'assistant.card.tour.unsaved': 'Salvesta või loobu kõigepealt retkest, mida muudad',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planification…',
'assistant.card.tour.added': 'Ajouté au voyage',
'assistant.card.tour.askAgain': "Ceci ne peut plus être planifié d'ici. Redemandez-le.",
'assistant.card.tour.needsPace': 'Indique ton allure à l’assistant pour enregistrer cette course.',
'assistant.card.tour.open': 'Ouvrir dans Sorties',
'assistant.card.tour.openPhone': 'Ouvrir la sortie',
'assistant.card.tour.unsaved': 'Enregistrez ou abandonnez d’abord la sortie en cours de modification',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Προγραμματίζεται…',
'assistant.card.tour.added': 'Προστέθηκε στο ταξίδι',
'assistant.card.tour.askAgain': 'Αυτό δεν μπορεί πια να προγραμματιστεί από εδώ. Ζήτησέ το ξανά.',
'assistant.card.tour.needsPace': 'Πες στον βοηθό τον ρυθμό σου για να αποθηκευτεί αυτό το τρέξιμο.',
'assistant.card.tour.open': 'Άνοιγμα στις Εκδρομές',
'assistant.card.tour.openPhone': 'Άνοιγμα εκδρομής',
'assistant.card.tour.unsaved': 'Αποθηκεύστε ή απορρίψτε πρώτα την εκδρομή που επεξεργάζεστε',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Tervezés…',
'assistant.card.tour.added': 'Hozzáadva az utazáshoz',
'assistant.card.tour.askAgain': 'Ezt innen már nem lehet megtervezni. Kérd újra.',
'assistant.card.tour.needsPace': 'Mondd meg az asszisztensnek a tempódat, hogy menthető legyen ez a futás.',
'assistant.card.tour.open': 'Megnyitás a Túrákban',
'assistant.card.tour.openPhone': 'Túra megnyitása',
'assistant.card.tour.unsaved': 'Előbb mentsd el vagy vesd el a szerkesztett túrát',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Sedang direncanakan…',
'assistant.card.tour.added': 'Ditambahkan ke perjalanan',
'assistant.card.tour.askAgain': 'Ini tidak bisa lagi direncanakan dari sini. Minta lagi.',
'assistant.card.tour.needsPace': 'Beri tahu asisten kecepatanmu untuk menyimpan lari ini.',
'assistant.card.tour.open': 'Buka di Tur',
'assistant.card.tour.openPhone': 'Buka tur',
'assistant.card.tour.unsaved': 'Simpan atau buang dulu tur yang sedang kamu edit',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Pianificazione…',
'assistant.card.tour.added': 'Aggiunto al viaggio',
'assistant.card.tour.askAgain': 'Non si può più pianificare da qui. Chiedilo di nuovo.',
'assistant.card.tour.needsPace': 'Di’ all’assistente il tuo passo per salvare questa corsa.',
'assistant.card.tour.open': 'Apri in Escursioni',
'assistant.card.tour.openPhone': 'Apri escursione',
'assistant.card.tour.unsaved': 'Prima salva o scarta l’escursione che stai modificando',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': '計画中…',
'assistant.card.tour.added': '旅行に追加しました',
'assistant.card.tour.askAgain': 'ここからはもう計画できません。もう一度頼んでください。',
'assistant.card.tour.needsPace': 'このランを保存するには、アシスタントにペースを伝えてください。',
'assistant.card.tour.open': 'ツアーで開く',
'assistant.card.tour.openPhone': 'ツアーを開く',
'assistant.card.tour.unsaved': '編集中のツアーを先に保存するか破棄してください',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': '계획하는 중…',
'assistant.card.tour.added': '여행에 추가했어요',
'assistant.card.tour.askAgain': '여기서는 더 이상 계획할 수 없어요. 다시 요청해 주세요.',
'assistant.card.tour.needsPace': '이 달리기를 저장하려면 어시스턴트에게 페이스를 알려 주세요.',
'assistant.card.tour.open': '투어에서 열기',
'assistant.card.tour.openPhone': '투어 열기',
'assistant.card.tour.unsaved': '편집 중인 투어를 먼저 저장하거나 취소하세요',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Wordt ingepland…',
'assistant.card.tour.added': 'Toegevoegd aan de reis',
'assistant.card.tour.askAgain': 'Dit kan hier niet meer worden ingepland. Vraag er opnieuw om.',
'assistant.card.tour.needsPace': 'Geef de assistent je tempo om deze run op te slaan.',
'assistant.card.tour.open': 'Openen in Tochten',
'assistant.card.tour.openPhone': 'Tocht openen',
'assistant.card.tour.unsaved': 'Sla eerst de tocht op die je bewerkt, of gooi hem weg',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planowanie…',
'assistant.card.tour.added': 'Dodano do podróży',
'assistant.card.tour.askAgain': 'Tego nie da się już stąd zaplanować. Poproś o to jeszcze raz.',
'assistant.card.tour.needsPace': 'Podaj asystentowi swoje tempo, aby zapisać ten bieg.',
'assistant.card.tour.open': 'Otwórz w Wycieczkach',
'assistant.card.tour.openPhone': 'Otwórz wycieczkę',
'assistant.card.tour.unsaved': 'Najpierw zapisz lub odrzuć edytowaną wycieczkę',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Планируется…',
'assistant.card.tour.added': 'Добавлено в поездку',
'assistant.card.tour.askAgain': 'Отсюда это уже не запланировать. Спросите ещё раз.',
'assistant.card.tour.needsPace': 'Сообщите ассистенту свой темп, чтобы сохранить эту пробежку.',
'assistant.card.tour.open': 'Открыть в Турах',
'assistant.card.tour.openPhone': 'Открыть тур',
'assistant.card.tour.unsaved': 'Сначала сохраните или отмените тур, который вы редактируете',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Plánuje sa…',
'assistant.card.tour.added': 'Pridané do cesty',
'assistant.card.tour.askAgain': 'Odtiaľto sa to už naplánovať nedá. Opýtaj sa na to znova.',
'assistant.card.tour.needsPace': 'Povedz asistentovi svoje tempo, aby sa tento beh mohol uložiť.',
'assistant.card.tour.open': 'Otvoriť v Túrach',
'assistant.card.tour.openPhone': 'Otvoriť túru',
'assistant.card.tour.unsaved': 'Najprv uložte alebo zahoďte túru, ktorú upravujete',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planeras…',
'assistant.card.tour.added': 'Tillagd i resan',
'assistant.card.tour.askAgain': 'Det här kan inte längre planeras härifrån. Fråga efter det igen.',
'assistant.card.tour.needsPace': 'Berätta din takt för assistenten för att spara den här löprundan.',
'assistant.card.tour.open': 'Öppna i Turer',
'assistant.card.tour.openPhone': 'Öppna tur',
'assistant.card.tour.unsaved': 'Spara eller släng först turen du redigerar',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'กำลังวางแผน…',
'assistant.card.tour.added': 'เพิ่มในทริปแล้ว',
'assistant.card.tour.askAgain': 'วางแผนจากตรงนี้ไม่ได้แล้ว โปรดขอใหม่อีกครั้ง',
'assistant.card.tour.needsPace': 'บอกเพซของคุณให้ผู้ช่วยทราบเพื่อบันทึกการวิ่งนี้',
'assistant.card.tour.open': 'เปิดในทัวร์',
'assistant.card.tour.openPhone': 'เปิดทัวร์',
'assistant.card.tour.unsaved': 'บันทึกหรือยกเลิกทัวร์ที่คุณกำลังแก้ไขก่อน',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Planlanıyor…',
'assistant.card.tour.added': 'Geziye eklendi',
'assistant.card.tour.askAgain': 'Bu artık buradan planlanamıyor. Yeniden iste.',
'assistant.card.tour.needsPace': 'Bu koşuyu kaydetmek için asistana temponu söyle.',
'assistant.card.tour.open': 'Turlar sekmesinde aç',
'assistant.card.tour.openPhone': 'Turu aç',
'assistant.card.tour.unsaved': 'Önce düzenlediğiniz turu kaydedin ya da vazgeçin',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Плануємо…',
'assistant.card.tour.added': 'Додано до подорожі',
'assistant.card.tour.askAgain': 'Звідси це вже не запланувати. Запитайте ще раз.',
'assistant.card.tour.needsPace': 'Скажіть асистенту свій темп, щоб зберегти цю пробіжку.',
'assistant.card.tour.open': 'Відкрити в Турах',
'assistant.card.tour.openPhone': 'Відкрити тур',
'assistant.card.tour.unsaved': 'Спершу збережіть або скасуйте тур, який ви редагуєте',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': 'Đang lên kế hoạch…',
'assistant.card.tour.added': 'Đã thêm vào chuyến đi',
'assistant.card.tour.askAgain': 'Không thể lên kế hoạch từ đây nữa. Hãy hỏi lại.',
'assistant.card.tour.needsPace': 'Hãy cho trợ lý biết tốc độ của bạn để lưu buổi chạy này.',
'assistant.card.tour.open': 'Mở trong Chuyến tham quan',
'assistant.card.tour.openPhone': 'Mở chuyến đi',
'assistant.card.tour.unsaved': 'Hãy lưu hoặc bỏ chuyến đi bạn đang chỉnh sửa trước',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': '正在安排…',
'assistant.card.tour.added': '已加入旅程',
'assistant.card.tour.askAgain': '這裡已無法再加入計畫,請重新詢問。',
'assistant.card.tour.needsPace': '告訴助理你的配速即可儲存這次跑步。',
'assistant.card.tour.open': '在遊程中開啟',
'assistant.card.tour.openPhone': '開啟路線',
'assistant.card.tour.unsaved': '請先儲存或捨棄正在編輯的路線',
+1
View File
@@ -15,6 +15,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.adding': '正在安排…',
'assistant.card.tour.added': '已加入行程',
'assistant.card.tour.askAgain': '这里已无法再加入计划,请重新询问。',
'assistant.card.tour.needsPace': '告诉助手你的配速即可保存这次跑步。',
'assistant.card.tour.open': '在游览中打开',
'assistant.card.tour.openPhone': '打开路线',
'assistant.card.tour.unsaved': '请先保存或放弃正在编辑的路线',
+3
View File
@@ -84,6 +84,9 @@ export * from './tours/walk-remaining';
export * from './tours/walk-ferry';
export * from './tours/tours.metrics';
export * from './tours/hiking-costing';
export * from './tours/tour-costing';
export * from './tours/tour-plan.schema';
export * from './tours/sport-tour-create.schema';
export * from './tours/hike-plan.schema';
export * from './tours/tour-hike.schema';
export * from './tours/tour-display.schema';
@@ -0,0 +1,38 @@
import { paddleTourCreateRequestSchema, skiTourCreateRequestSchema } from './sport-tour-create.schema';
import { describe, expect, it } from 'vitest';
const POINTS = [
{ lat: 48.1, lng: 11.6, name: 'Put-in' },
{ lat: 48.15, lng: 11.62 },
];
describe('paddleTourCreateRequestSchema', () => {
it('SPORT-CREATE-001: a paddle tour names its water; the boat, styles and speeds may be left out', () => {
expect(
paddleTourCreateRequestSchema.safeParse({ name: 'Isar', waypoints: POINTS, water_context: 'river' }).success,
).toBe(true);
expect(paddleTourCreateRequestSchema.safeParse({ name: 'Isar', waypoints: POINTS }).success).toBe(false);
expect(
paddleTourCreateRequestSchema.safeParse({ name: 'Isar', waypoints: POINTS, water_context: 'sea' }).success,
).toBe(false);
expect(
paddleTourCreateRequestSchema.safeParse({ name: 'Isar', waypoints: [POINTS[0]], water_context: 'lake' }).success,
).toBe(false);
});
});
describe('skiTourCreateRequestSchema', () => {
it('SPORT-CREATE-002: a cross-country tour needs its technique, no other discipline takes one', () => {
const base = { name: 'Loipe', waypoints: POINTS };
expect(skiTourCreateRequestSchema.safeParse({ ...base, discipline: 'alpine' }).success).toBe(true);
const missing = skiTourCreateRequestSchema.safeParse({ ...base, discipline: 'cross_country' });
expect(missing.error?.issues[0]?.path).toEqual(['technique']);
expect(
skiTourCreateRequestSchema.safeParse({ ...base, discipline: 'cross_country', technique: 'skating' }).success,
).toBe(true);
expect(skiTourCreateRequestSchema.safeParse({ ...base, discipline: 'alpine', technique: 'classic' }).success).toBe(
false,
);
});
});
@@ -0,0 +1,101 @@
import { MAX_PADDLE_ROUTE_LEGS, paddleLockPolicySchema } from '../paddle/paddle-route.schema';
import { MAX_SKI_ROUTE_LEGS } from '../ski/ski-route.schema';
import { portageSpeedKmhSchema } from './paddle-plan.schema';
import { skiDisciplineSchema, skiIssues, skiRouteOptionsSchema, skiTechniqueSchema } from './ski.schema';
import { hikeDayIdSchema, hikeStartTimeSchema, plannedMinutesSchema } from './tour-hike.schema';
import {
paddleBoatTypeSchema,
paddleLakeStyleSchema,
paddleSpeedKmhSchema,
paddleWaterContextSchema,
tourDescriptionSchema,
tourNameSchema,
tourWaypointSchema,
} from './tours.schema';
import { z } from 'zod';
/**
* A paddle tour or a ski tour the assistant planned with plan_paddle_route
* or plan_ski_route, saved once the traveller said yes. The legs run from
* each point to the next; the server plans them again with the same router
* (both are deterministic over the same data), joins them into one line with
* its heights and saves the tour the way the planner's save does. A leg that
* cannot be planned refuses the tour; nothing is saved. Nothing here carries
* a line from the caller.
*/
const { lat, lng, name } = tourWaypointSchema.shape;
const sportPointSchema = z
.object({ lat, lng, name: name.describe('What the point is, such as a put-in, a lift station or a hut') })
.strict();
export type SportTourPoint = z.infer<typeof sportPointSchema>;
const dayFields = {
planned_duration_minutes: plannedMinutesSchema.optional(),
day_id: hikeDayIdSchema.optional().describe('The day of the trip the tour is put on, as list_days gives it'),
start_time: hikeStartTimeSchema
.optional()
.describe('When the tour starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
};
export const paddleTourCreateRequestSchema = z
.object({
name: tourNameSchema.describe('What the paddle tour is called'),
description: tourDescriptionSchema.describe('A few words about the tour for the travellers, up to 2000 characters'),
waypoints: z
.array(sportPointSchema)
.min(2)
.max(MAX_PADDLE_ROUTE_LEGS + 1)
.describe(
'The points on the water in order: the put-in, the points on the way and the take-out; each leg runs from one to the next, as plan_paddle_route planned them',
),
boat_type: paddleBoatTypeSchema.optional().describe('The boat: kayak (the default), canoe, sup or other'),
water_context: paddleWaterContextSchema.describe('The water the tour is on: river or lake'),
lake_style: paddleLakeStyleSchema
.optional()
.describe('direct crosses lakes straight (the default), shore keeps near the shore'),
lock_policy: paddleLockPolicySchema
.optional()
.describe('auto (whichever is quicker, the default), use the locks, or portage round them'),
paddle_speed_kmh: paddleSpeedKmhSchema
.optional()
.describe("The paddling speed in km/h the time is reckoned with; the boat's starting value when left out"),
portage_speed_kmh: portageSpeedKmhSchema
.optional()
.describe('The carrying speed on a portage in km/h; 2 when left out'),
...dayFields,
})
.strict();
export type PaddleTourCreateRequest = z.infer<typeof paddleTourCreateRequestSchema>;
export const skiTourCreateRequestSchema = z
.object({
name: tourNameSchema.describe('What the ski tour is called'),
description: tourDescriptionSchema.describe('A few words about the tour for the travellers, up to 2000 characters'),
waypoints: z
.array(sportPointSchema)
.min(2)
.max(MAX_SKI_ROUTE_LEGS + 1)
.describe(
'The points in order: the start, the points on the way (a lift station, a hut, a summit) and the end; each leg runs from one to the next, as plan_ski_route planned them',
),
discipline: skiDisciplineSchema.describe(
'alpine, snowboard, cross_country, touring, snowshoe or winter_hike, as plan_ski_route was asked',
),
technique: skiTechniqueSchema
.optional()
.describe('Cross-country only, and needed there: classic, skating or unknown (the skier does not mind)'),
options: skiRouteOptionsSchema.optional().describe('The options plan_ski_route was asked with'),
...dayFields,
})
.strict()
.superRefine((value, ctx) => {
const issues = skiIssues(true, { ski_discipline: value.discipline, ski_technique: value.technique ?? null });
for (const issue of issues) {
const path = issue.path[0] === 'ski_technique' ? ['technique'] : ['discipline'];
ctx.addIssue({ code: 'custom', path, message: issue.message });
}
});
export type SkiTourCreateRequest = z.infer<typeof skiTourCreateRequestSchema>;
+22
View File
@@ -0,0 +1,22 @@
import { BIKE_COSTING, bikeCosting, RUN_COSTING, runCosting } from './tour-costing';
import { BIKE_PROFILES, RUN_PROFILES } from './tours.schema';
import { describe, expect, it } from 'vitest';
describe('tour costing (BIK-01, RUN-01)', () => {
it('TOUR-COST-001: every bike profile asks the bicycle costing for its own bicycle type', () => {
expect(BIKE_PROFILES.map((profile) => bikeCosting(profile).bicycle_type)).toEqual([
'Hybrid',
'Road',
'Cross',
'Mountain',
]);
expect(bikeCosting('road')).toBe(BIKE_COSTING.road);
});
it('TOUR-COST-002: a run avoids ferries whatever its profile, and only a trail run takes T2 paths', () => {
for (const profile of RUN_PROFILES) expect(runCosting(profile).use_ferry).toBe(0);
expect(runCosting('running')).toEqual({ use_tracks: 0, use_ferry: 0 });
expect(RUN_COSTING.trail.max_hiking_difficulty).toBe(2);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { BikeProfile, RunProfile } from './tours.schema';
/**
* What a bike tour and a run ask Valhalla for, by their profile: the
* bicycle costing of a bike tour and the pedestrian costing of a run. The
* planner in the browser and the server's tour routing both read them here,
* so a ride or a run is weighed alike wherever it is planned. A hike's own
* costing is `hikingCosting`.
*/
export type TourCostingOptions = Readonly<Record<string, number | string>>;
/**
* BIK-01 to BIK-04: what each bike profile asks Valhalla's bicycle costing for.
* Every one was checked against the public instance with trace_attributes over
* the routes it gave, before it was offered (shares of the length):
* - touring, Hybrid with bad surfaces avoided: Garmisch station to the Eckbauer
* 92 % asphalt and no gravel, where gravel took 18 % gravel on farm tracks.
* - road, Road at full surface avoidance: Herrsching to Andechs 100 % asphalt
* over 6.2 km, where touring took 4.98 km with 7 % dirt and 10 % compacted.
* - gravel, Cross with surfaces barely avoided: Munich Marienplatz to the
* Englischer Garten 39 % compacted paths along the Isar, touring 9 %.
* - mtb, Mountain with no surface avoidance and roads least wanted: Garmisch
* to the Eckbauer 63 % tracks and 38 % gravel.
* `use_roads` and the bicycle type also set the router's riding speed, so the
* time it gives is the profile's. None of it says a way is rideable or safe.
*/
export const BIKE_COSTING: Readonly<Record<BikeProfile, TourCostingOptions>> = {
touring: { bicycle_type: 'Hybrid', use_roads: 0.2, avoid_bad_surfaces: 1 },
road: { bicycle_type: 'Road', use_roads: 0.5, avoid_bad_surfaces: 1 },
gravel: { bicycle_type: 'Cross', use_roads: 0.1, avoid_bad_surfaces: 0.25 },
mtb: { bicycle_type: 'Mountain', use_roads: 0, avoid_bad_surfaces: 0 },
};
/**
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
* for. A run is run, so a ferry only where no way on land exists
* (`use_ferry` 0). Checked with trace_attributes against the public instance
* on 20.10.2026 (shares of the length):
* - running keeps to runnable, mostly paved ways: farm and forest tracks only
* where there is no other way (`use_tracks` 0), and no hiking limit, so the
* router keeps to the ways it takes for walking anyway (T1). Berlin S
* Grunewald into the forest went from 95 % tracks with 3 % paved to 67 %
* smooth paved paths over 3.58 km instead of 3.50 km; Munich Großhesseloher
* Brücke to Pullach from 79 % to 88 % smooth asphalt and no track left;
* Marienplatz to the Englischer Garten stayed as it was.
* - trail takes tracks wherever they serve (`use_tracks` 1) and admits
* mountain paths up to T2 (`max_hiking_difficulty` 2), never T3 and above.
* Above Freiburg Günterstal up to the Schauinsland it went from 82 % roads
* and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel
* and dirt over 9.57 km instead of 10.15 km; Rathen to the Bastei took 28 % of T2
* path that running leaves out; Heidelberg's old town, all streets, stayed
* as it was. Leaning on footways (`walkway_factor`) was tried and left out:
* along the Isar it moved the run onto paved footways, from 33 % to 18 %
* unpaved.
* The router's own time is a walker's and is never used: a run's time is its
* distance at the runner's pace, whatever the profile. Nothing here says a
* way can be run.
*/
export const RUN_COSTING: Readonly<Record<RunProfile, TourCostingOptions>> = {
running: { use_tracks: 0, use_ferry: 0 },
trail: { use_tracks: 1, use_ferry: 0, max_hiking_difficulty: 2 },
};
/** The bicycle costing of a bike tour of this profile. */
export function bikeCosting(profile: BikeProfile): TourCostingOptions {
return BIKE_COSTING[profile];
}
/** The pedestrian costing of a run of this profile. */
export function runCosting(profile: RunProfile): TourCostingOptions {
return RUN_COSTING[profile];
}
+21 -17
View File
@@ -60,7 +60,7 @@ export const TOUR_GPX_TEXT_MAX = 1_000_000;
* over several days whether a day's stage ends there (MDT-01), where the
* night is spent and the next stage starts.
*/
const hikeStopSchema = hikePlanPointSchema
export const hikeStopSchema = hikePlanPointSchema
.extend({
stage_end: z
.boolean()
@@ -72,7 +72,7 @@ const hikeStopSchema = hikePlanPointSchema
.strict();
export type HikeStop = z.infer<typeof hikeStopSchema>;
const hikeWaypointsSchema = z
export const hikeWaypointsSchema = z
.array(hikeStopSchema)
.min(2)
.max(HIKE_MAX_WAYPOINTS)
@@ -99,7 +99,10 @@ export function hikeStageIssues(
);
}
function refineStages(value: { waypoints?: readonly HikeStop[]; round_trip?: boolean }, ctx: z.RefinementCtx): void {
export function refineStages(
value: { waypoints?: readonly HikeStop[]; round_trip?: boolean },
ctx: z.RefinementCtx,
): void {
if (!value.waypoints) return;
for (const issue of hikeStageIssues(value.waypoints, value.round_trip)) {
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
@@ -114,22 +117,31 @@ 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');
export const roundTripSchema = z.boolean().describe('The hike ends back at its start: the way back is planned too');
const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
export const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
);
const hikeDateSchema = z
export const hikeDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe(
'The day the hike is planned for, YYYY-MM-DD, where it is on no day of the trip; day_id takes its own date',
);
const hikeDayIdSchema = idSchema.describe('The day of the trip the hike is planned on, as list_days gives it');
export const hikeDayIdSchema = idSchema.describe('The day of the trip the hike is planned on, as list_days gives it');
const hikeStartTimeSchema = clockTimeSchema.describe('When the hike starts, HH:MM');
export const hikeStartTimeSchema = clockTimeSchema.describe('When the hike starts, HH:MM');
export const variantLabelSchema = z
.string()
.trim()
.min(1)
.max(HIKE_VARIANT_LABEL_MAX)
.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',
);
export const hikeProposeRequestSchema = z
.object({
@@ -138,15 +150,7 @@ export const hikeProposeRequestSchema = z
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',
),
variant_label: variantLabelSchema.optional(),
day_id: hikeDayIdSchema.optional(),
date: hikeDateSchema.optional(),
start_time: hikeStartTimeSchema.optional(),
+61
View File
@@ -0,0 +1,61 @@
import { tourPlanCreateRequestSchema, tourProposalSchema, tourProposeRequestSchema } from './tour-plan.schema';
import { describe, expect, it } from 'vitest';
const POINTS = [
{ lat: 47.9, lng: 11.1 },
{ lat: 47.95, lng: 11.15, name: 'Herrsching' },
];
describe('tourProposeRequestSchema', () => {
it('TOUR-PLAN-001: a bike tour and a run are proposed with their own profile, the profile optional', () => {
expect(tourProposeRequestSchema.safeParse({ name: 'Lake', activity: 'bike', waypoints: POINTS }).success).toBe(
true,
);
const run = { name: 'Isar', activity: 'run', run_profile: 'trail', waypoints: POINTS };
expect(tourProposeRequestSchema.parse(run).run_profile).toBe('trail');
});
it('TOUR-PLAN-002: refuses a profile of the other kind, a pace on a bike tour, a hike and a stage at the start', () => {
const bad = [
{ name: 'X', activity: 'bike', run_profile: 'trail', waypoints: POINTS },
{ name: 'X', activity: 'bike', run_pace_seconds_per_km: 360, waypoints: POINTS },
{ name: 'X', activity: 'run', bike_profile: 'road', waypoints: POINTS },
{ name: 'X', activity: 'hike', waypoints: POINTS },
{ name: 'X', activity: 'bike', waypoints: [{ ...POINTS[0], stage_end: true }, POINTS[1]] },
{ name: 'X', activity: 'bike', max_hiking_difficulty: 3, waypoints: POINTS },
];
for (const request of bad) expect(tourProposeRequestSchema.safeParse(request).success).toBe(false);
});
});
describe('tourPlanCreateRequestSchema', () => {
it("TOUR-PLAN-003: a run is created only with the runner's pace, a bike tour without one", () => {
const run = { name: 'Isar', activity: 'run', waypoints: POINTS };
const refused = tourPlanCreateRequestSchema.safeParse(run);
expect(refused.success).toBe(false);
expect(refused.error?.issues[0]?.path).toEqual(['run_pace_seconds_per_km']);
expect(tourPlanCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: 330 }).success).toBe(true);
expect(tourPlanCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: 30 }).success).toBe(false);
const bike = {
name: 'Lake',
activity: 'bike',
bike_profile: 'road',
waypoints: POINTS,
day_id: 4,
start_time: '09:00',
};
expect(tourPlanCreateRequestSchema.safeParse(bike).success).toBe(true);
});
});
describe('tourProposalSchema', () => {
it("TOUR-PLAN-004: holds a hike's limit, preferences and grades at null and knows a pace as a time's basis", () => {
const shape = tourProposalSchema.shape;
expect(shape.max_hiking_difficulty.safeParse(null).success).toBe(true);
expect(shape.max_hiking_difficulty.safeParse(2).success).toBe(false);
expect(shape.duration_basis.safeParse('pace').success).toBe(true);
expect(shape.duration_basis.safeParse('din33466').success).toBe(false);
expect(shape.activity.safeParse('run').success).toBe(true);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { runPaceSecondsPerKmSchema } from './run-pace';
import { includeGeometryFlagSchema } from './tour-display.schema';
import {
hikeDateSchema,
hikeDayIdSchema,
hikeProposalSchema,
hikeStartTimeSchema,
hikeWaypointsSchema,
plannedMinutesSchema,
refineStages,
roundTripSchema,
variantLabelSchema,
} from './tour-hike.schema';
import { bikeProfileSchema, runProfileSchema, tourDescriptionSchema, tourNameSchema } from './tours.schema';
import { z } from 'zod';
/**
* A bike tour or a run an assistant plans with the traveller over MCP, the
* way a hike is planned (propose_hike, create_hike): proposed first, read
* only, as often as ways are compared, and created once the traveller said
* yes. The server plans the way through the points with the costing of the
* tour's profile (`bikeCosting`, `runCosting`), its heights and its time: a
* bike tour the router's riding time for its profile, a run its distance at
* the runner's pace. Nothing here carries a line from the caller.
*/
/** The tour types planned this way besides a hike, which keeps its own tools. */
export const TOUR_PLAN_ACTIVITIES = ['bike', 'run'] as const;
export const tourPlanActivitySchema = z.enum(TOUR_PLAN_ACTIVITIES);
export type TourPlanActivity = z.infer<typeof tourPlanActivitySchema>;
const activitySchema = tourPlanActivitySchema.describe(
'bike for a bike tour, run for a run; a hike is planned with propose_hike, a city walk with propose_city_walk',
);
const bikeProfileField = bikeProfileSchema.describe(
'A bike tour only, what it is ridden on: touring (paved ways, cycle paths and quiet roads, the default), road (asphalt only, a road bike), gravel (gravel and farm tracks) or mtb (rough tracks and trails, a mountain bike)',
);
const runProfileField = runProfileSchema.describe(
'A run only: running (runnable, mostly paved ways in towns, parks and along rivers, the default) or trail (forest tracks and natural paths up to T2 mountain paths)',
);
const runPaceField = runPaceSecondsPerKmSchema.describe(
"A run only: the runner's own pace in seconds per kilometre, 360 for 6:00 min/km; the run's time is its distance at this pace",
);
/** What belongs to which activity: a profile of its own kind, and a pace on a run only. */
function refineActivity(
value: {
activity: TourPlanActivity;
bike_profile?: unknown;
run_profile?: unknown;
run_pace_seconds_per_km?: unknown;
},
ctx: z.RefinementCtx,
): void {
const add = (path: string, message: string) => ctx.addIssue({ code: 'custom', path: [path], message });
if (value.activity === 'bike') {
if (value.run_profile !== undefined) add('run_profile', 'run_profile belongs to a run');
if (value.run_pace_seconds_per_km !== undefined) add('run_pace_seconds_per_km', 'a pace belongs to a run');
} else if (value.bike_profile !== undefined) {
add('bike_profile', 'bike_profile belongs to a bike tour');
}
}
const tourPlanShape = {
activity: activitySchema,
bike_profile: bikeProfileField.optional(),
run_profile: runProfileField.optional(),
run_pace_seconds_per_km: runPaceField.optional(),
waypoints: hikeWaypointsSchema.describe(
'The points the tour goes through in order: its start, the points on the way and its end. For a round trip leave the end out and set round_trip. A tour over several days marks the stop of each night with stage_end',
),
round_trip: roundTripSchema.optional(),
};
export const tourProposeRequestSchema = z
.object({
name: tourNameSchema.describe('What the tour is called, such as "Round the Ammersee"'),
...tourPlanShape,
variant_label: variantLabelSchema.optional(),
day_id: hikeDayIdSchema.optional(),
date: hikeDateSchema.optional(),
start_time: hikeStartTimeSchema.optional(),
along: z
.boolean()
.optional()
.describe(
'Looks for water, food, toilets and viewpoints along the way; true when left out, false when comparing ways',
),
include_geometry: includeGeometryFlagSchema,
})
.strict()
.superRefine(refineStages)
.superRefine(refineActivity);
export type TourProposeRequest = z.infer<typeof tourProposeRequestSchema>;
export const tourPlanCreateRequestSchema = z
.object({
name: tourNameSchema.describe('What the tour is called'),
description: tourDescriptionSchema.describe('A few words about the tour for the travellers, up to 2000 characters'),
...tourPlanShape,
planned_duration_minutes: plannedMinutesSchema.optional(),
day_id: hikeDayIdSchema.optional().describe('The day of the trip the tour is put on, as list_days gives it'),
start_time: hikeStartTimeSchema
.optional()
.describe('When the tour starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
})
.strict()
.superRefine(refineStages)
.superRefine(refineActivity)
.superRefine((value, ctx) => {
// The planner saves a run only with a pace: without one it has no time at all.
if (value.activity === 'run' && value.run_pace_seconds_per_km === undefined) {
ctx.addIssue({
code: 'custom',
path: ['run_pace_seconds_per_km'],
message: "a run is saved with the runner's pace: ask for it",
});
}
});
export type TourPlanCreateRequest = z.infer<typeof tourPlanCreateRequestSchema>;
/** How a bike tour's or a run's time was reckoned: the router's riding time, or the distance at the runner's pace. */
export const TOUR_PLAN_DURATION_BASES = ['router', 'pace'] as const;
/**
* What propose_tour answers: a hike's proposal (the way, its measures, its
* points with where and when each is reached, the places along it, the day)
* for a bike tour or a run, with its activity and profile. A hike's limit,
* preferences and SAC grades are a hike's alone and stay null. A run without
* a pace has no time.
*/
export const tourProposalSchema = hikeProposalSchema.extend({
activity: tourPlanActivitySchema,
bike_profile: bikeProfileSchema.nullable(),
run_profile: runProfileSchema.nullable(),
run_pace_seconds_per_km: runPaceSecondsPerKmSchema.nullable(),
duration_basis: z.enum(TOUR_PLAN_DURATION_BASES).nullable(),
max_hiking_difficulty: z.null(),
hiking_preferences: z.null(),
sac: z.null(),
});
export type TourProposal = z.infer<typeof tourProposalSchema>;