mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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': 'احفظ الجولة التي تعدّلها أو تجاهلها أولًا',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Αποθηκεύστε ή απορρίψτε πρώτα την εκδρομή που επεξεργάζεστε',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '編集中のツアーを先に保存するか破棄してください',
|
||||
|
||||
@@ -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': '편집 중인 투어를 먼저 저장하거나 취소하세요',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ę',
|
||||
|
||||
@@ -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': 'Сначала сохраните или отмените тур, который вы редактируете',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'บันทึกหรือยกเลิกทัวร์ที่คุณกำลังแก้ไขก่อน',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Спершу збережіть або скасуйте тур, який ви редагуєте',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '請先儲存或捨棄正在編輯的路線',
|
||||
|
||||
@@ -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': '请先保存或放弃正在编辑的路线',
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user