Compare commits

..

4 Commits

Author SHA1 Message Date
Maurice aee275e2b0 fix(places): read an imported row for the dedup set in one helper
The candidate a GPX or KML row is checked as is built in lineCandidate, so
places.service.ts stays within its size baseline, and the atomic import test
imports the same Ridge line it later expects to be skipped.
2026-10-28 15:43:52 +01:00
Maurice ef86bf2660 fix(places): keep two different tracks of the same name apart on import
Two GPX tracks a watch named alike (two "Afternoon Hike"s) were taken for one:
the import compared a track only by its name, so the second was skipped as
already in the trip. A track now matches a track of that name only when it
also starts, ends and reaches as far as that one, to about a hundred metres,
so the same file imported again is still skipped. The GPX, KML and tour
imports and the tour preview all read the same rule.

Closes #2870
2026-10-28 15:43:52 +01:00
Maurice 0139c46fb4 test(i18n): count the new planned total key in the tours locales 2026-10-28 15:18:07 +01:00
Maurice a0a7fcdbb9 fix(tours): say why a tour over a day cannot be saved
A tour whose walking time and breaks came to more than 24 hours a stage
only greyed out Save, with nothing saying why. The planner now marks the
planned total and says what to do: make it a multi-day tour by ending a
stage at a stop, or enter a shorter planned total. The details section
opens by itself so the reason is in sight.

