feat(tours): assistant takes found places into saved tours and route files from the chat

P6: insert_tour_stop and remove_tour_stop take a place into a saved hike or
city walk as one stop and out again, each the other's undo, with change
cards in the chat. The planner's insertion rule (poiInsertion) moved to
shared so the planner and the server place a stop the same way. "+ Tour" on
a found place holds it as a stop of the saved tour open in the planner.

P7: GPX, KML and KMZ files attached in the chat are read into their tracks
and each is held as a change that takes it in as a hike through
import_tour_gpx, undoable per track; the GPX text stays on the held call.
This commit is contained in:
Maurice
2026-10-23 21:18:19 +02:00
parent 454fc1a5f4
commit cd60d4d859
92 changed files with 3257 additions and 111 deletions
@@ -9,10 +9,12 @@ import {
assistantChatRequestSchema,
assistantDecisionsRequestSchema,
assistantFoundPlaceSchema,
assistantInsertFoundPlaceRequestSchema,
assistantMessageSchema,
assistantPlacesSchema,
assistantStopRequestSchema,
assistantWeatherSchema,
isAssistantTourFile,
UI_STREAM_DONE,
uiStreamChunkSchema,
type UiStreamChunk,
@@ -263,6 +265,32 @@ describe('assistantAddFoundPlaceRequestSchema', () => {
});
});
describe('assistantInsertFoundPlaceRequestSchema', () => {
const parse = (body: Record<string, unknown>) =>
assistantInsertFoundPlaceRequestSchema.safeParse({ ...ctx, ...body });
it('names a found place by its ref and the saved tour it goes into', () => {
expect(parse({ ref: 'toolu_01:2', tourId: 64 }).success).toBe(true);
});
it('refuses a bad tour, a day instead of a tour and anything about the stop itself', () => {
expect(parse({ ref: 'a:1', tourId: 0 }).success).toBe(false);
expect(parse({ ref: 'a:1', dayId: 3 }).success).toBe(false);
expect(parse({ ref: 'a:1', tourId: 64, after_key: 'abc123' }).success).toBe(false);
expect(assistantInsertFoundPlaceRequestSchema.safeParse({ ref: 'a:1', tourId: 64 }).success).toBe(false);
});
});
describe('route files attached in the chat', () => {
it('tells a GPX, KML or KMZ file by its name, whatever its case', () => {
expect(isAssistantTourFile('Herzogstand.GPX')).toBe(true);
expect(isAssistantTourFile('map.kml')).toBe(true);
expect(isAssistantTourFile('map.kmz')).toBe(true);
expect(isAssistantTourFile('ticket.pdf')).toBe(false);
expect(isAssistantTourFile('gpx')).toBe(false);
});
});
describe('the effect of a change', () => {
const preview = {
toolCallId: 'c1',
+26
View File
@@ -68,6 +68,8 @@ export const ASSISTANT_ERROR_CODES = [
'empty_answer',
'offline',
'no_bookings_found',
// An attached GPX, KML or KMZ file without a single track or route to take in as a tour.
'no_tracks_found',
'import_unavailable',
// A found place was suggested while the thread still holds changes nobody decided on.
'decide_first',
@@ -293,6 +295,30 @@ export const assistantAddFoundPlaceRequestSchema = assistantClientCtxSchema
.strict();
export type AssistantAddFoundPlaceRequest = z.infer<typeof assistantAddFoundPlaceRequestSchema>;
/**
* POST /threads/:threadId/found-places/tour: "+ Tour" on a place a search
* found, while a saved tour is open in the planner. Held like "+ Day 3": the
* place comes from the stored search results, the tour must be one of this
* trip's, and the server picks where along it the stop goes.
*/
export const assistantInsertFoundPlaceRequestSchema = assistantClientCtxSchema
.extend({
ref: z.string().min(1).max(80),
tourId: idSchema,
})
.strict();
export type AssistantInsertFoundPlaceRequest = z.infer<typeof assistantInsertFoundPlaceRequestSchema>;
/** The route files the chat takes in as tours (P7), beside the booking files. */
export const ASSISTANT_TOUR_FILE_EXTENSIONS = ['.gpx', '.kml', '.kmz'] as const;
/** Whether a file the user attached is a route file, by its name. */
export function isAssistantTourFile(fileName: string): boolean {
const dot = fileName.lastIndexOf('.');
const ext = dot < 0 ? '' : fileName.slice(dot).toLowerCase();
return (ASSISTANT_TOUR_FILE_EXTENSIONS as readonly string[]).includes(ext);
}
/**
* POST /threads/:threadId/proposals: take a proposal an answer showed as a
* card ("Plan this"). `toolCallId` names the read whose card it was; the
@@ -37,6 +37,9 @@ export const TOUR_VERB_KEYS = [
'setPaddlePlan',
'decideObstacles',
'restoreTrack',
// A found place taken into a saved tour as a stop, or taken out again (P6).
'addTourStop',
'removeTourStop',
] as const;
/** 'tour' is a core entity already. */
export const TOUR_APPLIED_ENTITIES = [] as const;
+6
View File
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'التحقق من التكاليف',
'assistant.step.help': 'البحث في المساعدة',
'assistant.step.readingBooking': 'قراءة الحجز',
'assistant.step.readingTourFile': 'جارٍ قراءة ملف المسار',
'assistant.step.planningHike': 'تخطيط رحلة المشي',
'assistant.step.planningHikeTo': 'تخطيط رحلة المشي إلى {place}',
'assistant.step.expenses': 'قراءة النفقات',
@@ -230,6 +231,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'كان ذلك كثيرًا دفعة واحدة. هل أكمل؟',
'assistant.error.offline': 'غير متصل. يحتاج المساعد إلى الإنترنت.',
'assistant.error.noBookingsFound': 'لم يُعثر على أي حجز في هذا الملف.',
'assistant.error.noTracksFound': 'لم يُعثر على أي مسار في هذا الملف.',
'assistant.error.importUnavailable': 'قراءة ملفات الحجز غير متاحة هنا.',
'assistant.error.generic': 'حدث خطأ ما.',
'assistant.errorAction.retry': 'إعادة المحاولة',
@@ -308,6 +310,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'تم الاقتراح',
'assistant.place.inTrip': 'موجود في رحلتك بالفعل',
'assistant.place.showOnMap': 'على الخريطة',
'assistant.place.addToTour': 'إلى الجولة',
'assistant.place.addToTourHint': 'إضافة كمحطة إلى {name}',
'assistant.place.openNow': 'مفتوح الآن',
'assistant.place.closed': 'مغلق',
'assistant.place.fromPrev': '{distance} من المحطة السابقة',
@@ -507,6 +511,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'اتخاذ قرار بشأن العوائق',
'assistant.change.verb.restoreTrack': 'العودة إلى المسار المسجل',
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
'assistant.change.verb.addTourStop': 'إضافة محطة إلى الجولة',
'assistant.change.verb.removeTourStop': 'إزالة محطة من الجولة',
'assistant.change.label.field': 'القيمة',
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
'assistant.change.label.track': 'مسار مسجل',
+6
View File
@@ -119,6 +119,7 @@ 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.readingTourFile': 'Marşrut faylı 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.expenses': 'Xərclər oxunur',
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Bir dəfəyə çox oldu. Davam edim?',
'assistant.error.offline': 'Oflayn. Köməkçinin internetə ehtiyacı var.',
'assistant.error.noBookingsFound': 'Bu faylda bron tapılmadı.',
'assistant.error.noTracksFound': 'Bu faylda heç bir trek və ya marşrut tapılmadı.',
'assistant.error.importUnavailable': 'Bron fayllarının oxunması burada mümkün deyil.',
'assistant.error.generic': 'Nəsə səhv getdi.',
'assistant.errorAction.retry': 'Yenidən cəhd et',
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Təklif edildi',
'assistant.place.inTrip': 'Artıq səyahətinizdədir',
'assistant.place.showOnMap': 'Xəritədə',
'assistant.place.addToTour': 'Tura əlavə et',
'assistant.place.addToTourHint': '{name} turuna dayanacaq kimi əlavə et',
'assistant.place.openNow': 'İndi açıqdır',
'assistant.place.closed': 'Bağlıdır',
'assistant.place.fromPrev': 'Əvvəlki dayanacaqdan {distance}',
@@ -430,6 +434,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Maneələr barədə qərar ver',
'assistant.change.verb.restoreTrack': 'Yazılmış treke qayıt',
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
'assistant.change.verb.addTourStop': 'Tura dayanacaq əlavə et',
'assistant.change.verb.removeTourStop': 'Turdan dayanacağı sil',
'assistant.change.label.field': 'Dəyər',
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
'assistant.change.label.track': 'Qeydə alınmış marşrut',
+6
View File
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Verificando os custos',
'assistant.step.help': 'Consultando a ajuda',
'assistant.step.readingBooking': 'Lendo a reserva',
'assistant.step.readingTourFile': 'Lendo o arquivo de rota',
'assistant.step.planningHike': 'Planejando a trilha',
'assistant.step.planningHikeTo': 'Planejando a trilha até {place}',
'assistant.step.expenses': 'Lendo as despesas',
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Foi muita coisa de uma vez. Posso continuar?',
'assistant.error.offline': 'Sem conexão. O assistente precisa de internet.',
'assistant.error.noBookingsFound': 'Nenhuma reserva encontrada neste arquivo.',
'assistant.error.noTracksFound': 'Nenhuma trilha ou rota encontrada neste arquivo.',
'assistant.error.importUnavailable': 'A leitura de arquivos de reserva não está disponível aqui.',
'assistant.error.generic': 'Algo deu errado.',
'assistant.errorAction.retry': 'Tentar novamente',
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Sugerido',
'assistant.place.inTrip': 'Já está na sua viagem',
'assistant.place.showOnMap': 'No mapa',
'assistant.place.addToTour': 'Ao passeio',
'assistant.place.addToTourHint': 'Adicionar como parada em {name}',
'assistant.place.openNow': 'Aberto agora',
'assistant.place.closed': 'Fechado',
'assistant.place.fromPrev': 'A {distance} da parada anterior',
@@ -430,6 +434,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Decidir sobre obstáculos',
'assistant.change.verb.restoreTrack': 'Voltar ao trajeto gravado',
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
'assistant.change.verb.addTourStop': 'Adicionar parada ao passeio',
'assistant.change.verb.removeTourStop': 'Remover parada do passeio',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
'assistant.change.label.track': 'Trilha gravada',
+6
View File
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisant les despeses',
'assistant.step.help': 'Cercant a l’ajuda',
'assistant.step.readingBooking': 'Llegint la reserva',
'assistant.step.readingTourFile': 'Llegint el fitxer de ruta',
'assistant.step.planningHike': 'Planificant l’excursió',
'assistant.step.planningHikeTo': 'Planificant l’excursió fins a {place}',
'assistant.step.expenses': 'Llegint les despeses',
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Ha estat molt de cop. Continuo?',
'assistant.error.offline': 'Sense connexió. L’assistent necessita internet.',
'assistant.error.noBookingsFound': 'No s’ha trobat cap reserva en aquest fitxer.',
'assistant.error.noTracksFound': 'No s’ha trobat cap traç ni ruta en aquest fitxer.',
'assistant.error.importUnavailable': 'La lectura de fitxers de reserva no està disponible aquí.',
'assistant.error.generic': 'Alguna cosa ha fallat.',
'assistant.errorAction.retry': 'Torna-ho a provar',
@@ -286,6 +288,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Proposat',
'assistant.place.inTrip': 'Ja és al teu viatge',
'assistant.place.showOnMap': 'Al mapa',
'assistant.place.addToTour': 'A la ruta',
'assistant.place.addToTourHint': 'Afegeix com a parada a {name}',
'assistant.place.openNow': 'Obert ara',
'assistant.place.closed': 'Tancat',
'assistant.place.fromPrev': 'A {distance} de la parada anterior',
@@ -433,6 +437,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Decidir sobre els obstacles',
'assistant.change.verb.restoreTrack': 'Tornar a la traça gravada',
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
'assistant.change.verb.addTourStop': 'Afegeix una parada a la ruta',
'assistant.change.verb.removeTourStop': 'Treu una parada de la ruta',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
'assistant.change.label.track': 'Traç enregistrat',
+6
View File
@@ -118,6 +118,7 @@ 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.readingTourFile': 'Čtu soubor s trasou',
'assistant.step.planningHike': 'Plánuji túru',
'assistant.step.planningHikeTo': 'Plánuji túru do místa {place}',
'assistant.step.expenses': 'Čtu výdaje',
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Bylo toho najednou hodně. Mám pokračovat?',
'assistant.error.offline': 'Offline. Asistent potřebuje internet.',
'assistant.error.noBookingsFound': 'V tomto souboru nebyla nalezena žádná rezervace.',
'assistant.error.noTracksFound': 'V tomto souboru nebyla nalezena žádná stopa ani trasa.',
'assistant.error.importUnavailable': 'Čtení souborů s rezervacemi tu není k dispozici.',
'assistant.error.generic': 'Něco se pokazilo.',
'assistant.errorAction.retry': 'Zkusit znovu',
@@ -289,6 +291,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Navrženo',
'assistant.place.inTrip': 'Už je ve tvém výletu',
'assistant.place.showOnMap': 'Na mapě',
'assistant.place.addToTour': 'Do túry',
'assistant.place.addToTourHint': 'Přidat jako zastávku do {name}',
'assistant.place.openNow': 'Teď otevřeno',
'assistant.place.closed': 'Zavřeno',
'assistant.place.fromPrev': '{distance} od předchozí zastávky',
@@ -448,6 +452,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Rozhodnout o překážkách',
'assistant.change.verb.restoreTrack': 'Zpět k zaznamenané stopě',
'assistant.change.verb.importTour': 'Přidat výlet ze souboru',
'assistant.change.verb.addTourStop': 'Přidat zastávku do túry',
'assistant.change.verb.removeTourStop': 'Odebrat zastávku z túry',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximální obtížnost stezky',
'assistant.change.label.track': 'Zaznamenaná trasa',
+6
View File
@@ -119,6 +119,7 @@ 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.readingTourFile': 'Liest die Routendatei',
'assistant.step.planningHike': 'Plant die Wanderung',
'assistant.step.planningHikeTo': 'Plant die Wanderung bis {place}',
'assistant.step.expenses': 'Liest die Ausgaben',
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Das war viel auf einmal. Soll ich weitermachen?',
'assistant.error.offline': 'Offline. Der Assistent braucht Internet.',
'assistant.error.noBookingsFound': 'In dieser Datei wurde keine Buchung gefunden.',
'assistant.error.noTracksFound': 'In dieser Datei wurde keine Strecke und keine Route gefunden.',
'assistant.error.importUnavailable': 'Buchungsdateien lesen ist hier nicht verfügbar.',
'assistant.error.generic': 'Etwas ist schiefgelaufen.',
'assistant.errorAction.retry': 'Nochmal versuchen',
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Vorgeschlagen',
'assistant.place.inTrip': 'Schon im Trip',
'assistant.place.showOnMap': 'Auf Karte',
'assistant.place.addToTour': 'Zur Tour',
'assistant.place.addToTourHint': 'Als Halt zu {name} hinzufügen',
'assistant.place.openNow': 'Geöffnet',
'assistant.place.closed': 'Geschlossen',
'assistant.place.fromPrev': '{distance} vom vorigen Stopp',
@@ -430,6 +434,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Über Hindernisse entscheiden',
'assistant.change.verb.restoreTrack': 'Zurück zur Aufzeichnung',
'assistant.change.verb.importTour': 'Tour aus Datei hinzufügen',
'assistant.change.verb.addTourStop': 'Halt zur Tour hinzufügen',
'assistant.change.verb.removeTourStop': 'Halt aus der Tour entfernen',
'assistant.change.label.field': 'Wert',
'assistant.change.label.difficulty': 'Maximale Wegschwierigkeit',
'assistant.change.label.track': 'Aufzeichnung',
+6
View File
@@ -115,6 +115,7 @@ 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.readingTourFile': 'Reading the route file',
'assistant.step.planningHike': 'Planning the hike',
'assistant.step.planningHikeTo': 'Planning the hike to {place}',
'assistant.step.expenses': 'Reading the expenses',
@@ -213,6 +214,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'That was a lot at once. Shall I continue?',
'assistant.error.offline': 'Offline. The assistant needs internet.',
'assistant.error.noBookingsFound': 'No booking found in this file.',
'assistant.error.noTracksFound': 'No track or route found in this file.',
'assistant.error.importUnavailable': 'Reading booking files isn’t available here.',
'assistant.error.generic': 'Something went wrong.',
'assistant.errorAction.retry': 'Try again',
@@ -279,6 +281,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Suggested',
'assistant.place.inTrip': 'Already in your trip',
'assistant.place.showOnMap': 'On map',
'assistant.place.addToTour': 'Add to tour',
'assistant.place.addToTourHint': 'Add as a stop to {name}',
'assistant.place.openNow': 'Open now',
'assistant.place.closed': 'Closed',
'assistant.place.fromPrev': '{distance} from the previous stop',
@@ -424,6 +428,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Decide on obstacles',
'assistant.change.verb.restoreTrack': 'Back to recorded track',
'assistant.change.verb.importTour': 'Add tour from file',
'assistant.change.verb.addTourStop': 'Add stop to tour',
'assistant.change.verb.removeTourStop': 'Remove stop from tour',
'assistant.change.label.field': 'Value',
'assistant.change.label.difficulty': 'Maximum trail difficulty',
'assistant.change.label.track': 'Recorded track',
+6
View File
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Revisando los gastos',
'assistant.step.help': 'Buscando en la ayuda',
'assistant.step.readingBooking': 'Leyendo la reserva',
'assistant.step.readingTourFile': 'Leyendo el archivo de ruta',
'assistant.step.planningHike': 'Planificando la excursión',
'assistant.step.planningHikeTo': 'Planificando la excursión hasta {place}',
'assistant.step.expenses': 'Leyendo los gastos',
@@ -216,6 +217,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Ha sido mucho de golpe. ¿Sigo?',
'assistant.error.offline': 'Sin conexión. El asistente necesita internet.',
'assistant.error.noBookingsFound': 'No se encontró ninguna reserva en este archivo.',
'assistant.error.noTracksFound': 'No se encontró ningún track ni ruta en este archivo.',
'assistant.error.importUnavailable': 'La lectura de archivos de reserva no está disponible aquí.',
'assistant.error.generic': 'Algo ha salido mal.',
'assistant.errorAction.retry': 'Reintentar',
@@ -282,6 +284,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Propuesto',
'assistant.place.inTrip': 'Ya está en tu viaje',
'assistant.place.showOnMap': 'En el mapa',
'assistant.place.addToTour': 'A la ruta',
'assistant.place.addToTourHint': 'Añadir como parada a {name}',
'assistant.place.openNow': 'Abierto ahora',
'assistant.place.closed': 'Cerrado',
'assistant.place.fromPrev': 'A {distance} de la parada anterior',
@@ -428,6 +432,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Decidir sobre obstáculos',
'assistant.change.verb.restoreTrack': 'Volver al track grabado',
'assistant.change.verb.importTour': 'Añadir excursión desde archivo',
'assistant.change.verb.addTourStop': 'Añadir parada a la ruta',
'assistant.change.verb.removeTourStop': 'Quitar parada de la ruta',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultad máxima del sendero',
'assistant.change.label.track': 'Track grabado',
+6
View File
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollin kulusid',
'assistant.step.help': 'Otsin abist',
'assistant.step.readingBooking': 'Loen broneeringut',
'assistant.step.readingTourFile': 'Loen marsruudifaili',
'assistant.step.planningHike': 'Planeerin matka',
'assistant.step.planningHikeTo': 'Planeerin matka sihtkohta {place}',
'assistant.step.expenses': 'Loen kulusid',
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Seda oli korraga palju. Kas jätkan?',
'assistant.error.offline': 'Võrguühendus puudub. Assistent vajab internetti.',
'assistant.error.noBookingsFound': 'Sellest failist ei leitud ühtegi broneeringut.',
'assistant.error.noTracksFound': 'Sellest failist ei leitud ühtegi rada ega marsruuti.',
'assistant.error.importUnavailable': 'Broneeringufailide lugemine pole siin saadaval.',
'assistant.error.generic': 'Midagi läks valesti.',
'assistant.errorAction.retry': 'Proovi uuesti',
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Soovitatud',
'assistant.place.inTrip': 'Juba sinu reisil',
'assistant.place.showOnMap': 'Kaardil',
'assistant.place.addToTour': 'Matkale',
'assistant.place.addToTourHint': 'Lisa peatusena matkale {name}',
'assistant.place.openNow': 'Praegu avatud',
'assistant.place.closed': 'Suletud',
'assistant.place.fromPrev': '{distance} eelmisest peatusest',
@@ -429,6 +433,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Otsusta takistuste üle',
'assistant.change.verb.restoreTrack': 'Tagasi salvestatud raja juurde',
'assistant.change.verb.importTour': 'Lisa retk failist',
'assistant.change.verb.addTourStop': 'Lisa matkale peatus',
'assistant.change.verb.removeTourStop': 'Eemalda matkalt peatus',
'assistant.change.label.field': 'Väärtus',
'assistant.change.label.difficulty': 'Raja suurim raskusaste',
'assistant.change.label.track': 'Salvestatud rada',
+6
View File
@@ -120,6 +120,7 @@ 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.readingTourFile': 'Lecture du fichier d’itinéraire',
'assistant.step.planningHike': 'Planification de la randonnée',
'assistant.step.planningHikeTo': 'Planification de la randonnée jusqu’à {place}',
'assistant.step.expenses': 'Lecture des dépenses',
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Ça faisait beaucoup d’un coup. Je continue ?',
'assistant.error.offline': 'Hors ligne. L’assistant a besoin d’Internet.',
'assistant.error.noBookingsFound': 'Aucune réservation trouvée dans ce fichier.',
'assistant.error.noTracksFound': 'Aucune trace ni aucun itinéraire trouvé dans ce fichier.',
'assistant.error.importUnavailable': 'La lecture des fichiers de réservation n’est pas disponible ici.',
'assistant.error.generic': 'Une erreur s’est produite.',
'assistant.errorAction.retry': 'Réessayer',
@@ -287,6 +289,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Proposé',
'assistant.place.inTrip': 'Déjà dans votre voyage',
'assistant.place.showOnMap': 'Sur la carte',
'assistant.place.addToTour': 'À la sortie',
'assistant.place.addToTourHint': 'Ajouter comme étape à {name}',
'assistant.place.openNow': 'Ouvert',
'assistant.place.closed': 'Fermé',
'assistant.place.fromPrev': '{distance} de l’étape précédente',
@@ -435,6 +439,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Décider des obstacles',
'assistant.change.verb.restoreTrack': 'Revenir à la trace enregistrée',
'assistant.change.verb.importTour': 'Ajouter une sortie depuis un fichier',
'assistant.change.verb.addTourStop': 'Ajouter une étape à la sortie',
'assistant.change.verb.removeTourStop': 'Retirer une étape de la sortie',
'assistant.change.label.field': 'Valeur',
'assistant.change.label.difficulty': 'Difficulté maximale du sentier',
'assistant.change.label.track': 'Trace enregistrée',
+6
View File
@@ -122,6 +122,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Έλεγχος εξόδων',
'assistant.step.help': 'Αναζήτηση στη βοήθεια',
'assistant.step.readingBooking': 'Διαβάζω την κράτηση',
'assistant.step.readingTourFile': 'Διαβάζω το αρχείο διαδρομής',
'assistant.step.planningHike': 'Σχεδιάζω την πεζοπορία',
'assistant.step.planningHikeTo': 'Σχεδιάζω την πεζοπορία προς {place}',
'assistant.step.expenses': 'Διαβάζω τα έξοδα',
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Ήταν πολλά μαζί. Να συνεχίσω;',
'assistant.error.offline': 'Εκτός σύνδεσης. Ο βοηθός χρειάζεται διαδίκτυο.',
'assistant.error.noBookingsFound': 'Δεν βρέθηκε κράτηση σε αυτό το αρχείο.',
'assistant.error.noTracksFound': 'Δεν βρέθηκε ίχνος ή διαδρομή σε αυτό το αρχείο.',
'assistant.error.importUnavailable': 'Η ανάγνωση αρχείων κράτησης δεν είναι διαθέσιμη εδώ.',
'assistant.error.generic': 'Κάτι πήγε στραβά.',
'assistant.errorAction.retry': 'Δοκιμή ξανά',
@@ -288,6 +290,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Προτάθηκε',
'assistant.place.inTrip': 'Ήδη στο ταξίδι σας',
'assistant.place.showOnMap': 'Στον χάρτη',
'assistant.place.addToTour': 'Στην εκδρομή',
'assistant.place.addToTourHint': 'Προσθήκη ως στάση στο {name}',
'assistant.place.openNow': 'Ανοιχτό τώρα',
'assistant.place.closed': 'Κλειστό',
'assistant.place.fromPrev': '{distance} από την προηγούμενη στάση',
@@ -434,6 +438,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Απόφαση για εμπόδια',
'assistant.change.verb.restoreTrack': 'Επιστροφή στην καταγεγραμμένη διαδρομή',
'assistant.change.verb.importTour': 'Προσθήκη εκδρομής από αρχείο',
'assistant.change.verb.addTourStop': 'Προσθήκη στάσης στην εκδρομή',
'assistant.change.verb.removeTourStop': 'Αφαίρεση στάσης από την εκδρομή',
'assistant.change.label.field': 'Τιμή',
'assistant.change.label.difficulty': 'Μέγιστη δυσκολία μονοπατιού',
'assistant.change.label.track': 'Καταγεγραμμένη διαδρομή',
+6
View File
@@ -121,6 +121,7 @@ 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.readingTourFile': 'Útvonalfájl beolvasása',
'assistant.step.planningHike': 'A túra tervezése',
'assistant.step.planningHikeTo': 'Túra tervezése ide: {place}',
'assistant.step.expenses': 'A kiadások olvasása',
@@ -219,6 +220,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Ez egyszerre sok volt. Folytassam?',
'assistant.error.offline': 'Offline vagy. Az asszisztensnek internet kell.',
'assistant.error.noBookingsFound': 'Ebben a fájlban nem található foglalás.',
'assistant.error.noTracksFound': 'Ebben a fájlban nem található nyomvonal vagy útvonal.',
'assistant.error.importUnavailable': 'A foglalási fájlok olvasása itt nem érhető el.',
'assistant.error.generic': 'Valami hiba történt.',
'assistant.errorAction.retry': 'Újra',
@@ -288,6 +290,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Javasolva',
'assistant.place.inTrip': 'Már az utadban van',
'assistant.place.showOnMap': 'Térképen',
'assistant.place.addToTour': 'A túrához',
'assistant.place.addToTourHint': 'Hozzáadás megállóként ehhez: {name}',
'assistant.place.openNow': 'Most nyitva',
'assistant.place.closed': 'Zárva',
'assistant.place.fromPrev': '{distance} az előző megállótól',
@@ -435,6 +439,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Döntés az akadályokról',
'assistant.change.verb.restoreTrack': 'Vissza a rögzített nyomvonalhoz',
'assistant.change.verb.importTour': 'Túra hozzáadása fájlból',
'assistant.change.verb.addTourStop': 'Megálló hozzáadása a túrához',
'assistant.change.verb.removeTourStop': 'Megálló eltávolítása a túrából',
'assistant.change.label.field': 'Érték',
'assistant.change.label.difficulty': 'Ösvény legnagyobb nehézsége',
'assistant.change.label.track': 'Rögzített nyomvonal',
+6
View File
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Memeriksa biaya',
'assistant.step.help': 'Mencari di bantuan',
'assistant.step.readingBooking': 'Membaca pemesanan',
'assistant.step.readingTourFile': 'Membaca berkas rute',
'assistant.step.planningHike': 'Merencanakan pendakian',
'assistant.step.planningHikeTo': 'Merencanakan pendakian ke {place}',
'assistant.step.expenses': 'Membaca pengeluaran',
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Itu banyak sekaligus. Mau aku lanjutkan?',
'assistant.error.offline': 'Sedang offline. Asisten butuh internet.',
'assistant.error.noBookingsFound': 'Tidak ada pemesanan yang ditemukan di file ini.',
'assistant.error.noTracksFound': 'Tidak ada jalur atau rute di berkas ini.',
'assistant.error.importUnavailable': 'Membaca file pemesanan tidak tersedia di sini.',
'assistant.error.generic': 'Terjadi kesalahan.',
'assistant.errorAction.retry': 'Coba lagi',
@@ -275,6 +277,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Diusulkan',
'assistant.place.inTrip': 'Sudah ada di perjalananmu',
'assistant.place.showOnMap': 'Di peta',
'assistant.place.addToTour': 'Ke tur',
'assistant.place.addToTourHint': 'Tambahkan sebagai perhentian ke {name}',
'assistant.place.openNow': 'Buka sekarang',
'assistant.place.closed': 'Tutup',
'assistant.place.fromPrev': '{distance} dari pemberhentian sebelumnya',
@@ -409,6 +413,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Putuskan soal rintangan',
'assistant.change.verb.restoreTrack': 'Kembali ke jejak rekaman',
'assistant.change.verb.importTour': 'Tambah tur dari file',
'assistant.change.verb.addTourStop': 'Tambahkan perhentian ke tur',
'assistant.change.verb.removeTourStop': 'Hapus perhentian dari tur',
'assistant.change.label.field': 'Nilai',
'assistant.change.label.difficulty': 'Tingkat kesulitan jalur maksimum',
'assistant.change.label.track': 'Jejak terekam',
+6
View File
@@ -121,6 +121,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Controllo i costi',
'assistant.step.help': 'Cerco nella guida',
'assistant.step.readingBooking': 'Leggo la prenotazione',
'assistant.step.readingTourFile': 'Lettura del file del percorso',
'assistant.step.planningHike': 'Pianifico l’escursione',
'assistant.step.planningHikeTo': 'Pianifico l’escursione fino a {place}',
'assistant.step.expenses': 'Leggo le spese',
@@ -219,6 +220,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Era tanto tutto insieme. Continuo?',
'assistant.error.offline': 'Sei offline. L’assistente ha bisogno di internet.',
'assistant.error.noBookingsFound': 'Nessuna prenotazione trovata in questo file.',
'assistant.error.noTracksFound': 'Nessuna traccia o percorso trovato in questo file.',
'assistant.error.importUnavailable': 'La lettura dei file di prenotazione non è disponibile qui.',
'assistant.error.generic': 'Qualcosa è andato storto.',
'assistant.errorAction.retry': 'Riprova',
@@ -286,6 +288,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Proposto',
'assistant.place.inTrip': 'Già nel tuo viaggio',
'assistant.place.showOnMap': 'Sulla mappa',
'assistant.place.addToTour': 'Al tour',
'assistant.place.addToTourHint': 'Aggiungi come tappa a {name}',
'assistant.place.openNow': 'Aperto ora',
'assistant.place.closed': 'Chiuso',
'assistant.place.fromPrev': 'A {distance} dalla tappa precedente',
@@ -436,6 +440,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Decidi sugli ostacoli',
'assistant.change.verb.restoreTrack': 'Torna alla traccia registrata',
'assistant.change.verb.importTour': 'Aggiungi escursione da file',
'assistant.change.verb.addTourStop': 'Aggiungi tappa al tour',
'assistant.change.verb.removeTourStop': 'Rimuovi tappa dal tour',
'assistant.change.label.field': 'Valore',
'assistant.change.label.difficulty': 'Difficoltà massima del sentiero',
'assistant.change.label.track': 'Traccia registrata',
+6
View File
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '費用を確認中',
'assistant.step.help': 'ヘルプを検索中',
'assistant.step.readingBooking': '予約を読み込み中',
'assistant.step.readingTourFile': 'ルートファイルを読み込み中',
'assistant.step.planningHike': 'ハイキングを計画中',
'assistant.step.planningHikeTo': '{place}までのハイキングを計画中',
'assistant.step.expenses': '支出を読み込み中',
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': '一度にたくさん処理しました。続けますか?',
'assistant.error.offline': 'オフラインです。アシスタントにはインターネット接続が必要です。',
'assistant.error.noBookingsFound': 'このファイルに予約は見つかりませんでした。',
'assistant.error.noTracksFound': 'このファイルにトラックやルートが見つかりませんでした。',
'assistant.error.importUnavailable': 'ここでは予約ファイルを読み込めません。',
'assistant.error.generic': '問題が発生しました。',
'assistant.errorAction.retry': '再試行',
@@ -274,6 +276,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': '提案済み',
'assistant.place.inTrip': 'すでに旅行に含まれています',
'assistant.place.showOnMap': '地図で表示',
'assistant.place.addToTour': 'ツアーへ',
'assistant.place.addToTourHint': '{name} に立ち寄り先として追加',
'assistant.place.openNow': '営業中',
'assistant.place.closed': '休業',
'assistant.place.fromPrev': '前の立ち寄り先から{distance}',
@@ -404,6 +408,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': '障害物について決める',
'assistant.change.verb.restoreTrack': '記録したトラックに戻す',
'assistant.change.verb.importTour': 'ファイルからツアーを追加',
'assistant.change.verb.addTourStop': 'ツアーに立ち寄り先を追加',
'assistant.change.verb.removeTourStop': 'ツアーから立ち寄り先を削除',
'assistant.change.label.field': '値',
'assistant.change.label.difficulty': '登山道の難易度上限',
'assistant.change.label.track': '記録トラック',
+6
View File
@@ -114,6 +114,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '비용을 확인하는 중',
'assistant.step.help': '도움말을 찾는 중',
'assistant.step.readingBooking': '예약을 읽는 중',
'assistant.step.readingTourFile': '경로 파일 읽는 중',
'assistant.step.planningHike': '하이킹 계획 중',
'assistant.step.planningHikeTo': '{place}까지 하이킹 계획 중',
'assistant.step.expenses': '지출을 읽는 중',
@@ -208,6 +209,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': '한 번에 많은 작업을 했습니다. 계속할까요?',
'assistant.error.offline': '오프라인입니다. 어시스턴트는 인터넷이 필요합니다.',
'assistant.error.noBookingsFound': '이 파일에서 예약을 찾지 못했습니다.',
'assistant.error.noTracksFound': '이 파일에서 트랙이나 경로를 찾지 못했습니다.',
'assistant.error.importUnavailable': '여기서는 예약 파일을 읽을 수 없습니다.',
'assistant.error.generic': '문제가 발생했습니다.',
'assistant.errorAction.retry': '다시 시도',
@@ -271,6 +273,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': '제안됨',
'assistant.place.inTrip': '이미 여행에 있음',
'assistant.place.showOnMap': '지도에서',
'assistant.place.addToTour': '투어에 추가',
'assistant.place.addToTourHint': '{name}에 경유지로 추가',
'assistant.place.openNow': '영업 중',
'assistant.place.closed': '영업 종료',
'assistant.place.fromPrev': '이전 장소에서 {distance}',
@@ -400,6 +404,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': '장애물 결정',
'assistant.change.verb.restoreTrack': '기록된 트랙으로 되돌리기',
'assistant.change.verb.importTour': '파일에서 투어 추가',
'assistant.change.verb.addTourStop': '투어에 경유지 추가',
'assistant.change.verb.removeTourStop': '투어에서 경유지 삭제',
'assistant.change.label.field': '값',
'assistant.change.label.difficulty': '탐방로 최대 난이도',
'assistant.change.label.track': '기록된 트랙',
+6
View File
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'De kosten controleren',
'assistant.step.help': 'In de help opzoeken',
'assistant.step.readingBooking': 'Boeking lezen',
'assistant.step.readingTourFile': 'Routebestand lezen',
'assistant.step.planningHike': 'Wandeling plannen',
'assistant.step.planningHikeTo': 'Plant de wandeling naar {place}',
'assistant.step.expenses': 'De uitgaven lezen',
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Dat was veel tegelijk. Zal ik doorgaan?',
'assistant.error.offline': 'Offline. De assistent heeft internet nodig.',
'assistant.error.noBookingsFound': 'Geen boeking gevonden in dit bestand.',
'assistant.error.noTracksFound': 'Geen track of route gevonden in dit bestand.',
'assistant.error.importUnavailable': 'Boekingsbestanden lezen is hier niet beschikbaar.',
'assistant.error.generic': 'Er ging iets mis.',
'assistant.errorAction.retry': 'Opnieuw proberen',
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Voorgesteld',
'assistant.place.inTrip': 'Al in je reis',
'assistant.place.showOnMap': 'Op de kaart',
'assistant.place.addToTour': 'Naar tocht',
'assistant.place.addToTourHint': 'Als stop toevoegen aan {name}',
'assistant.place.openNow': 'Nu open',
'assistant.place.closed': 'Gesloten',
'assistant.place.fromPrev': '{distance} van de vorige stop',
@@ -431,6 +435,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Over obstakels beslissen',
'assistant.change.verb.restoreTrack': 'Terug naar opgenomen track',
'assistant.change.verb.importTour': 'Tocht uit bestand toevoegen',
'assistant.change.verb.addTourStop': 'Stop aan tocht toevoegen',
'assistant.change.verb.removeTourStop': 'Stop uit tocht verwijderen',
'assistant.change.label.field': 'Waarde',
'assistant.change.label.difficulty': 'Maximale moeilijkheid van het pad',
'assistant.change.label.track': 'Opgenomen track',
+6
View File
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Sprawdzam koszty',
'assistant.step.help': 'Szukam w pomocy',
'assistant.step.readingBooking': 'Czytam rezerwację',
'assistant.step.readingTourFile': 'Odczytuję plik trasy',
'assistant.step.planningHike': 'Planuję wędrówkę',
'assistant.step.planningHikeTo': 'Planuje wędrówkę do celu: {place}',
'assistant.step.expenses': 'Czytam wydatki',
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'To było dużo naraz. Kontynuować?',
'assistant.error.offline': 'Brak sieci. Asystent potrzebuje internetu.',
'assistant.error.noBookingsFound': 'W tym pliku nie znaleziono żadnej rezerwacji.',
'assistant.error.noTracksFound': 'W tym pliku nie znaleziono żadnego śladu ani trasy.',
'assistant.error.importUnavailable': 'Odczyt plików z rezerwacjami nie jest tu dostępny.',
'assistant.error.generic': 'Coś poszło nie tak.',
'assistant.errorAction.retry': 'Spróbuj ponownie',
@@ -299,6 +301,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Zaproponowano',
'assistant.place.inTrip': 'Już w twojej podróży',
'assistant.place.showOnMap': 'Na mapie',
'assistant.place.addToTour': 'Do wycieczki',
'assistant.place.addToTourHint': 'Dodaj jako przystanek do {name}',
'assistant.place.openNow': 'Teraz otwarte',
'assistant.place.closed': 'Zamknięte',
'assistant.place.fromPrev': '{distance} od poprzedniego przystanku',
@@ -473,6 +477,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Zdecyduj o przeszkodach',
'assistant.change.verb.restoreTrack': 'Wróć do nagranego śladu',
'assistant.change.verb.importTour': 'Dodaj wycieczkę z pliku',
'assistant.change.verb.addTourStop': 'Dodaj przystanek do wycieczki',
'assistant.change.verb.removeTourStop': 'Usuń przystanek z wycieczki',
'assistant.change.label.field': 'Wartość',
'assistant.change.label.difficulty': 'Maksymalna trudność szlaku',
'assistant.change.label.track': 'Nagrany ślad',
+6
View File
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Проверяю расходы',
'assistant.step.help': 'Ищу в справке',
'assistant.step.readingBooking': 'Читаю бронирование',
'assistant.step.readingTourFile': 'Читаю файл маршрута',
'assistant.step.planningHike': 'Планирую поход',
'assistant.step.planningHikeTo': 'Планирую поход, цель: {place}',
'assistant.step.expenses': 'Читаю расходы',
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Это было много за раз. Продолжить?',
'assistant.error.offline': 'Нет сети. Ассистенту нужен интернет.',
'assistant.error.noBookingsFound': 'В этом файле не найдено ни одного бронирования.',
'assistant.error.noTracksFound': 'В этом файле не найдено ни трека, ни маршрута.',
'assistant.error.importUnavailable': 'Чтение файлов бронирований здесь недоступно.',
'assistant.error.generic': 'Что-то пошло не так.',
'assistant.errorAction.retry': 'Повторить',
@@ -298,6 +300,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Предложено',
'assistant.place.inTrip': 'Уже в вашей поездке',
'assistant.place.showOnMap': 'На карте',
'assistant.place.addToTour': 'В тур',
'assistant.place.addToTourHint': 'Добавить как остановку в {name}',
'assistant.place.openNow': 'Сейчас открыто',
'assistant.place.closed': 'Закрыто',
'assistant.place.fromPrev': '{distance} от предыдущей остановки',
@@ -473,6 +477,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Решить по препятствиям',
'assistant.change.verb.restoreTrack': 'Вернуть записанный трек',
'assistant.change.verb.importTour': 'Добавить тур из файла',
'assistant.change.verb.addTourStop': 'Добавить остановку в тур',
'assistant.change.verb.removeTourStop': 'Убрать остановку из тура',
'assistant.change.label.field': 'Значение',
'assistant.change.label.difficulty': 'Максимальная сложность тропы',
'assistant.change.label.track': 'Записанный трек',
+6
View File
@@ -118,6 +118,7 @@ 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.readingTourFile': 'Čítam súbor s trasou',
'assistant.step.planningHike': 'Plánujem túru',
'assistant.step.planningHikeTo': 'Plánujem túru do cieľa {place}',
'assistant.step.expenses': 'Čítam výdavky',
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Bolo toho naraz veľa. Mám pokračovať?',
'assistant.error.offline': 'Offline. Asistent potrebuje internet.',
'assistant.error.noBookingsFound': 'V tomto súbore sa nenašla žiadna rezervácia.',
'assistant.error.noTracksFound': 'V tomto súbore sa nenašla žiadna stopa ani trasa.',
'assistant.error.importUnavailable': 'Čítanie súborov s rezerváciami tu nie je dostupné.',
'assistant.error.generic': 'Niečo sa pokazilo.',
'assistant.errorAction.retry': 'Skúsiť znova',
@@ -289,6 +291,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Navrhnuté',
'assistant.place.inTrip': 'Už je v tvojom výlete',
'assistant.place.showOnMap': 'Na mape',
'assistant.place.addToTour': 'Do túry',
'assistant.place.addToTourHint': 'Pridať ako zastávku do {name}',
'assistant.place.openNow': 'Teraz otvorené',
'assistant.place.closed': 'Zatvorené',
'assistant.place.fromPrev': '{distance} od predošlej zastávky',
@@ -448,6 +452,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Rozhodnúť o prekážkach',
'assistant.change.verb.restoreTrack': 'Späť na zaznamenanú stopu',
'assistant.change.verb.importTour': 'Pridať túru zo súboru',
'assistant.change.verb.addTourStop': 'Pridať zastávku do túry',
'assistant.change.verb.removeTourStop': 'Odobrať zastávku z túry',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximálna náročnosť chodníka',
'assistant.change.label.track': 'Zaznamenaná trasa',
+6
View File
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Kontrollerar kostnaderna',
'assistant.step.help': 'Letar i hjälpen',
'assistant.step.readingBooking': 'Läser bokningen',
'assistant.step.readingTourFile': 'Läser ruttfilen',
'assistant.step.planningHike': 'Planerar vandringen',
'assistant.step.planningHikeTo': 'Planerar vandringen till {place}',
'assistant.step.expenses': 'Läser utgifterna',
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Det var mycket på en gång. Ska jag fortsätta?',
'assistant.error.offline': 'Offline. Assistenten behöver internet.',
'assistant.error.noBookingsFound': 'Ingen bokning hittades i den här filen.',
'assistant.error.noTracksFound': 'Inget spår eller ingen rutt hittades i den här filen.',
'assistant.error.importUnavailable': 'Att läsa bokningsfiler är inte tillgängligt här.',
'assistant.error.generic': 'Något gick fel.',
'assistant.errorAction.retry': 'Försök igen',
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Föreslagen',
'assistant.place.inTrip': 'Redan i din resa',
'assistant.place.showOnMap': 'På kartan',
'assistant.place.addToTour': 'Till turen',
'assistant.place.addToTourHint': 'Lägg till som stopp i {name}',
'assistant.place.openNow': 'Öppet nu',
'assistant.place.closed': 'Stängt',
'assistant.place.fromPrev': '{distance} från förra stoppet',
@@ -429,6 +433,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Besluta om hinder',
'assistant.change.verb.restoreTrack': 'Tillbaka till inspelat spår',
'assistant.change.verb.importTour': 'Lägg till tur från fil',
'assistant.change.verb.addTourStop': 'Lägg till stopp i turen',
'assistant.change.verb.removeTourStop': 'Ta bort stopp från turen',
'assistant.change.label.field': 'Värde',
'assistant.change.label.difficulty': 'Högsta svårighetsgrad för leden',
'assistant.change.label.track': 'Inspelat spår',
+6
View File
@@ -114,6 +114,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'กำลังตรวจสอบค่าใช้จ่าย',
'assistant.step.help': 'กำลังค้นในความช่วยเหลือ',
'assistant.step.readingBooking': 'กำลังอ่านการจอง',
'assistant.step.readingTourFile': 'กำลังอ่านไฟล์เส้นทาง',
'assistant.step.planningHike': 'กำลังวางแผนเดินป่า',
'assistant.step.planningHikeTo': 'กำลังวางแผนเดินป่าไปยัง {place}',
'assistant.step.expenses': 'กำลังอ่านรายจ่าย',
@@ -208,6 +209,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'ครั้งนี้เยอะไปหน่อย ให้ฉันทำต่อไหม',
'assistant.error.offline': 'ออฟไลน์อยู่ ผู้ช่วยต้องใช้อินเทอร์เน็ต',
'assistant.error.noBookingsFound': 'ไม่พบการจองในไฟล์นี้',
'assistant.error.noTracksFound': 'ไม่พบแทร็กหรือเส้นทางในไฟล์นี้',
'assistant.error.importUnavailable': 'การอ่านไฟล์การจองใช้ไม่ได้ที่นี่',
'assistant.error.generic': 'เกิดข้อผิดพลาดบางอย่าง',
'assistant.errorAction.retry': 'ลองอีกครั้ง',
@@ -271,6 +273,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'เสนอแล้ว',
'assistant.place.inTrip': 'อยู่ในทริปของคุณแล้ว',
'assistant.place.showOnMap': 'บนแผนที่',
'assistant.place.addToTour': 'เพิ่มในทัวร์',
'assistant.place.addToTourHint': 'เพิ่มเป็นจุดแวะใน {name}',
'assistant.place.openNow': 'เปิดอยู่',
'assistant.place.closed': 'ปิด',
'assistant.place.fromPrev': '{distance} จากจุดแวะก่อนหน้า',
@@ -401,6 +405,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'ตัดสินใจเรื่องสิ่งกีดขวาง',
'assistant.change.verb.restoreTrack': 'กลับไปใช้เส้นทางที่บันทึกไว้',
'assistant.change.verb.importTour': 'เพิ่มทัวร์จากไฟล์',
'assistant.change.verb.addTourStop': 'เพิ่มจุดแวะในทัวร์',
'assistant.change.verb.removeTourStop': 'ลบจุดแวะออกจากทัวร์',
'assistant.change.label.field': 'ค่า',
'assistant.change.label.difficulty': 'ระดับความยากสูงสุดของทางเดิน',
'assistant.change.label.track': 'เส้นทางที่บันทึก',
+6
View File
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Maliyetler kontrol ediliyor',
'assistant.step.help': 'Yardımda aranıyor',
'assistant.step.readingBooking': 'Rezervasyon okunuyor',
'assistant.step.readingTourFile': 'Rota dosyası okunuyor',
'assistant.step.planningHike': 'Yürüyüş planlanıyor',
'assistant.step.planningHikeTo': '{place} için yürüyüş planlanıyor',
'assistant.step.expenses': 'Harcamalar okunuyor',
@@ -216,6 +217,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Bir seferde epey fazlaydı. Devam edeyim mi?',
'assistant.error.offline': 'Çevrimdışı. Asistanın internete ihtiyacı var.',
'assistant.error.noBookingsFound': 'Bu dosyada rezervasyon bulunamadı.',
'assistant.error.noTracksFound': 'Bu dosyada iz veya rota bulunamadı.',
'assistant.error.importUnavailable': 'Rezervasyon dosyalarını okuma burada kullanılamıyor.',
'assistant.error.generic': 'Bir şeyler ters gitti.',
'assistant.errorAction.retry': 'Yeniden dene',
@@ -282,6 +284,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Önerildi',
'assistant.place.inTrip': 'Zaten seyahatinde',
'assistant.place.showOnMap': 'Haritada',
'assistant.place.addToTour': 'Tura ekle',
'assistant.place.addToTourHint': '{name} turuna durak olarak ekle',
'assistant.place.openNow': 'Şu an açık',
'assistant.place.closed': 'Kapalı',
'assistant.place.fromPrev': 'Önceki duraktan {distance}',
@@ -429,6 +433,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Engeller hakkında karar ver',
'assistant.change.verb.restoreTrack': 'Kayıtlı izlemeye dön',
'assistant.change.verb.importTour': 'Dosyadan tur ekle',
'assistant.change.verb.addTourStop': 'Tura durak ekle',
'assistant.change.verb.removeTourStop': 'Turdan durağı kaldır',
'assistant.change.label.field': 'Değer',
'assistant.change.label.difficulty': 'En yüksek patika zorluğu',
'assistant.change.label.track': 'Kayıtlı iz',
+6
View File
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': 'Перевіряю витрати',
'assistant.step.help': 'Шукаю в довідці',
'assistant.step.readingBooking': 'Читаю бронювання',
'assistant.step.readingTourFile': 'Читаю файл маршруту',
'assistant.step.planningHike': 'Планую похід',
'assistant.step.planningHikeTo': 'Планую похід, ціль: {place}',
'assistant.step.expenses': 'Читаю витрати',
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Це було забагато за раз. Продовжити?',
'assistant.error.offline': 'Немає мережі. Асистентові потрібен інтернет.',
'assistant.error.noBookingsFound': 'У цьому файлі не знайдено жодного бронювання.',
'assistant.error.noTracksFound': 'У цьому файлі не знайдено ні треку, ні маршруту.',
'assistant.error.importUnavailable': 'Читання файлів бронювань тут недоступне.',
'assistant.error.generic': 'Щось пішло не так.',
'assistant.errorAction.retry': 'Спробувати ще раз',
@@ -298,6 +300,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Запропоновано',
'assistant.place.inTrip': 'Уже у вашій подорожі',
'assistant.place.showOnMap': 'На мапі',
'assistant.place.addToTour': 'До туру',
'assistant.place.addToTourHint': 'Додати як зупинку до {name}',
'assistant.place.openNow': 'Зараз відчинено',
'assistant.place.closed': 'Зачинено',
'assistant.place.fromPrev': '{distance} від попередньої зупинки',
@@ -471,6 +475,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Вирішити щодо перешкод',
'assistant.change.verb.restoreTrack': 'Повернути записаний трек',
'assistant.change.verb.importTour': 'Додати тур із файлу',
'assistant.change.verb.addTourStop': 'Додати зупинку до туру',
'assistant.change.verb.removeTourStop': 'Прибрати зупинку з туру',
'assistant.change.label.field': 'Значення',
'assistant.change.label.difficulty': 'Максимальна складність стежки',
'assistant.change.label.track': 'Записаний трек',
+6
View File
@@ -116,6 +116,7 @@ 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.readingTourFile': 'Đang đọc tệp lộ trình',
'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.expenses': 'Đang đọc các khoản chi',
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': 'Một lần hơi nhiều việc. Tôi tiếp tục nhé?',
'assistant.error.offline': 'Ngoại tuyến. Trợ lý cần có internet.',
'assistant.error.noBookingsFound': 'Không tìm thấy đặt chỗ nào trong tệp này.',
'assistant.error.noTracksFound': 'Không tìm thấy đường đi hay lộ trình nào trong tệp này.',
'assistant.error.importUnavailable': 'Không thể đọc tệp đặt chỗ ở đây.',
'assistant.error.generic': 'Đã xảy ra lỗi.',
'assistant.errorAction.retry': 'Thử lại',
@@ -273,6 +275,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': 'Đã đề xuất',
'assistant.place.inTrip': 'Đã có trong chuyến đi',
'assistant.place.showOnMap': 'Trên bản đồ',
'assistant.place.addToTour': 'Vào chuyến đi',
'assistant.place.addToTourHint': 'Thêm làm điểm dừng vào {name}',
'assistant.place.openNow': 'Đang mở cửa',
'assistant.place.closed': 'Đã đóng cửa',
'assistant.place.fromPrev': 'Cách điểm dừng trước {distance}',
@@ -404,6 +408,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': 'Quyết định về chướng ngại vật',
'assistant.change.verb.restoreTrack': 'Quay lại đường đã ghi',
'assistant.change.verb.importTour': 'Thêm chuyến đi từ tệp',
'assistant.change.verb.addTourStop': 'Thêm điểm dừng vào chuyến đi',
'assistant.change.verb.removeTourStop': 'Xóa điểm dừng khỏi chuyến đi',
'assistant.change.label.field': 'Giá trị',
'assistant.change.label.difficulty': 'Độ khó tối đa của đường mòn',
'assistant.change.label.track': 'Hành trình đã ghi',
+6
View File
@@ -111,6 +111,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在檢查費用',
'assistant.step.help': '正在說明中查找',
'assistant.step.readingBooking': '正在讀取預訂',
'assistant.step.readingTourFile': '正在讀取路線檔案',
'assistant.step.planningHike': '正在規劃健行路線',
'assistant.step.planningHikeTo': '正在規劃前往{place}的健行路線',
'assistant.step.expenses': '正在讀取支出',
@@ -205,6 +206,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': '一次處理的內容太多了。要我繼續嗎?',
'assistant.error.offline': '已離線。助理需要連上網路。',
'assistant.error.noBookingsFound': '此檔案中找不到預訂。',
'assistant.error.noTracksFound': '此檔案中找不到軌跡或路線。',
'assistant.error.importUnavailable': '此處無法讀取預訂檔案。',
'assistant.error.generic': '發生錯誤。',
'assistant.errorAction.retry': '再試一次',
@@ -268,6 +270,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': '已建議',
'assistant.place.inTrip': '已在行程中',
'assistant.place.showOnMap': '在地圖上',
'assistant.place.addToTour': '加入路線',
'assistant.place.addToTourHint': '作為停靠點加入 {name}',
'assistant.place.openNow': '營業中',
'assistant.place.closed': '已打烊',
'assistant.place.fromPrev': '距上一站 {distance}',
@@ -396,6 +400,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': '決定障礙物處理',
'assistant.change.verb.restoreTrack': '恢復為記錄的軌跡',
'assistant.change.verb.importTour': '從檔案新增路線',
'assistant.change.verb.addTourStop': '為路線新增停靠點',
'assistant.change.verb.removeTourStop': '從路線中移除停靠點',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高難度',
'assistant.change.label.track': '記錄軌跡',
+6
View File
@@ -111,6 +111,7 @@ const assistant: TranslationStrings = {
'assistant.step.costs': '正在检查费用',
'assistant.step.help': '正在帮助中查找',
'assistant.step.readingBooking': '正在读取预订',
'assistant.step.readingTourFile': '正在读取路线文件',
'assistant.step.planningHike': '正在规划徒步路线',
'assistant.step.planningHikeTo': '正在规划前往{place}的徒步路线',
'assistant.step.expenses': '正在读取支出',
@@ -205,6 +206,7 @@ const assistant: TranslationStrings = {
'assistant.error.maxSteps': '一次处理的内容太多了。要我继续吗?',
'assistant.error.offline': '已离线。助手需要联网。',
'assistant.error.noBookingsFound': '此文件中未找到预订。',
'assistant.error.noTracksFound': '此文件中没有找到轨迹或路线。',
'assistant.error.importUnavailable': '此处无法读取预订文件。',
'assistant.error.generic': '出了点问题。',
'assistant.errorAction.retry': '重试',
@@ -268,6 +270,8 @@ const assistant: TranslationStrings = {
'assistant.place.suggested': '已建议',
'assistant.place.inTrip': '已在行程中',
'assistant.place.showOnMap': '在地图上',
'assistant.place.addToTour': '加入路线',
'assistant.place.addToTourHint': '作为停靠点加入 {name}',
'assistant.place.openNow': '营业中',
'assistant.place.closed': '已打烊',
'assistant.place.fromPrev': '距上一站 {distance}',
@@ -396,6 +400,8 @@ const assistant: TranslationStrings = {
'assistant.change.verb.decideObstacles': '决定障碍物处理',
'assistant.change.verb.restoreTrack': '恢复为记录的轨迹',
'assistant.change.verb.importTour': '从文件添加路线',
'assistant.change.verb.addTourStop': '为路线添加停靠点',
'assistant.change.verb.removeTourStop': '从路线中移除停靠点',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高难度',
'assistant.change.label.track': '记录轨迹',
+2
View File
@@ -104,6 +104,8 @@ export * from './tours/paddle-check.schema';
export * from './tours/paddle-check-basis';
export * from './tours/paddle-check';
export * from './tours/route-corridor';
export * from './tours/poi-insertion';
export * from './tours/tour-stop.schema';
export * from './tours/horse-route.schema';
export * from './wanderer/wanderer.schema';
export * from './wanderer/wanderer-watch.schema';
+190
View File
@@ -0,0 +1,190 @@
import {
alongRouteM,
chosenAfterId,
defaultAfterId,
insertionBlock,
insertionPreview,
insertionSlots,
placesInTour,
straightDetourM,
withPointAfter,
withPointAppended,
type InsertionPoint,
} from './poi-insertion';
import { describe, expect, it } from 'vitest';
// TOUR-POI-INSERT-001 to TOUR-POI-INSERT-012, moved here from the planner with the module.
const point = (id: string, lat: number, lng: number, over: Partial<InsertionPoint> = {}): InsertionPoint => ({
id,
lat,
lng,
role: 'via',
...over,
});
const A = point('a', 47.0, 11.0, { role: 'start' });
const B = point('b', 47.01, 11.0);
const C = point('c', 47.02, 11.0, { role: 'end' });
const NEW = point('new', 47.005, 11.001);
describe('placing a found place in the tour (PLN-03)', () => {
it('TOUR-POI-INSERT-001: goes in right after the point chosen, as a stop the router plans to', () => {
expect(withPointAfter([A, B, C], 'a', NEW)?.map((p) => p.id)).toEqual(['a', 'new', 'b', 'c']);
expect(withPointAfter([A, B, C], 'a', NEW)?.[1]).toMatchObject({ role: 'via' });
// After the last point the tour goes on to it.
expect(withPointAfter([A, B, C], 'c', NEW)?.map((p) => p.id)).toEqual(['a', 'b', 'c', 'new']);
});
it('TOUR-POI-INSERT-002: never cuts a fixed line or a stretch drawn by hand', () => {
const ferrata = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'ferrata',
ref: 'way/1',
name: null,
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
const free = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'free',
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
expect(insertionBlock([A, ferrata, C], 'b')).toBe('fixed');
expect(insertionBlock([A, free, C], 'b')).toBe('free');
expect(withPointAfter([A, free, C], 'b', NEW)).toBeNull();
expect(insertionBlock([A, free, C], 'a')).toBeNull();
});
it('TOUR-POI-INSERT-003: nothing goes after the way back of a loop, or after a point that is gone', () => {
const back = point('back', 47.0, 11.0, { role: 'end' });
expect(insertionBlock([A, B, back], 'back')).toBe('none');
expect(insertionBlock([A, B, C], 'gone')).toBe('none');
expect(withPointAfter([A, B, back], 'back', NEW)).toBeNull();
});
it('TOUR-POI-INSERT-004: suggests the last point before the place along the route', () => {
const route: Array<[number, number]> = [
[47.0, 11.0],
[47.01, 11.0],
[47.02, 11.0],
];
// About 1.6 km along: after B (1.1 km), before C (2.2 km).
expect(defaultAfterId([A, B, C], route, 1600)).toBe('b');
expect(defaultAfterId([A, B, C], route, 100)).toBe('a');
// Without a route the points themselves stand in for the line.
expect(defaultAfterId([A, B, C], null, 1600)).toBe('b');
});
it('TOUR-POI-INSERT-005: skips a point that cannot take a stop, and has nothing to suggest without one', () => {
const free = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'free',
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
expect(defaultAfterId([A, free, C], null, 1600)).toBe('a');
expect(defaultAfterId([], null, 0)).toBeNull();
});
});
describe('the slots a found place can go into (PLN-04)', () => {
const back = point('back', 47.0, 11.0, { role: 'end' });
it('TOUR-POI-INSERT-006: one slot per point, between it and the next, the last one as the new end', () => {
expect(insertionSlots([A, B, C])).toEqual([
{ afterId: 'a', beforeId: 'b', block: null },
{ afterId: 'b', beforeId: 'c', block: null },
{ afterId: 'c', beforeId: null, block: null },
]);
// A loop has no new end: the slot after its way back is listed as one that cannot take the place.
expect(insertionSlots([A, B, back])[2]).toEqual({ afterId: 'back', beforeId: null, block: 'none' });
});
it('TOUR-POI-INSERT-007: the preview runs through the place to the next point, or ends on it as the new end', () => {
const place = { lat: 47.03, lng: 11.0 };
expect(insertionPreview([A, B, C], 'b', place)).toEqual([
[47.01, 11.0],
[47.03, 11.0],
[47.02, 11.0],
]);
expect(insertionPreview([A, B, C], 'c', place)).toEqual([
[47.02, 11.0],
[47.03, 11.0],
]);
expect(insertionPreview([A, B, back], 'back', place)).toBeNull();
});
it('TOUR-POI-INSERT-008: the detour as the crow flies: the way round less the stretch it replaces', () => {
// 1.11 km on to the place and 1.11 km back, instead of the 1.11 km between the points.
const between = straightDetourM([
[47.01, 11.0],
[47.02, 11.0],
[47.01, 11.0],
]);
expect(between).toBeGreaterThan(2200);
expect(between).toBeLessThan(2250);
// A place on the way adds nothing.
expect(
straightDetourM([
[47.0, 11.0],
[47.01, 11.0],
[47.02, 11.0],
]),
).toBeLessThan(1);
// As the new end, the whole way to it.
expect(
straightDetourM([
[47.02, 11.0],
[47.03, 11.0],
]),
).toBeGreaterThan(1100);
});
it('TOUR-POI-INSERT-009: keeps the point picked for the place while it is there, the suggestion otherwise', () => {
const place = { id: 'node:1', along_m: 1600 };
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:1', afterId: 'c' })).toBe('c');
// Picked for another place, or a point undone since.
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:2', afterId: 'c' })).toBe('b');
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:1', afterId: 'gone' })).toBe('b');
expect(chosenAfterId([A, B, C], null, place, null)).toBe('b');
});
it('TOUR-POI-INSERT-010: tells the places that are a point of the tour already by their spot', () => {
const places = [
{ id: 'node:1', lat: B.lat, lng: B.lng },
{ id: 'node:2', lat: B.lat + 0.001, lng: B.lng },
];
expect([...placesInTour(places, [A, B, C])]).toEqual(['node:1']);
expect(placesInTour(places, [A, C]).size).toBe(0);
});
});
describe('a stop the assistant takes in (P6)', () => {
it('TOUR-POI-INSERT-011: a new end goes after the last point, or before the way back of a loop', () => {
const back = point('back', 47.0, 11.0, { role: 'end' });
expect(withPointAppended([A, B, C], NEW).map((p) => p.id)).toEqual(['a', 'b', 'c', 'new']);
expect(withPointAppended([A, B, C], NEW)[3]).toMatchObject({ role: 'end' });
expect(withPointAppended([A, B, back], NEW).map((p) => p.id)).toEqual(['a', 'b', 'new', 'back']);
expect(withPointAppended([A, B, back], NEW)[2]).toMatchObject({ role: 'via' });
});
it('TOUR-POI-INSERT-012: never suggests a shaping point, and reads how far along the route a place lies', () => {
const shaping = point('s', 47.01, 11.0, { shaping: true });
expect(defaultAfterId([A, shaping, C], null, 1600)).toBe('a');
const along = alongRouteM([A, B, C], null, { lat: 47.015, lng: 11.0 });
expect(along).toBeGreaterThan(1600);
expect(along).toBeLessThan(1700);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { haversineMetres } from '../geo/haversine';
import { nearnessToLine } from './route-corridor';
import { isClosedLoop } from './tour-walk';
import type { TourFixedLeg } from './tours.schema';
/**
* PLN-03 and PLN-04: where a place found along a tour can go in as a stop,
* the same answer for the planner in the browser and for the assistant's
* insert_tour_stop on the server. Plain list operations on the ordered points
* of a tour; neither side's own point type is needed, only its id, spot,
* role and the fixed line its leg to the next point keeps.
*/
export interface InsertionPoint {
id: string;
lat: number;
lng: number;
role: string;
fixedLeg?: TourFixedLeg | null;
shaping?: boolean;
}
/**
* Why a place cannot go in after a point (PLN-03): the leg after it is a
* fixed line (a via ferrata, a portage) or drawn by hand, which a new point in
* between would cut or redraw without being asked; or there is no leg after it
* to go into (the way back of a loop, a point that is gone).
*/
export type InsertionBlock = 'fixed' | 'free' | 'none';
/**
* The points with a new end: after the last point, or for a loop between its
* last stop and the way back to the start, so the tour still comes back.
*/
export function withPointAppended<T extends { lat: number; lng: number; role: string }>(
points: readonly T[],
point: T,
): T[] {
if (!isClosedLoop(points)) return [...points, { ...point, role: 'end' }];
const end = points[points.length - 1];
return end ? [...points.slice(0, -1), { ...point, role: 'via' }, end] : [{ ...point, role: 'end' }];
}
/** What would stop a place going in after `afterId`, or null when it can. */
export function insertionBlock(points: readonly InsertionPoint[], afterId: string): InsertionBlock | null {
const index = points.findIndex((point) => point.id === afterId);
if (index < 0) return 'none';
const last = index === points.length - 1;
// After the last point the tour goes on to the place; after the way back of a loop there is nothing.
if (last) return isClosedLoop(points) ? 'none' : null;
const leg = points[index]?.fixedLeg;
if (leg?.kind === 'free') return 'free';
if (leg) return 'fixed';
return null;
}
/**
* The points with a new stop right after `afterId`: in a leg the router plans,
* between that point and the next, or as the new end after the last. Null
* where {@link insertionBlock} says it cannot go; nothing else is changed.
*/
export function withPointAfter<T extends InsertionPoint>(points: readonly T[], afterId: string, point: T): T[] | null {
if (insertionBlock(points, afterId) !== null) return null;
const index = points.findIndex((candidate) => candidate.id === afterId);
if (index === points.length - 1) return withPointAppended(points, point);
return [...points.slice(0, index + 1), { ...point, role: 'via' }, ...points.slice(index + 1)];
}
/**
* The point a place found at `alongM` metres along the route most likely
* follows: the last point before it along the line, where a stop can go in.
* The first point that can take one when none lies before it. A shaping point
* only steers the line, so a stop never goes in after one.
*/
export function defaultAfterId(
points: readonly InsertionPoint[],
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
alongM: number,
): string | null {
const open = points.filter((point) => point.shaping !== true && insertionBlock(points, point.id) === null);
if (!open.length) return null;
const line = route && route.length > 1 ? route : points.map((point): [number, number] => [point.lat, point.lng]);
let chosen = open[0]?.id ?? null;
for (const point of open) {
const along = nearnessToLine(point, line)?.alongM ?? 0;
if (along <= alongM) chosen = point.id;
}
return chosen;
}
/** How far along the route a place lies, in metres, read from the line or the points joined straight. */
export function alongRouteM(
points: readonly InsertionPoint[],
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
place: { lat: number; lng: number },
): number {
const line = route && route.length > 1 ? route : points.map((point): [number, number] => [point.lat, point.lng]);
return nearnessToLine(place, line)?.alongM ?? 0;
}
/**
* The point a place goes in after: the one picked for it while that point is
* still there (not undone, not removed), else the suggestion.
*/
export function chosenAfterId(
points: readonly InsertionPoint[],
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
place: { id: string; along_m: number },
picked: { poiId: string; afterId: string } | null,
): string | null {
if (picked?.poiId === place.id && points.some((point) => point.id === picked.afterId)) return picked.afterId;
return defaultAfterId(points, route, place.along_m);
}
/**
* PLN-04: where a place can go, one slot per point: between it and the next
* point, or after the last as the new end, which makes the tour longer. A
* slot that cannot take it says why, so the list can show it dimmed.
*/
export interface InsertionSlot {
afterId: string;
/** The point it would go in before; null where it would become the new end. */
beforeId: string | null;
block: InsertionBlock | null;
}
export function insertionSlots(points: readonly InsertionPoint[]): InsertionSlot[] {
return points.map((point, index) => ({
afterId: point.id,
beforeId: points[index + 1]?.id ?? null,
block: insertionBlock(points, point.id),
}));
}
/**
* The straight lines a place would add in the slot after `afterId`: from that
* point to the place and on to the next, or only to the place as the new
* end. Null where it cannot go there.
*/
export function insertionPreview(
points: readonly InsertionPoint[],
afterId: string,
place: { lat: number; lng: number },
): Array<[number, number]> | null {
if (insertionBlock(points, afterId) !== null) return null;
const index = points.findIndex((point) => point.id === afterId);
const from = points[index];
if (!from) return null;
const to = points[index + 1];
const line: Array<[number, number]> = [
[from.lat, from.lng],
[place.lat, place.lng],
];
return to ? [...line, [to.lat, to.lng]] : line;
}
/** How much longer the preview's lines make the tour, as the crow flies; the route planned after says it exactly. */
export function straightDetourM(line: ReadonlyArray<readonly [number, number]>): number {
const legs = (path: ReadonlyArray<readonly [number, number]>) =>
path.slice(1).reduce((sum, [lat, lng], index) => {
const [fromLat, fromLng] = path[index] ?? [lat, lng];
return sum + haversineMetres(fromLat, fromLng, lat, lng);
}, 0);
const first = line[0];
const last = line[line.length - 1];
const replaced = line.length > 2 && first && last ? legs([first, last]) : 0;
return Math.max(0, legs(line) - replaced);
}
/** The places of a search that are a point of the tour already, told by where they lie: a taken place keeps its spot. */
export function placesInTour(
places: ReadonlyArray<{ id: string; lat: number; lng: number }>,
points: ReadonlyArray<{ lat: number; lng: number }>,
): Set<string> {
const spots = new Set(points.map((point) => `${point.lat.toFixed(6)},${point.lng.toFixed(6)}`));
return new Set(
places.filter((place) => spots.has(`${place.lat.toFixed(6)},${place.lng.toFixed(6)}`)).map((place) => place.id),
);
}
+61
View File
@@ -0,0 +1,61 @@
import {
tourStopInsertRequestSchema,
tourStopInsertResultSchema,
tourStopRemoveRequestSchema,
tourStopRemoveResultSchema,
} from './tour-stop.schema';
import { describe, expect, it } from 'vitest';
const stop = { lat: 47.6, lng: 11.3, name: 'Herzogstandhaus', poi_category: 'shelter' };
const tour = {
place_id: 4,
name: 'Herzogstand',
tour_type: 'hike',
revision: 3,
distance_km: 9.1,
minutes: 210,
route_pending: false,
};
describe('a stop taken into a saved tour (P6)', () => {
it('TOUR-STOP-SCHEMA-001: takes a stop against the revision, after a key or where it lies along the way', () => {
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, expected_revision: 2, stop }).success).toBe(true);
const after = { placeId: 4, expected_revision: 2, stop, after_key: 'abc123' };
expect(tourStopInsertRequestSchema.safeParse(after).success).toBe(true);
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, stop }).success).toBe(false);
expect(tourStopInsertRequestSchema.safeParse({ ...after, after_key: 'no spaces' }).success).toBe(false);
});
it('TOUR-STOP-SCHEMA-002: refuses a stop off the globe and fields it does not know', () => {
const off = { placeId: 4, expected_revision: 2, stop: { ...stop, lat: 91 } };
expect(tourStopInsertRequestSchema.safeParse(off).success).toBe(false);
const extra = { placeId: 4, expected_revision: 2, stop: { ...stop, stay: 5 } };
expect(tourStopInsertRequestSchema.safeParse(extra).success).toBe(false);
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, expected_revision: 2, stop, x: 1 }).success).toBe(false);
});
it('TOUR-STOP-SCHEMA-003: takes a stop out by its key, against the revision', () => {
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, expected_revision: 3, key: 'abc123' }).success).toBe(
true,
);
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, key: 'abc123' }).success).toBe(false);
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, expected_revision: 0, key: 'abc123' }).success).toBe(
false,
);
});
it('TOUR-STOP-SCHEMA-004: describes what an insert and a removal answer', () => {
const point = { key: 'abc123', ...stop };
const inserted = {
tour,
stop: { ...point, after_key: 'start1', after_name: 'Parking' },
before: { distance_km: 8.2, minutes: 190 },
};
expect(tourStopInsertResultSchema.safeParse(inserted).success).toBe(true);
expect(tourStopRemoveResultSchema.safeParse({ tour, removed: { ...point, after_key: null } }).success).toBe(true);
expect(tourStopRemoveResultSchema.safeParse({ tour: { ...tour, tour_type: 'bike' }, removed: point }).success).toBe(
false,
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { idSchema } from '../common/primitives.schema';
import { stationKeySchema, tourRevisionSchema, tourWaypointSchema } from './tours.schema';
import { z } from 'zod';
/**
* P6: a place found along a saved tour (a search result, a place the
* traveller names) taken in as one stop, and the same stop taken out again.
* The server lays the stop in with the planner's own rule (poi-insertion.ts):
* after the point named, or where it most likely belongs along the route, and
* never into a leg drawn by hand or a fixed line. A hike plans its way anew
* through the points, a city walk saves the stop as a station; both against
* the revision the tour was read at.
*/
const { lat, lng, name, poi_category, opening_hours } = tourWaypointSchema.shape;
const placeId = idSchema.describe('The place id of the tour, as list_tours gives it');
const expectedRevision = tourRevisionSchema.describe(
'The revision of the tour as last read (get_tour_plan or list_tours); a tour changed since is refused, not overwritten',
);
const tourStopSchema = z
.object({
lat,
lng,
name: name.describe('The name of the place the stop is, such as a hut, a café or a viewpoint'),
poi_category: poi_category.describe(
'What kind of place it is, where it is one: peak, shelter, drinking_water, viewpoint, restaurant, museum',
),
opening_hours: opening_hours.describe(
'On a city walk: the opening hours of the place as OpenStreetMap writes them, where known',
),
})
.strict();
export type TourStop = z.infer<typeof tourStopSchema>;
export const tourStopInsertRequestSchema = z
.object({
placeId,
expected_revision: expectedRevision,
stop: tourStopSchema.describe('The place taken in as a stop: where it is and what it is called'),
after_key: stationKeySchema
.optional()
.describe(
'The key of the point of the tour the stop comes after, as get_tour_plan gives it; left out, the stop goes after the last point before it along the route',
),
})
.strict();
export type TourStopInsertRequest = z.infer<typeof tourStopInsertRequestSchema>;
export const tourStopRemoveRequestSchema = z
.object({
placeId,
expected_revision: expectedRevision,
key: stationKeySchema.describe('The key of the stop to take out, as get_tour_plan or insert_tour_stop gives it'),
})
.strict();
export type TourStopRemoveRequest = z.infer<typeof tourStopRemoveRequestSchema>;
/** The tour types a stop is taken in or out of over the assistant; any other type in the planner. */
export const TOUR_STOP_TYPES = ['hike', 'walk'] as const;
/** The tour as an insert or a removal of a stop saved it. */
const tourStopTourSchema = z.object({
place_id: idSchema,
name: z.string(),
tour_type: z.enum(TOUR_STOP_TYPES),
revision: tourRevisionSchema,
distance_km: z.number().nullable(),
minutes: z.number().nullable(),
route_pending: z.boolean(),
});
/** One stop as an answer names it: its key (null where the tour gave it none), its place and its name. */
const tourStopPointSchema = z.object({
key: stationKeySchema.nullable(),
lat: z.number(),
lng: z.number(),
name: z.string().nullable(),
poi_category: z.string().nullable(),
});
/**
* What insert_tour_stop answers: the tour as saved, the stop with its key and
* the point it came after, and the distance and time before and after as the
* tour stored them (null where not known).
*/
export const tourStopInsertResultSchema = z.object({
tour: tourStopTourSchema,
stop: tourStopPointSchema.extend({ after_key: stationKeySchema.nullable(), after_name: z.string().nullable() }),
before: z.object({ distance_km: z.number().nullable(), minutes: z.number().nullable() }),
});
export type TourStopInsertResult = z.infer<typeof tourStopInsertResultSchema>;
/** What remove_tour_stop answers: the tour as saved and the stop it took out, with the point it came after. */
export const tourStopRemoveResultSchema = z.object({
tour: tourStopTourSchema,
removed: tourStopPointSchema.extend({ after_key: stationKeySchema.nullable() }),
});
export type TourStopRemoveResult = z.infer<typeof tourStopRemoveResultSchema>;