Closes #2869
2026-10-28 14:52:01 +01:00
35 changed files with 202 additions and 23 deletions
@@ -212,7 +212,7 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid}
invalid={planner.plannedDurationInvalid || planner.plannedTotalInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
@@ -220,6 +220,10 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
{/* #2869: over a day a stage Save waits, and says why rather than only greying out. */}
{!planner.plannedDurationInvalid && planner.plannedTotalInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.plannedTotalOverDay')}</FieldError>
)}
</div>
</div>
);
@@ -337,7 +341,11 @@ export function TourDetailsSection({
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const forcedOpen =
planner.websiteInvalid ||
planner.plannedDurationInvalid ||
planner.breakAdditionalInvalid ||
planner.plannedTotalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
@@ -526,6 +526,18 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
})
it('says why Save waits on a tour over a day a stage, and opens the details to say it (#2869)', () => {
window.localStorage.clear()
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, canSave: false,
walkingDurationMinutes: 1500, plannedTotalMinutes: 1500, plannedTotalInvalid: true,
})} />)
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
)
})
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
+67 -11
View File
@@ -86,15 +86,66 @@ export async function reclaimPhotoCache(
export interface DedupSet {
names: Set<string>;
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
trackNames?: Set<string>;
/**
* The places with a line, keyed by name and course (`trackKey`): a track to import
* matches only these (#2810), and only one that runs the same way (#2870). Absent
* where none is read.
*/
tracks?: Set<string>;
coords: Array<{ lat: number; lng: number }>;
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
externalIds: Set<string>;
}
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
/**
* A candidate for the dedup set; `track` when it is a line (a GPX track or route, a
* KML path), with that line in `routeGeometry`.
*/
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean; routeGeometry?: string | null };
/**
* Where a line starts, ends and how far it reaches, to about a hundred metres: two
* hikes a watch names alike ("Afternoon Hike") differ here, while the same file read
* again gives the same answer however its points were rounded or thinned. Empty for
* a line that does not parse.
*/
function lineCourse(routeGeometry: string | null | undefined): string {
let points: unknown;
try {
points = JSON.parse(routeGeometry ?? '');
} catch {
return '';
}
if (!Array.isArray(points)) return '';
const line = points.filter(
(p): p is [number, number] => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]),
);
if (line.length === 0) return '';
// A loop rather than Math.min(...): a recorded day holds more points than a call takes arguments.
let [south, west] = line[0];
let [north, east] = line[0];
for (const [lat, lng] of line) {
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
const [first, last] = [line[0], line[line.length - 1]];
return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(',');
}
/** An imported row as the dedup set reads it: a line when it carries one. */
export function lineCandidate(
name: string | null | undefined,
row: { lat: number | null; lng: number | null; routeGeometry?: string | null },
): DedupCandidate {
return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry };
}
/** A track's entry in `DedupSet.tracks`: its normalized name and its course. */
function trackKey(name: string, routeGeometry: string | null | undefined): string {
return `${name}\u0000${lineCourse(routeGeometry)}`;
}
/**
* Returns true if a candidate place is already represented in the dedup set.
@@ -114,9 +165,14 @@ export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): bo
if (strategy.by === 'externalId') {
if (dedup.externalIds.has(strategy.id)) return true;
} else if (strategy.by === 'name') {
// A track is the same as a track of that name, never as a sight of that name:
// a watch names a hike after the place it went to (#2810).
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
// A track is the same as a track of that name that runs the same way, never as a
// sight of that name: a watch names a hike after the place it went to (#2810), and
// two hikes of one day alike (#2870).
if (candidate.track) {
if (dedup.tracks?.has(trackKey(strategy.name, candidate.routeGeometry))) return true;
} else if (dedup.names.has(strategy.name)) {
return true;
}
} else if (
dedup.coords.some(
(c) =>
@@ -143,19 +199,19 @@ export function dedupSetOf(
>,
): DedupSet {
const names = new Set<string>();
const trackNames = new Set<string>();
const tracks = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = [];
const externalIds = new Set<string>();
for (const row of rows) {
for (const id of externalIdsOf(row)) externalIds.add(id);
if (row.name) {
names.add(row.name.trim().toLowerCase());
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
if (row.route_geometry) tracks.add(trackKey(row.name.trim().toLowerCase(), row.route_geometry));
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, trackNames, coords, externalIds };
return { names, tracks, coords, externalIds };
}
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
@@ -164,7 +220,7 @@ export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet):
const normalizedName = normalizePlaceName(place.name);
if (normalizedName) {
dedup.names.add(normalizedName);
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
if (place.track) (dedup.tracks ??= new Set()).add(trackKey(normalizedName, place.routeGeometry));
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
+6 -6
View File
@@ -58,7 +58,7 @@ import {
buildCategoryNameLookup,
escapeLikePattern,
dedupSetOf,
isPlaceDuplicate,
isPlaceDuplicate, lineCandidate,
mapWithConcurrency,
pickEnrichmentMatch,
reclaimPhotoCache,
@@ -758,7 +758,7 @@ export class PlacesService {
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
const dedup = await this.buildDedupSet(tripId);
return rows.map((wp) => {
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
const candidate = lineCandidate(wp.name, wp);
if (isPlaceDuplicate(candidate, dedup)) return true;
trackInsertedInDedupSet(candidate, dedup);
return false;
@@ -925,7 +925,7 @@ export class PlacesService {
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
// sees the still-uncommitted row.
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
if (isPlaceDuplicate(lineCandidate(wp.name, wp), dedup)) {
skipped++;
continue;
}
@@ -960,7 +960,7 @@ export class PlacesService {
});
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
trackInsertedInDedupSet(lineCandidate(wp.name, wp), dedup);
}
return { places: created, count: created.length, skipped };
@@ -1033,7 +1033,7 @@ export class PlacesService {
const fallbackName = `Placemark ${fallbackIndex}`;
const name = parsedPlacemark.name || fallbackName;
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
if (isPlaceDuplicate(lineCandidate(name, parsedPlacemark), dedup)) {
summary.skippedCount += 1;
dupCount++;
fallbackIndex += 1;
@@ -1079,7 +1079,7 @@ export class PlacesService {
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
trackInsertedInDedupSet(lineCandidate(name, parsedPlacemark), dedup);
summary.createdCount += 1;
fallbackIndex += 1;
}
+16
View File
@@ -535,6 +535,22 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1);
});
it('imports two different tracks of the same name, and still skips either one the second time (#2870)', async () => {
// A second hike the watch called "Alpine loop" too, somewhere else entirely.
const other = TRACK_GPX.replace(/lat="49(\.\d+)?" lon="12/g, (m) => m.replace('49', '47').replace('12', '11'));
expect((await importGpx(OWNER, TRACK_GPX)).body).toMatchObject({ skipped: 0 });
expect((await previewGpx(OWNER, other)).body.tracks[0]).toMatchObject({ duplicate: false });
const second = await importGpx(OWNER, other);
expect(second.status).toBe(201);
expect(second.body).toMatchObject({ skipped: 0 });
expect(second.body.tours).toHaveLength(1);
expect(await countRows(orm, Tours)).toBe(2);
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
expect((await importGpx(OWNER, other)).body).toEqual({ tours: [], caution: false, skipped: 1 });
expect(await countRows(orm, Tours)).toBe(2);
});
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
// 4.x stored a GPX track as a plain place: a line, no tours row.
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
+34 -2
View File
@@ -33,7 +33,9 @@ describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
]);
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true, routeGeometry: '[[1,2],[3,4]]' }, dedup)).toBe(
true,
);
// A pin still matches every place of its name, a track included.
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
@@ -46,7 +48,37 @@ describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
expect([...(bare.trackNames ?? [])]).toEqual(['lake']);
expect([...(bare.tracks ?? [])]).toEqual(['lake\u0000']);
});
it('keeps two tracks of one name apart when they run differently (#2870)', () => {
// Two watch hikes, both called "Afternoon Hike": one up the valley, one along the coast.
const valley = '[[46.5,8.0,1200],[46.52,8.03,1500],[46.55,8.06,1900]]';
const coast = '[[43.7,7.25],[43.72,7.3],[43.74,7.35]]';
const dedup = dedupSetOf([{ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, route_geometry: valley }]);
expect(
isPlaceDuplicate({ name: 'afternoon hike', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast }, dedup),
).toBe(false);
// The same file again is still the same track, its points rounded or thinned as they may be.
const valleyAgain = '[[46.50001,8.00002],[46.55,8.06]]';
expect(
isPlaceDuplicate({ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, track: true, routeGeometry: valleyAgain }, dedup),
).toBe(true);
// Within one file: the first of the two goes in, the second is not taken for it.
const batch = emptyDedup();
const first = { name: 'Day 1', lat: 46.5, lng: 8.0, track: true, routeGeometry: valley };
const second = { name: 'Day 1', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast };
expect(isPlaceDuplicate(first, batch)).toBe(false);
trackInsertedInDedupSet(first, batch);
expect(isPlaceDuplicate(second, batch)).toBe(false);
trackInsertedInDedupSet(second, batch);
expect(isPlaceDuplicate({ ...second }, batch)).toBe(true);
// A line that does not parse falls back to the name alone, on both sides.
const broken = dedupSetOf([{ name: 'Loop', lat: 1, lng: 2, route_geometry: 'not json' }]);
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: '{}' }, broken)).toBe(true);
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: coast }, broken)).toBe(false);
});
it('matches a named place case- and whitespace-insensitively', () => {
@@ -418,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
});
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
// The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870).
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
</trkseg></trk></gpx>`);
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk></gpx>`);
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
broadcast.mockClear();
+2
View File
@@ -157,6 +157,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'حول المدة الإجمالية المخططة',
'tours.planner.plannedTotalManual': 'تعديل يدوي',
'tours.planner.timeValueInvalid': 'أدخل قيمة صحيحة من 0 إلى 1440 دقيقة.',
'tours.planner.plannedTotalOverDay':
'وقت المشي مع الاستراحات يزيد على 24 ساعة في اليوم. اجعلها جولة لعدة أيام عبر «إنهاء المرحلة هنا» عند إحدى المحطات، أو أدخل مدة إجمالية مخططة أقصر.',
'tours.planner.walkingTimeUnknown': 'غير محسوب',
'tours.planner.walkingShort': 'مشي',
'tours.planner.breaksShort': 'استراحات',
+2
View File
@@ -142,6 +142,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Planlaşdırılmış ümumi müddət haqqında',
'tours.planner.plannedTotalManual': 'Əl ilə dəyişdirmə',
'tours.planner.timeValueInvalid': '0 ilə 1440 dəqiqə arasında tam dəyər daxil edin.',
'tours.planner.plannedTotalOverDay':
'Yeriş vaxtı və fasilələr gündə 24 saatdan çox edir. Dayanacaqda “Mərhələni burada bitir” ilə onu çoxgünlük tura çevirin və ya daha qısa planlaşdırılmış ümumi müddət daxil edin.',
'tours.planner.walkingTimeUnknown': 'Hesablanmayıb',
'tours.planner.walkingShort': 'Gəzinti',
'tours.planner.breaksShort': 'Fasilələr',
+2
View File
@@ -146,6 +146,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Sobre a duração total planejada',
'tours.planner.plannedTotalManual': 'Ajuste manual',
'tours.planner.timeValueInvalid': 'Informe um valor inteiro de 0 a 1440 minutos.',
'tours.planner.plannedTotalOverDay':
'O tempo de caminhada com as pausas passa de 24 horas por dia. Transforme em um passeio de vários dias com “Encerrar etapa aqui” em uma parada, ou informe uma duração total planejada menor.',
'tours.planner.walkingTimeUnknown': 'Não calculado',
'tours.planner.walkingShort': 'Caminhada',
'tours.planner.breaksShort': 'Pausas',
+2
View File
@@ -146,6 +146,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Sobre la durada total planificada',
'tours.planner.plannedTotalManual': 'Valor manual',
'tours.planner.timeValueInvalid': 'Introduïu un valor enter de 0 a 1440 minuts.',
'tours.planner.plannedTotalOverDay':
'El temps de marxa i les pauses sumen més de 24 hores al dia. Feu-ne una ruta de diversos dies amb “Acaba l’etapa aquí” en una parada, o introduïu una durada total planificada més curta.',
'tours.planner.walkingTimeUnknown': 'No calculat',
'tours.planner.walkingShort': 'Caminada',
'tours.planner.breaksShort': 'Pauses',
+2
View File
@@ -144,6 +144,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'O plánované celkové době',
'tours.planner.plannedTotalManual': 'Ruční úprava',
'tours.planner.timeValueInvalid': 'Zadejte celou hodnotu od 0 do 1440 minut.',
'tours.planner.plannedTotalOverDay':
'Doba chůze a přestávky dávají dohromady víc než 24 hodin denně. Udělejte z ní vícedenní túru pomocí „Ukončit etapu zde“ u zastávky, nebo zadejte kratší plánovanou celkovou dobu.',
'tours.planner.walkingTimeUnknown': 'Nevypočteno',
'tours.planner.walkingShort': 'Chůze',
'tours.planner.breaksShort': 'Pauzy',
+2
View File
@@ -144,6 +144,8 @@ const tours: TranslationStrings = {
'tours.planner.breaksAdditional': 'Pausen / zusätzliche Zeit',
'tours.planner.plannedTotalManual': 'Manuell angepasst',
'tours.planner.timeValueInvalid': 'Ganze Minuten von 0 bis 1.440 eingeben.',
'tours.planner.plannedTotalOverDay':
'Gehzeit und Pausen ergeben mehr als 24 Stunden am Tag. Mach eine Mehrtagestour daraus, mit „Etappe hier beenden“ an einem Halt, oder gib eine kürzere geplante Gesamtdauer ein.',
'tours.planner.walkingTimeUnknown': 'Noch nicht berechnet',
'tours.planner.walkingShort': 'Gehzeit',
'tours.planner.breaksShort': 'Pausen',
+2
View File
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'About planned total duration',
'tours.planner.plannedTotalManual': 'Manual override',
'tours.planner.timeValueInvalid': 'Enter a whole value from 0 to 1,440 minutes.',
'tours.planner.plannedTotalOverDay':
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
'tours.planner.walkingTimeUnknown': 'Not calculated',
'tours.planner.walkingShort': 'Walk',
'tours.planner.breaksShort': 'Breaks',
+2
View File
@@ -147,6 +147,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Acerca de la duración total planificada',
'tours.planner.plannedTotalManual': 'Ajuste manual',
'tours.planner.timeValueInvalid': 'Introduce un valor entero de 0 a 1440 minutos.',
'tours.planner.plannedTotalOverDay':
'El tiempo de marcha y las pausas suman más de 24 horas al día. Conviértela en una ruta de varios días con «Terminar etapa aquí» en una parada, o introduce una duración total planificada más corta.',
'tours.planner.walkingTimeUnknown': 'No calculado',
'tours.planner.walkingShort': 'Caminata',
'tours.planner.breaksShort': 'Pausas',
+2
View File
@@ -143,6 +143,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Planeeritud kogukestusest',
'tours.planner.plannedTotalManual': 'Käsitsi määratud',
'tours.planner.timeValueInvalid': 'Sisestage täisarv vahemikus 0–1440 minutit.',
'tours.planner.plannedTotalOverDay':
'Liikumisaeg koos pausidega on üle 24 tunni päevas. Tee sellest mitmepäevane matk, valides peatuses „Lõpeta etapp siin“, või sisesta lühem planeeritud kogukestus.',
'tours.planner.walkingTimeUnknown': 'Arvutamata',
'tours.planner.walkingShort': 'Kõnd',
'tours.planner.breaksShort': 'Pausid',
+2
View File
@@ -148,6 +148,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'À propos de la durée totale prévue',
'tours.planner.plannedTotalManual': 'Valeur manuelle',
'tours.planner.timeValueInvalid': 'Saisissez une valeur entière de 0 à 1440 minutes.',
'tours.planner.plannedTotalOverDay':
'Le temps de marche et les pauses dépassent 24 heures par jour. Faites-en un itinéraire de plusieurs jours avec « Terminer l’étape ici » sur un arrêt, ou saisissez une durée totale prévue plus courte.',
'tours.planner.walkingTimeUnknown': 'Non calculé',
'tours.planner.walkingShort': 'Marche',
'tours.planner.breaksShort': 'Pauses',
+2
View File
@@ -148,6 +148,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Σχετικά με τη συνολική προγραμματισμένη διάρκεια',
'tours.planner.plannedTotalManual': 'Χειροκίνητη τιμή',
'tours.planner.timeValueInvalid': 'Εισαγάγετε ακέραια τιμή από 0 έως 1440 λεπτά.',
'tours.planner.plannedTotalOverDay':
'Ο χρόνος πεζοπορίας μαζί με τις στάσεις ξεπερνά τις 24 ώρες την ημέρα. Κάντε τη πολυήμερη με «Τέλος σκέλους εδώ» σε μια στάση ή εισαγάγετε μικρότερη συνολική προγραμματισμένη διάρκεια.',
'tours.planner.walkingTimeUnknown': 'Δεν έχει υπολογιστεί',
'tours.planner.walkingShort': 'Περπάτημα',
'tours.planner.breaksShort': 'Διαλείμματα',
+2
View File
@@ -143,6 +143,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'A tervezett teljes időtartamról',
'tours.planner.plannedTotalManual': 'Kézi érték',
'tours.planner.timeValueInvalid': 'Adjon meg egész értéket 0 és 1440 perc között.',
'tours.planner.plannedTotalOverDay':
'A menetidő a pihenőkkel együtt napi 24 óránál több. Tegye többnapossá a „Szakasz vége itt” lehetőséggel egy megállónál, vagy adjon meg rövidebb tervezett teljes időtartamot.',
'tours.planner.walkingTimeUnknown': 'Nincs kiszámítva',
'tours.planner.walkingShort': 'Séta',
'tours.planner.breaksShort': 'Szünetek',
+2
View File
@@ -139,6 +139,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Tentang total durasi yang direncanakan',
'tours.planner.plannedTotalManual': 'Penggantian manual',
'tours.planner.timeValueInvalid': 'Masukkan nilai bulat dari 0 hingga 1440 menit.',
'tours.planner.plannedTotalOverDay':
'Waktu jalan ditambah istirahat lebih dari 24 jam sehari. Jadikan tur beberapa hari dengan “Akhiri etape di sini” di sebuah perhentian, atau masukkan total durasi rencana yang lebih singkat.',
'tours.planner.walkingTimeUnknown': 'Belum dihitung',
'tours.planner.walkingShort': 'Jalan kaki',
'tours.planner.breaksShort': 'Istirahat',
+2
View File
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Informazioni sulla durata totale pianificata',
'tours.planner.plannedTotalManual': 'Valore manuale',
'tours.planner.timeValueInvalid': 'Inserisci un valore intero da 0 a 1440 minuti.',
'tours.planner.plannedTotalOverDay':
'Il tempo di cammino e le pause superano le 24 ore al giorno. Rendilo un itinerario di più giorni con “Termina la tappa qui” su una sosta, oppure inserisci una durata totale pianificata più breve.',
'tours.planner.walkingTimeUnknown': 'Non calcolato',
'tours.planner.walkingShort': 'Cammino',
'tours.planner.breaksShort': 'Pause',
+2
View File
@@ -137,6 +137,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': '予定の合計所要時間について',
'tours.planner.plannedTotalManual': '手動で上書き',
'tours.planner.timeValueInvalid': '0〜1440分の整数を入力してください。',
'tours.planner.plannedTotalOverDay':
'歩行時間と休憩の合計が1日24時間を超えています。立ち寄り先で「ここでステージを終える」を選んで複数日のツアーにするか、予定の合計所要時間を短く入力してください。',
'tours.planner.walkingTimeUnknown': '未計算',
'tours.planner.walkingShort': '徒歩',
'tours.planner.breaksShort': '休憩',
+2
View File
@@ -134,6 +134,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': '예상 총 소요 시간 정보',
'tours.planner.plannedTotalManual': '직접 입력',
'tours.planner.timeValueInvalid': '0~1440분 사이의 정수 값을 입력하세요.',
'tours.planner.plannedTotalOverDay':
'걷는 시간과 휴식을 합치면 하루 24시간을 넘습니다. 경유지에서 “여기서 구간 종료”로 여러 날 투어로 나누거나, 더 짧은 예상 총 소요 시간을 입력하세요.',
'tours.planner.walkingTimeUnknown': '계산되지 않음',
'tours.planner.walkingShort': '도보',
'tours.planner.breaksShort': '휴식',
+2
View File
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Over de geplande totale duur',
'tours.planner.plannedTotalManual': 'Handmatige waarde',
'tours.planner.timeValueInvalid': 'Voer een geheel getal in van 0 tot 1440 minuten.',
'tours.planner.plannedTotalOverDay':
'Looptijd en pauzes komen samen op meer dan 24 uur per dag. Maak er een meerdaagse tocht van met ‘Etappe hier beëindigen’ bij een stop, of vul een kortere geplande totale duur in.',
'tours.planner.walkingTimeUnknown': 'Niet berekend',
'tours.planner.walkingShort': 'Wandelen',
'tours.planner.breaksShort': 'Pauzes',
+2
View File
@@ -154,6 +154,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'O planowanym całkowitym czasie',
'tours.planner.plannedTotalManual': 'Wartość ręczna',
'tours.planner.timeValueInvalid': 'Wpisz liczbę całkowitą od 0 do 1440 minut.',
'tours.planner.plannedTotalOverDay':
'Czas marszu i przerwy dają razem ponad 24 godziny na dzień. Zrób z tego trasę wielodniową przez „Zakończ etap tutaj” na postoju albo wpisz krótszy planowany całkowity czas.',
'tours.planner.walkingTimeUnknown': 'Nieobliczono',
'tours.planner.walkingShort': 'Marsz',
'tours.planner.breaksShort': 'Przerwy',
+2
View File
@@ -155,6 +155,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'О запланированной общей длительности',
'tours.planner.plannedTotalManual': 'Ручное значение',
'tours.planner.timeValueInvalid': 'Введите целое значение от 0 до 1440 минут.',
'tours.planner.plannedTotalOverDay':
'Время в пути вместе с перерывами превышает 24 часа в день. Сделайте маршрут многодневным через «Завершить этап здесь» на остановке или введите меньшую запланированную общую длительность.',
'tours.planner.walkingTimeUnknown': 'Не рассчитано',
'tours.planner.walkingShort': 'Ходьба',
'tours.planner.breaksShort': 'Перерывы',
+2
View File
@@ -145,6 +145,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'O plánovanej celkovej dobe',
'tours.planner.plannedTotalManual': 'Ručná úprava',
'tours.planner.timeValueInvalid': 'Zadajte celú hodnotu od 0 do 1440 minút.',
'tours.planner.plannedTotalOverDay':
'Čas chôdze a prestávky dávajú spolu viac než 24 hodín denne. Urobte z nej viacdňovú túru cez „Ukončiť etapu tu“ pri zastávke alebo zadajte kratšiu plánovanú celkovú dobu.',
'tours.planner.walkingTimeUnknown': 'Nevypočítané',
'tours.planner.walkingShort': 'Chôdza',
'tours.planner.breaksShort': 'Prestávky',
+2
View File
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Om planerad total tid',
'tours.planner.plannedTotalManual': 'Manuellt värde',
'tours.planner.timeValueInvalid': 'Ange ett helt värde från 0 till 1440 minuter.',
'tours.planner.plannedTotalOverDay':
'Gångtid och pauser blir tillsammans mer än 24 timmar per dag. Gör det till en flerdagarstur med ”Avsluta etappen här” vid ett stopp, eller ange en kortare planerad total tid.',
'tours.planner.walkingTimeUnknown': 'Inte beräknad',
'tours.planner.walkingShort': 'Gång',
'tours.planner.breaksShort': 'Pauser',
+2
View File
@@ -134,6 +134,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'เกี่ยวกับระยะเวลารวมที่วางแผนไว้',
'tours.planner.plannedTotalManual': 'กำหนดเอง',
'tours.planner.timeValueInvalid': 'ป้อนค่าจำนวนเต็มตั้งแต่ 0 ถึง 1440 นาที',
'tours.planner.plannedTotalOverDay':
'เวลาเดินรวมเวลาพักเกิน 24 ชั่วโมงต่อวัน ให้แบ่งเป็นทริปหลายวันด้วย “จบช่วงที่นี่” ที่จุดแวะ หรือป้อนระยะเวลารวมที่วางแผนไว้ให้สั้นลง',
'tours.planner.walkingTimeUnknown': 'ยังไม่ได้คำนวณ',
'tours.planner.walkingShort': 'เดิน',
'tours.planner.breaksShort': 'การพัก',
+1 -1
View File
@@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
{
file: 'tours',
generalKeyCount: 1350,
generalKeyCount: 1351,
modules: toursModules,
},
{
+2
View File
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Planlanan toplam süre hakkında',
'tours.planner.plannedTotalManual': 'Elle değiştirilen',
'tours.planner.timeValueInvalid': '0 ile 1440 dakika arasında tam bir değer girin.',
'tours.planner.plannedTotalOverDay':
'Yürüyüş süresi ve molalar günde 24 saati aşıyor. Bir durakta “Etabı burada bitir” ile çok günlü bir tura dönüştürün ya da daha kısa bir planlanan toplam süre girin.',
'tours.planner.walkingTimeUnknown': 'Hesaplanmadı',
'tours.planner.walkingShort': 'Yürüyüş',
'tours.planner.breaksShort': 'Molalar',
+2
View File
@@ -152,6 +152,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Про заплановану загальну тривалість',
'tours.planner.plannedTotalManual': 'Ручне значення',
'tours.planner.timeValueInvalid': 'Введіть ціле значення від 0 до 1440 хвилин.',
'tours.planner.plannedTotalOverDay':
'Час у дорозі разом із перервами перевищує 24 години на день. Зробіть маршрут багатоденним через «Завершити етап тут» на зупинці або введіть коротшу заплановану загальну тривалість.',
'tours.planner.walkingTimeUnknown': 'Не розраховано',
'tours.planner.walkingShort': 'Ходьба',
'tours.planner.breaksShort': 'Перерви',
+2
View File
@@ -141,6 +141,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': 'Về tổng thời lượng dự kiến',
'tours.planner.plannedTotalManual': 'Ghi đè thủ công',
'tours.planner.timeValueInvalid': 'Nhập giá trị nguyên từ 0 đến 1440 phút.',
'tours.planner.plannedTotalOverDay':
'Thời gian đi bộ cộng thời gian nghỉ vượt quá 24 giờ mỗi ngày. Hãy chia thành chuyến nhiều ngày bằng “Kết thúc chặng tại đây” ở một điểm dừng, hoặc nhập tổng thời lượng dự kiến ngắn hơn.',
'tours.planner.walkingTimeUnknown': 'Chưa tính',
'tours.planner.walkingShort': 'Đi bộ',
'tours.planner.breaksShort': 'Nghỉ',
+2
View File
@@ -127,6 +127,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': '關於預計總時長',
'tours.planner.plannedTotalManual': '手動覆寫',
'tours.planner.timeValueInvalid': '請輸入 0 到 1440 分鐘之間的整數。',
'tours.planner.plannedTotalOverDay':
'步行時間加休息每天超過 24 小時。請在某個停靠點選擇「在此結束階段」,把它分成多日行程,或輸入更短的預計總時長。',
'tours.planner.walkingTimeUnknown': '未計算',
'tours.planner.walkingShort': '步行',
'tours.planner.breaksShort': '休息',
+2
View File
@@ -128,6 +128,8 @@ const tours: TranslationStrings = {
'tours.planner.plannedTotalInfoLabel': '关于计划总时长',
'tours.planner.plannedTotalManual': '手动覆盖',
'tours.planner.timeValueInvalid': '请输入 0 到 1440 分钟之间的整数。',
'tours.planner.plannedTotalOverDay':
'步行时间加休息每天超过 24 小时。请在某个停靠点选择“在此结束阶段”,把它分成多日行程,或输入更短的计划总时长。',
'tours.planner.walkingTimeUnknown': '未计算',
'tours.planner.walkingShort': '步行',
'tours.planner.breaksShort': '休息',