From 6d43f3a4e6985661a40aa76b610c148bc6237351 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 17:45:31 +0200 Subject: [PATCH] feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass --- .../components/Ferratas/FerrataDetailCard.tsx | 25 ++++++-- .../Ferratas/FerrataDetailOverlay.tsx | 7 ++- .../src/components/Planner/LocationSelect.tsx | 17 ++++- client/src/components/Tours/TourListRow.tsx | 40 ++++++------ .../src/components/Tours/hikingTime.test.ts | 19 ++++++ client/src/components/Tours/hikingTime.ts | 19 ++++++ .../Tours/planner/TourEditorFields.tsx | 27 +------- .../Tours/planner/TourPlannerPanels.test.tsx | 5 +- .../Tours/planner/TourPlannerPanels.tsx | 55 +++++++++++----- .../Tours/planner/useTourPlanner.test.tsx | 11 +++- .../Tours/planner/useTourPlanner.ts | 19 +++--- .../Tours/terrain/TourTerrainMap.tsx | 49 +++++++++++++-- client/src/components/Tours/tourParts.tsx | 63 +++++++++++++++++-- .../src/components/Tours/toursI18n.test.tsx | 5 +- .../pages/tripPlanner/TourPlannerMapStage.tsx | 9 ++- shared/src/i18n/ar/tours.ts | 5 +- shared/src/i18n/az/tours.ts | 5 +- shared/src/i18n/br/tours.ts | 5 +- shared/src/i18n/ca/tours.ts | 5 +- shared/src/i18n/cs/tours.ts | 5 +- shared/src/i18n/de/tours.ts | 5 +- shared/src/i18n/en/tours.ts | 5 +- shared/src/i18n/es/tours.ts | 5 +- shared/src/i18n/et/tours.ts | 5 +- shared/src/i18n/fr/tours.ts | 5 +- shared/src/i18n/gr/tours.ts | 5 +- shared/src/i18n/hu/tours.ts | 5 +- shared/src/i18n/id/tours.ts | 5 +- shared/src/i18n/it/tours.ts | 5 +- shared/src/i18n/ja/tours.ts | 5 +- shared/src/i18n/ko/tours.ts | 5 +- shared/src/i18n/nl/tours.ts | 5 +- shared/src/i18n/pl/tours.ts | 5 +- shared/src/i18n/ru/tours.ts | 5 +- shared/src/i18n/sk/tours.ts | 5 +- shared/src/i18n/sv/tours.ts | 5 +- shared/src/i18n/th/tours.ts | 5 +- shared/src/i18n/tr/tours.ts | 5 +- shared/src/i18n/uk/tours.ts | 5 +- shared/src/i18n/vi/tours.ts | 5 +- shared/src/i18n/zh-TW/tours.ts | 5 +- shared/src/i18n/zh/tours.ts | 5 +- 42 files changed, 385 insertions(+), 120 deletions(-) create mode 100644 client/src/components/Tours/hikingTime.test.ts create mode 100644 client/src/components/Tours/hikingTime.ts diff --git a/client/src/components/Ferratas/FerrataDetailCard.tsx b/client/src/components/Ferratas/FerrataDetailCard.tsx index 855a32061..96b949d72 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.tsx @@ -5,10 +5,20 @@ import type { Ferrata } from '@trek/shared'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { formatDistance } from '../../utils/units'; +import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow'; import { SoftPill } from '../Planner/planParts'; +import { fs } from '../shared/DialogShell'; import { type FerrataGroup, ferrataLabel } from './ferrataGroups'; import { ferrataOsmUrl } from './ferrataModel'; +/** The map controls' frosted glass, so the card reads as one of them. */ +const MAP_GLASS = { + background: 'var(--sidebar-bg)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + boxShadow: MAP_CONTROL_SHADOW, +}; + /** * One via ferrata, picked on the map: its name, the route it belongs to, its * grade range and total length as badges, and the way back to the source. @@ -21,10 +31,13 @@ export default function FerrataDetailCard({ group, segment, onClose, + width, }: { group: FerrataGroup; segment: Ferrata; onClose?: () => void; + /** Set when the card stands on another control and takes its width. */ + width?: number; }) { const { t } = useTranslation(); const unit = useSettingsStore((s) => s.settings.distance_unit); @@ -37,13 +50,14 @@ export default function FerrataDetailCard({ return (
); } diff --git a/client/src/components/Ferratas/FerrataDetailOverlay.tsx b/client/src/components/Ferratas/FerrataDetailOverlay.tsx index 704172f7c..cca77a2a7 100644 --- a/client/src/components/Ferratas/FerrataDetailOverlay.tsx +++ b/client/src/components/Ferratas/FerrataDetailOverlay.tsx @@ -22,12 +22,15 @@ export default function FerrataDetailOverlay({ onOutsidePress, insetEnd, bottom, + width, }: { selection: FerrataSelection | null; onClose: () => void; onOutsidePress?: () => void; insetEnd: number; bottom: number; + /** A fixed width, to match what the card stands on (the grade legend in 3D). */ + width?: number; }) { const ref = useRef(null); const open = !!selection; @@ -55,11 +58,11 @@ export default function FerrataDetailOverlay({ return (
- +
); } diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 5eb0e9c9a..9772fafd3 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -3,9 +3,11 @@ import { MapPin, Search } from 'lucide-react' import { mapsApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useLocationBias } from '../../hooks/useLocationBias' +import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' import { useSuggestionDropdown } from './useSuggestionDropdown' import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown' +import { SoftPill } from './planParts' export interface LocationPoint { name: string @@ -30,9 +32,11 @@ interface Props { * name; from the third character on the map search answers instead. */ places?: LocationPoint[] + /** Marks each search result with where it came from (OSM, Google, TREK, a plugin), as the place form does. */ + showSource?: boolean } -export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places }: Props) { +export default function LocationSelect({ value, onChange, placeholder, ariaLabel, showSearchStatus = false, floating = false, style, places, showSource = false }: Props) { const { t, locale } = useTranslation() const placeLang = usePlaceLanguage() // Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher. @@ -40,6 +44,9 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel const [query, setQuery] = useState(value?.name || '') const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown() const [results, setResults] = useState([]) + // What answered the last search, for the rows that do not name their own source. + const [listSource, setListSource] = useState('') + const { sourceLabel } = usePlaceSuggestions() const [loading, setLoading] = useState(false) const [searchError, setSearchError] = useState(false) // The text the last answer was for, so a status never describes older text. @@ -72,6 +79,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel const data = await mapsApi.search(trimmed, placeLang, locationBias) if (stale) return setResults(data.places || []) + setListSource(data.source || '') setHighlight(-1) setSearchedQuery(trimmed) } catch { @@ -170,6 +178,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel optionId={showSearchStatus ? `${listId}-option-${i}` : undefined} > + {showSource && !showPicks && } ))} @@ -178,6 +187,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel ) } +/** Where a result came from, quiet on purpose, beside its name. */ +function SourceMark({ label }: { label: string | null }) { + if (!label) return null + return {label} +} + function pickRows(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) { const showPicks = !value && typed.length < 3 && !!places?.length const rows: (T | LocationPoint)[] = showPicks diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index b5e34dc56..45607e003 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -1,7 +1,8 @@ import { useId, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' +import { Link2 } from 'lucide-react' import { fs } from '../shared/DialogShell' -import { useTranslation } from '../../i18n' +import { SoftPill } from '../Planner/planParts' import { TourFactPills, TourGradeTile } from './tourParts' import { tourWebsitePresentation } from './tourPresentation' @@ -16,7 +17,6 @@ interface TourListRowProps { /** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */ export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) { - const { t } = useTranslation() const nameId = useId() const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover' const website = tourWebsitePresentation(tour.website) @@ -37,28 +37,32 @@ export default function TourListRow({ tour, selected = false, disabled = false, } }} > -
+ {/* The tile, the name and the day actions share the head line; the rest runs the + full width under them, below the tile too. */} +
-
- {tour.name} - - {tour.description && ( -
- {tour.description} -
- )} - {website && ( -
- {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`} -
- )} -
+ {tour.name} {action && ( -
event.stopPropagation()}> +
event.stopPropagation()}> {action}
)}
+ {tour.description && ( +
+ + {tour.description} + +
+ )} + + {website && ( +
+ }> + {website.provider ?? website.domain} + +
+ )} {children} ) diff --git a/client/src/components/Tours/hikingTime.test.ts b/client/src/components/Tours/hikingTime.test.ts new file mode 100644 index 000000000..94756e7fe --- /dev/null +++ b/client/src/components/Tours/hikingTime.test.ts @@ -0,0 +1,19 @@ +// FE-TOURS-HIKINGTIME-001 to -003: walking time after DIN 33466. +import { describe, expect, it } from 'vitest'; +import { hikingTimeMinutes } from './hikingTime'; + +describe('hikingTimeMinutes', () => { + it('FE-TOURS-HIKINGTIME-001: on the flat it is 4 km an hour', () => { + expect(hikingTimeMinutes(8, 0, 0)).toBe(120); + }); + + it('FE-TOURS-HIKINGTIME-002: a short steep climb is ruled by the height, the distance counts by half', () => { + // 2 km and 450 m up: 1.5 h for the height plus half of 0.5 h for the distance. + expect(hikingTimeMinutes(2, 450, 0)).toBe(105); + }); + + it('FE-TOURS-HIKINGTIME-003: a long walk is ruled by the distance, the height counts by half', () => { + // 12 km, 300 m up and 500 m down: 3 h for the distance plus half of 2 h for the height. + expect(hikingTimeMinutes(12, 300, 500)).toBe(240); + }); +}); diff --git a/client/src/components/Tours/hikingTime.ts b/client/src/components/Tours/hikingTime.ts new file mode 100644 index 000000000..80a5b4399 --- /dev/null +++ b/client/src/components/Tours/hikingTime.ts @@ -0,0 +1,19 @@ +/** + * Walking time the way hiking signposts in the Alps state it (DIN 33466, as the + * DAV and the Swiss hiking trails use it): 4 km an hour on the flat, 300 m of + * climb and 500 m of descent an hour, and of the time for the distance and the + * time for the height only the larger counts in full, the smaller by half. + * + * The router's own estimate is a walking pace along the line and barely sees a + * slope, so a short steep climb came out far too quick. + */ +const FLAT_KM_PER_HOUR = 4; +const CLIMB_M_PER_HOUR = 300; +const DESCENT_M_PER_HOUR = 500; + +export function hikingTimeMinutes(distanceKm: number, climbM: number, descentM: number): number { + const flat = distanceKm / FLAT_KM_PER_HOUR; + const height = climbM / CLIMB_M_PER_HOUR + descentM / DESCENT_M_PER_HOUR; + const hours = Math.max(flat, height) + Math.min(flat, height) / 2; + return Math.round(hours * 60); +} diff --git a/client/src/components/Tours/planner/TourEditorFields.tsx b/client/src/components/Tours/planner/TourEditorFields.tsx index 9a2dd3d44..9917527d0 100644 --- a/client/src/components/Tours/planner/TourEditorFields.tsx +++ b/client/src/components/Tours/planner/TourEditorFields.tsx @@ -1,12 +1,11 @@ -import { AlertTriangle, Info } from 'lucide-react'; -import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { SoftPill } from '../../Planner/planParts'; import CustomSelect from '../../shared/CustomSelect'; import { INPUT, LABEL } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; -import { Tooltip } from '../../shared/Tooltip'; -import { FoldButton, TourNotice, TourSection } from '../tourParts'; +import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts'; import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput'; import type { TourPlannerController } from './useTourPlanner'; @@ -39,26 +38,6 @@ function writeDetailsOpen(open: boolean) { } } -/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */ -export function InfoDot({ description, label }: { description: string; label: string }) { - const id = useId(); - return ( - - - - ); -} - /** A field label with what belongs beside it (an info dot, a badge) on the same line. */ function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) { return ( diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index aa3f6f2d3..99aecd116 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -195,7 +195,6 @@ describe('TourPlannerToursRail', () => { fireEvent.click(screen.getByRole('button', { name: /Summit day/ })) await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7)) - expect(screen.getByRole('status')).toHaveTextContent('Tour saved') expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' })).toBeEnabled() }) @@ -563,7 +562,7 @@ describe('TourPlannerRail', () => { expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) - expect(screen.getByRole('status')).toHaveTextContent('Saving…') + expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument() }) it('TOUR-PLANNER-RAIL-006: places the safety note beside Save and numbers rows like map pins', () => { @@ -813,12 +812,10 @@ describe('TourPlannerRail', () => { it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => { const { rerender } = render() - expect(screen.getByRole('status')).toHaveTextContent('Draft') expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument() rerender() - expect(screen.getByRole('status')).toHaveTextContent('Draft') expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument() }) diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 2b19e945b..869de1523 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { AlertTriangle, ArrowLeft, Info, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react' +import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react' import type { Day } from '../../../types' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' @@ -58,9 +58,21 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid ) } +/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */ +function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) { + const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted' + return ( + + + {children} + + + ) +} + /** - * What the editor is doing right now, as badges under the head's title: a - * restored draft, routing or saving, and a failure with its retry. Nothing + * What the editor is doing right now, as icon badges beside the head's title: + * a restored draft, routing or saving, and a failure with its retry. Nothing * when there is nothing to say. */ function RailStatusBadges({ planner }: { planner: TourPlannerController }) { @@ -71,19 +83,21 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) { const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed' if (!restored && !statusKey && !failed) return null const warning = planner.error != null || canRetry + const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null + const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation' return ( -
+ {restored && ( - - - }>{t('tours.planner.restoredShort')} - - + + + )} - {(failed || statusKey) && ( - : undefined}> - {failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null} - + {statusLabel && ( + + {warning + ? + : busy ? : } + )} {canRetry && (
+ ) } @@ -187,7 +201,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> - } /> + } /> { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}> @@ -315,9 +329,14 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo const routeAnalysis = planner.routeAnalysis // A finished save is said once, as the app says it everywhere: a toast. + // Once per save: the toast helper and t are fresh objects on every render, so the + // effect reruns often; only a save it has not announced yet gets the toast. const savedTour = planner.saveOutcome + const announced = useRef(null) useEffect(() => { - if (savedTour) toast.success(t('tours.planner.saved')) + if (!savedTour || announced.current === savedTour) return + announced.current = savedTour + toast.success(t('tours.planner.saved')) }, [savedTour, t, toast]) const openTour = async (tour: TourListItem) => { @@ -341,7 +360,9 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo const tourTimeFields = [ { - label: t('tours.planner.inspector.duration'), + label: t('tours.planner.walkingEstimated'), + shortLabel: t('tours.planner.walkingEstimatedShort'), + info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') }, value: planner.walkingDurationMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.walkingDurationMinutes), diff --git a/client/src/components/Tours/planner/useTourPlanner.test.tsx b/client/src/components/Tours/planner/useTourPlanner.test.tsx index 144eec7dd..0621c773c 100644 --- a/client/src/components/Tours/planner/useTourPlanner.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -20,6 +20,8 @@ vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detai vi.mock('../useTourPermissions', () => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ canEdit, canAssign }) })) import { useTourPlanner } from './useTourPlanner' +import { hikingTimeMinutes } from '../hikingTime' +import { analyzeRouteGeometry } from '../../../utils/routeGeometry' import { TourPlannerRail } from './TourPlannerPanels' const routed = { @@ -28,6 +30,9 @@ const routed = { durationSeconds: 1800, } const enriched = [[48, 11, 500], [48.01, 11.02, 550]] as [number, number, number][] +// With the heights in, the walking time is the hiking rule over the enriched line, not the router's. +const enrichedAnalysis = analyzeRouteGeometry(JSON.stringify(enriched))! +const enrichedWalkingSeconds = hikingTimeMinutes(enrichedAnalysis.distanceKm, enrichedAnalysis.gain!, enrichedAnalysis.loss!) * 60 const savedTour = { place_id: 42, name: 'Saved ridge', @@ -299,7 +304,7 @@ describe('useTourPlanner', () => { expect(result.current.canSave).toBe(true) await act(async () => { await result.current.save() }) expect(createTour).toHaveBeenCalledWith(73, expect.objectContaining({ - duration_seconds: routed.durationSeconds, + duration_seconds: enrichedWalkingSeconds, planned_duration_minutes: 95, break_additional_minutes: 40, })) @@ -311,13 +316,15 @@ describe('useTourPlanner', () => { act(() => result.current.setPlannedDurationMinutes(null)) await act(async () => { await result.current.save() }) expect(updateTour).toHaveBeenCalledWith(73, savedTour.place_id, expect.objectContaining({ - duration_seconds: routed.durationSeconds, + duration_seconds: enrichedWalkingSeconds, planned_duration_minutes: null, break_additional_minutes: 40, })) }) it('RS-02: recalculates automatic total when route changes but preserves a manual override', async () => { + // No heights for this line, so the router's time stands and each new route brings its own. + enrichTourElevations.mockResolvedValue(routed.coordinates) routeWalkingTour .mockResolvedValueOnce({ ...routed, durationSeconds: 3600 }) .mockResolvedValueOnce({ ...routed, durationSeconds: 5400 }) diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 4a4f0c5c2..b936dea3c 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -5,6 +5,7 @@ import { randomId } from '../../../utils/randomId' import type { TourBaseLayer } from '../../Map/MapLayerSwitcher' import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry' import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting' +import { hikingTimeMinutes } from '../hikingTime' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' export interface TourPlannerWaypoint { @@ -417,7 +418,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const websiteInvalid = website.trim().length > 0 && !tourWebsiteSchema.safeParse(website).success const plannedDurationInvalid = plannedDurationMinutes !== null && (!Number.isInteger(plannedDurationMinutes) || plannedDurationMinutes < 0 || plannedDurationMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES) - const walkingDurationMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60) + const routeAnalysis = useMemo( + () => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)), + [enrichedGeometry, route], + ) + // The router's time only stands in until the route has its heights; from then on the + // walking time is the signposts' rule over distance, climb and descent. + const walkingDurationMinutes = routeAnalysis?.gain != null && routeAnalysis.loss != null + ? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss) + : durationSeconds === null ? null : Math.round(durationSeconds / 60) const automaticPlannedTotalMinutes = walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0) @@ -603,10 +612,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe }, [interruptRouting]) const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]) - const routeAnalysis = useMemo( - () => analyzeRouteGeometry(JSON.stringify(enrichedGeometry ?? route)), - [enrichedGeometry, route], - ) const readOnlyGpxAnalysis = useMemo( () => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry), [readOnlyGpxTour?.routeGeometry], @@ -713,7 +718,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe sequence, })), max_hiking_difficulty: maxHikingDifficulty, - duration_seconds: durationSeconds, + duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60, planned_duration_minutes: plannedDurationMinutes, break_additional_minutes: breakAdditionalMinutes, } as const @@ -755,7 +760,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setIsSaving(false) } } - }, [breakAdditionalMinutes, canSave, description, durationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website]) + }, [breakAdditionalMinutes, canSave, description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website]) return { canEdit, canAssign, diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index 023acd2a4..98f011bac 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -1,6 +1,6 @@ import type { FerrataBbox } from '@trek/shared'; import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react'; -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import type { DistanceUnit } from '../../../types'; import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'; @@ -44,6 +44,14 @@ export interface TourTerrainMapProps { onControlsHeightChange?: (px: number) => void; /** The "set points" switch in the layer pill, while a tour is being edited. */ placeTool?: MapPlaceTool; + /** The grade legend's width and how far its open bands reach above it, for a card that sits on it; null while there is none. */ + onLegendLayout?: (layout: TerrainLegendLayout | null) => void; +} + +export interface TerrainLegendLayout { + width: number; + /** How far the unfolded bands reach above the legend pill, 0 while they are folded. */ + raisedBy: number; } const SPEEDS = [1, 2, 4] as const; @@ -67,7 +75,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } }); const stats = useMemo(() => terrainStats(samples), [samples]); const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit(); - const legend = terrain.canReplay ? : null; + const legend = terrain.canReplay ? : null; const legendAbove = fit === 'stacked'; useEffect(() => { @@ -270,15 +278,46 @@ function PreloadRing({ share, label }: { share: number; label: string }) { } /** The grade mix as a pill like the dock; it unfolds upwards into the bands. */ -function GradeLegend({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) { +function GradeLegend({ + stats, + unit, + onLayout, +}: { + stats: TerrainStats; + unit: DistanceUnit; + onLayout?: (layout: TerrainLegendLayout | null) => void; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + const panelRef = useRef(null); + // A card over the legend takes its width and stands on its bands when they are open. + useLayoutEffect(() => { + const wrap = wrapRef.current; + if (!wrap || !onLayout) return; + const report = () => { + const panel = panelRef.current; + onLayout({ width: wrap.offsetWidth, raisedBy: open && panel ? panel.offsetHeight + 8 : 0 }); + }; + report(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(report); + observer.observe(wrap); + if (panelRef.current) observer.observe(panelRef.current); + return () => observer.disconnect(); + }, [open, onLayout]); + useEffect(() => () => onLayout?.(null), [onLayout]); const total = stats.totalMeters || 1; const width = (key: GradeBandKey) => `${(stats.bandMeters[key] / total) * 100}%`; return ( -
+
{open && ( -
+
    {GRADE_BANDS.map((band) => (
  • diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index 3423245d1..d115760fa 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from 'react' +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react' +import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Info, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react' import type { Day, DistanceUnit } from '../../types' import type { RouteGeometryAnalysis } from '../../utils/routeGeometry' import { useTranslation } from '../../i18n' @@ -159,6 +159,20 @@ export function TourDayMenu({ days, placeId, onPick, disabled = false, label, va ) } +/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */ +export function InfoDot({ description, label }: { description: string; label: string }) { + const id = useId() + return ( + + + + ) +} + /** A labelled block of a tour panel: the eyebrow, an optional control on its right, then the content. */ export function TourSection({ label, action, children, className = '' }: { label: ReactNode; action?: ReactNode; children: ReactNode; className?: string }) { return ( @@ -197,6 +211,42 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded: ) } +interface TourMetricField { + label: string + /** Said instead of `label` where the field is too narrow for it, rather than cutting it off. */ + shortLabel?: string + value: string + info?: { label: string; description: string } +} + +/** A label that falls back to its short form when the full one does not fit its box. */ +function FitLabel({ full, short }: { full: string; short?: string }) { + const ref = useRef(null) + const [narrow, setNarrow] = useState(false) + useLayoutEffect(() => { + const element = ref.current?.parentElement + if (!element || !short) return + // Measured with the full text in place: it fits when it does not overflow its box. + const check = () => { + const probe = ref.current + if (!probe) return + setNarrow(probe.scrollWidth > element.clientWidth + 0.5) + } + check() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(check) + observer.observe(element) + return () => observer.disconnect() + }, [full, short]) + if (!short) return <>{full} + return ( + <> + {full} + {narrow && {short}} + + ) +} + /** * A tour's numbers as labelled fields, the way a booking card shows its facts: * distance, any extra the caller has (the walking time), then the altitudes. @@ -204,12 +254,12 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded: export function TourMetricFields({ analysis, unit, extra = [], columns }: { analysis: Pick | null unit?: DistanceUnit - extra?: Array<{ label: string; value: string }> + extra?: Array /** A fixed column count for a narrow rail; the inspector lets the fields flow. */ columns?: 2 }) { const { t } = useTranslation() - const fields: Array<{ label: string; value: string }> = [ + const fields: TourMetricField[] = [ { label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' }, ...extra, ] @@ -222,7 +272,10 @@ export function TourMetricFields({ analysis, unit, extra = [], columns }: {
    {fields.map(field => (
    -
    {field.label}
    +
    + + {field.info && } +
    {field.value}
    ))} diff --git a/client/src/components/Tours/toursI18n.test.tsx b/client/src/components/Tours/toursI18n.test.tsx index 9653aedb2..5f3f5f1a0 100644 --- a/client/src/components/Tours/toursI18n.test.tsx +++ b/client/src/components/Tours/toursI18n.test.tsx @@ -93,9 +93,8 @@ describe('Tours English and German copy', () => { })} />) expect(screen.getByRole('heading', { name: newTitle })).toBeInTheDocument() const strings = language === 'de' ? de : en - expect(screen.getByText(toursText(strings, 'tours.planner.restoredShort'))).toBeInTheDocument() expect(screen.getByLabelText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument() - expect(screen.getByText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument() + expect(screen.getByLabelText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument() expect(screen.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument() expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNote')) === true)).toBeInTheDocument() expect(screen.getByLabelText(language === 'de' ? 'Wegpunkt 1' : 'Waypoint 1')).toBeInTheDocument() @@ -149,7 +148,7 @@ describe('Tours English and German copy', () => { assertNoRawToursKeys() } rerender() - expect(screen.getByText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument() + expect(screen.getByLabelText(toursText(de, 'tours.planner.status.saveFailed'))).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.maxDifficulty') })) fireEvent.click(screen.getByRole('button', { name: toursText(de, 'tours.planner.difficulty.t4') })) expect(screen.getByRole('heading', { name: toursText(de, 'tours.planner.difficulty.alpineTitle') })).toBeInTheDocument() diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 292affe7d..dbfd6d610 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -6,6 +6,7 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../componen import { MapUndoHint } from '../../components/Map/MapUndoHint'; import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto'; import LocationSelect from '../../components/Planner/LocationSelect'; +import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap'; import { isRtlLanguage, useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { useTourMapPoints } from './useTourMapPoints'; @@ -58,6 +59,7 @@ export function TourPlannerMapStage({ const [terrain3d, setTerrain3d] = useState(false); // How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock. const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE); + const [terrainLegend, setTerrainLegend] = useState(null); // One via ferrata layer for both modes: it stays on, and keeps its card, across 2D and 3D. // The trip's area for the layer to prefetch: the framed points and the tour's line. const ferrataArea = useMemo( @@ -111,6 +113,7 @@ export function TourPlannerMapStage({ ferrataLayer={ferrata.layer} onViewportChange={ferrata.onViewportChange} onControlsHeightChange={setTerrainControlsPx} + onLegendLayout={setTerrainLegend} placeTool={placeTool} /> @@ -151,7 +154,10 @@ export function TourPlannerMapStage({ onClose={ferrata.close} onOutsidePress={closingPress.arm} insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET} - bottom={bottomRowTop} + // On the flat map nothing sits under the card's corner, so it rests at the edge gap; in 3D it + // stands on the dock row, and on the grade bands while they are open. + bottom={terrain3d ? bottomRowTop + (terrainLegend?.raisedBy ?? 0) : CONTROL_GAP} + width={terrain3d ? terrainLegend?.width : undefined} /> {/* "Point set", with the way back, above the bottom row and between the panels. */} {placedHint.shown && ( @@ -202,6 +208,7 @@ export function TourPlannerMapStage({ ariaLabel={t('tours.planner.mapSearch')} floating showSearchStatus + showSource style={{ width: '100%' }} />
diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 1b172238f..da5955a62 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -69,10 +69,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'إظهار التفاصيل', 'tours.planner.collapseDetails': 'إخفاء التفاصيل', 'tours.planner.safetyInfoLabel': 'حول السلامة', - 'tours.planner.restoredShort': 'مسودة', 'tours.planner.addPoint': 'إضافة نقطة', 'tours.planner.addPointActive': 'انقر على الخريطة', 'tours.planner.emptyHint': 'فعّل «إضافة نقطة» وانقر على الخريطة. من نقطتين فصاعدًا يحسب TREK المسار.', + 'tours.planner.walkingEstimated': 'وقت المشي التقديري', + 'tours.planner.walkingEstimatedShort': 'وقت المشي التقريبي', + 'tours.planner.walkingEstimatedInfoLabel': 'كيف يُقدَّر وقت المشي', + 'tours.planner.walkingEstimatedInfo': 'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.', 'tours.planner.waypoints': 'نقاط المرور', 'tours.planner.waypointLabel': 'نقطة المرور {n}', 'tours.durationMinutes': '{count} د', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 19f5ee87e..a76139d13 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -67,10 +67,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Təfərrüatları göstər', 'tours.planner.collapseDetails': 'Təfərrüatları gizlət', 'tours.planner.safetyInfoLabel': 'Təhlükəsizlik haqqında', - 'tours.planner.restoredShort': 'Qaralama', 'tours.planner.addPoint': 'Nöqtə əlavə et', 'tours.planner.addPointActive': 'Xəritəyə klikləyin', 'tours.planner.emptyHint': '“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.', + 'tours.planner.walkingEstimated': 'Təxmini piyada vaxtı', + 'tours.planner.walkingEstimatedShort': 'Təxm. piyada vaxtı', + 'tours.planner.walkingEstimatedInfoLabel': 'Piyada vaxtı necə təxmin edilir', + 'tours.planner.walkingEstimatedInfo': 'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.', 'tours.planner.waypoints': 'Yol nöqtələri', 'tours.planner.waypointLabel': 'Yol nöqtəsi {n}', 'tours.durationMinutes': '{count} dəq.', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 79808b751..49d078030 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -68,10 +68,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Mostrar detalhes', 'tours.planner.collapseDetails': 'Ocultar detalhes', 'tours.planner.safetyInfoLabel': 'Sobre segurança', - 'tours.planner.restoredShort': 'Rascunho', 'tours.planner.addPoint': 'Adicionar ponto', 'tours.planner.addPointActive': 'Clique no mapa', 'tours.planner.emptyHint': 'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.', + 'tours.planner.walkingEstimated': 'Tempo de caminhada estimado', + 'tours.planner.walkingEstimatedShort': 'Tempo est.', + 'tours.planner.walkingEstimatedInfoLabel': 'Como o tempo de caminhada é estimado', + 'tours.planner.walkingEstimatedInfo': 'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.', 'tours.planner.waypoints': 'Pontos de passagem', 'tours.planner.waypointLabel': 'Ponto de passagem {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index e992de00f..1135fc795 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -67,10 +67,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Mostra els detalls', 'tours.planner.collapseDetails': 'Amaga els detalls', 'tours.planner.safetyInfoLabel': 'Sobre la seguretat', - 'tours.planner.restoredShort': 'Esborrany', 'tours.planner.addPoint': 'Afegeix un punt', 'tours.planner.addPointActive': 'Fes clic al mapa', 'tours.planner.emptyHint': 'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.', + 'tours.planner.walkingEstimated': 'Temps a peu estimat', + 'tours.planner.walkingEstimatedShort': 'Temps est.', + 'tours.planner.walkingEstimatedInfoLabel': 'Com s’estima el temps a peu', + 'tours.planner.walkingEstimatedInfo': 'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.', 'tours.planner.waypoints': 'Punts de pas', 'tours.planner.waypointLabel': 'Punt de pas {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 54799d5ea..eb01448d7 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Zobrazit podrobnosti', 'tours.planner.collapseDetails': 'Skrýt podrobnosti', 'tours.planner.safetyInfoLabel': 'O bezpečnosti', - 'tours.planner.restoredShort': 'Koncept', 'tours.planner.addPoint': 'Přidat bod', 'tours.planner.addPointActive': 'Klikněte do mapy', 'tours.planner.emptyHint': 'Zapněte „Přidat bod“ a klikněte do mapy. Od dvou bodů TREK spočítá trasu.', + 'tours.planner.walkingEstimated': 'Odhadovaný čas chůze', + 'tours.planner.walkingEstimatedShort': 'Odhad. čas chůze', + 'tours.planner.walkingEstimatedInfoLabel': 'Jak se čas chůze odhaduje', + 'tours.planner.walkingEstimatedInfo': 'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.', 'tours.planner.waypoints': 'Průjezdní body', 'tours.planner.waypointLabel': 'Bod {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 1ff5a0fa6..b28fb0a88 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -67,10 +67,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Details einblenden', 'tours.planner.collapseDetails': 'Details ausblenden', 'tours.planner.safetyInfoLabel': 'Hinweis zur Sicherheit', - 'tours.planner.restoredShort': 'Entwurf', 'tours.planner.addPoint': 'Punkt hinzufügen', 'tours.planner.addPointActive': 'Auf Karte klicken', 'tours.planner.emptyHint': 'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.', + 'tours.planner.walkingEstimated': 'Geschätzte Gehzeit', + 'tours.planner.walkingEstimatedShort': 'Gesch. Gehzeit', + 'tours.planner.walkingEstimatedInfoLabel': 'Wie die Gehzeit geschätzt wird', + 'tours.planner.walkingEstimatedInfo': 'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.', 'tours.planner.waypoints': 'Wegpunkte', 'tours.planner.waypointLabel': 'Wegpunkt {n}', 'tours.durationMinutes': '{count} Min.', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index a8f556ead..fd08b62af 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Show details', 'tours.planner.collapseDetails': 'Hide details', 'tours.planner.safetyInfoLabel': 'About safety', - 'tours.planner.restoredShort': 'Draft', 'tours.planner.addPoint': 'Add point', 'tours.planner.addPointActive': 'Click the map', 'tours.planner.emptyHint': 'Turn on Add point and click the map. From two points on, TREK works out the route.', + 'tours.planner.walkingEstimated': 'Estimated walking time', + 'tours.planner.walkingEstimatedShort': 'Est. walking time', + 'tours.planner.walkingEstimatedInfoLabel': 'How the walking time is estimated', + 'tours.planner.walkingEstimatedInfo': 'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.', 'tours.planner.waypoints': 'Waypoints', 'tours.planner.waypointLabel': 'Waypoint {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 6bbe336d0..b7b5b9930 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -67,10 +67,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Mostrar detalles', 'tours.planner.collapseDetails': 'Ocultar detalles', 'tours.planner.safetyInfoLabel': 'Sobre la seguridad', - 'tours.planner.restoredShort': 'Borrador', 'tours.planner.addPoint': 'Añadir punto', 'tours.planner.addPointActive': 'Haz clic en el mapa', 'tours.planner.emptyHint': 'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.', + 'tours.planner.walkingEstimated': 'Tiempo a pie estimado', + 'tours.planner.walkingEstimatedShort': 'Tiempo est.', + 'tours.planner.walkingEstimatedInfoLabel': 'Cómo se estima el tiempo a pie', + 'tours.planner.walkingEstimatedInfo': 'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.', 'tours.planner.waypoints': 'Puntos de paso', 'tours.planner.waypointLabel': 'Punto de paso {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 3e82b57ca..73b72da33 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Näita üksikasju', 'tours.planner.collapseDetails': 'Peida üksikasjad', 'tours.planner.safetyInfoLabel': 'Ohutusest', - 'tours.planner.restoredShort': 'Mustand', 'tours.planner.addPoint': 'Lisa punkt', 'tours.planner.addPointActive': 'Klõpsa kaardil', 'tours.planner.emptyHint': 'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.', + 'tours.planner.walkingEstimated': 'Hinnanguline kõndimisaeg', + 'tours.planner.walkingEstimatedShort': 'Hinn. kõndimisaeg', + 'tours.planner.walkingEstimatedInfoLabel': 'Kuidas kõndimisaega hinnatakse', + 'tours.planner.walkingEstimatedInfo': 'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.', 'tours.planner.waypoints': 'Teekonnapunktid', 'tours.planner.waypointLabel': 'Teekonnapunkt {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 4e1d1eda5..daef4c40e 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -69,10 +69,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Afficher les détails', 'tours.planner.collapseDetails': 'Masquer les détails', 'tours.planner.safetyInfoLabel': 'À propos de la sécurité', - 'tours.planner.restoredShort': 'Brouillon', 'tours.planner.addPoint': 'Ajouter un point', 'tours.planner.addPointActive': 'Cliquez sur la carte', 'tours.planner.emptyHint': 'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.', + 'tours.planner.walkingEstimated': 'Temps de marche estimé', + 'tours.planner.walkingEstimatedShort': 'Temps estimé', + 'tours.planner.walkingEstimatedInfoLabel': 'Comment le temps de marche est estimé', + 'tours.planner.walkingEstimatedInfo': 'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.', 'tours.planner.waypoints': 'Points de passage', 'tours.planner.waypointLabel': 'Point de passage {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index b011888bb..32ddbd82c 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -69,10 +69,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Εμφάνιση λεπτομερειών', 'tours.planner.collapseDetails': 'Απόκρυψη λεπτομερειών', 'tours.planner.safetyInfoLabel': 'Σχετικά με την ασφάλεια', - 'tours.planner.restoredShort': 'Πρόχειρο', 'tours.planner.addPoint': 'Προσθήκη σημείου', 'tours.planner.addPointActive': 'Κλικ στον χάρτη', 'tours.planner.emptyHint': 'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.', + 'tours.planner.walkingEstimated': 'Εκτιμώμενος χρόνος πεζοπορίας', + 'tours.planner.walkingEstimatedShort': 'Εκτ. χρόνος', + 'tours.planner.walkingEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος πεζοπορίας', + 'tours.planner.walkingEstimatedInfo': 'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.', 'tours.planner.waypoints': 'Σημεία διέλευσης', 'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}', 'tours.durationMinutes': '{count} λεπτά', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 2bc990f8c..2f9ea7f3e 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Részletek megjelenítése', 'tours.planner.collapseDetails': 'Részletek elrejtése', 'tours.planner.safetyInfoLabel': 'A biztonságról', - 'tours.planner.restoredShort': 'Piszkozat', 'tours.planner.addPoint': 'Pont hozzáadása', 'tours.planner.addPointActive': 'Kattints a térképre', 'tours.planner.emptyHint': 'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.', + 'tours.planner.walkingEstimated': 'Becsült gyaloglási idő', + 'tours.planner.walkingEstimatedShort': 'Becs. idő', + 'tours.planner.walkingEstimatedInfoLabel': 'Hogyan becsüljük a gyaloglási időt', + 'tours.planner.walkingEstimatedInfo': 'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.', 'tours.planner.waypoints': 'Útvonalpontok', 'tours.planner.waypointLabel': '{n}. útvonalpont', 'tours.durationMinutes': '{count} perc', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index f9d8d8c26..521b58187 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Tampilkan detail', 'tours.planner.collapseDetails': 'Sembunyikan detail', 'tours.planner.safetyInfoLabel': 'Tentang keselamatan', - 'tours.planner.restoredShort': 'Draf', 'tours.planner.addPoint': 'Tambah titik', 'tours.planner.addPointActive': 'Klik peta', 'tours.planner.emptyHint': 'Aktifkan Tambah titik lalu klik peta. Mulai dari dua titik, TREK menghitung rutenya.', + 'tours.planner.walkingEstimated': 'Perkiraan waktu jalan', + 'tours.planner.walkingEstimatedShort': 'Perk. waktu jalan', + 'tours.planner.walkingEstimatedInfoLabel': 'Cara waktu jalan diperkirakan', + 'tours.planner.walkingEstimatedInfo': 'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.', 'tours.planner.waypoints': 'Titik lintasan', 'tours.planner.waypointLabel': 'Titik lintasan {n}', 'tours.durationMinutes': '{count} menit', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index f7166c58c..3b75dc510 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -68,10 +68,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Mostra dettagli', 'tours.planner.collapseDetails': 'Nascondi dettagli', 'tours.planner.safetyInfoLabel': 'Sulla sicurezza', - 'tours.planner.restoredShort': 'Bozza', 'tours.planner.addPoint': 'Aggiungi punto', 'tours.planner.addPointActive': 'Clicca sulla mappa', 'tours.planner.emptyHint': 'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.', + 'tours.planner.walkingEstimated': 'Tempo di cammino stimato', + 'tours.planner.walkingEstimatedShort': 'Tempo stim.', + 'tours.planner.walkingEstimatedInfoLabel': 'Come viene stimato il tempo di cammino', + 'tours.planner.walkingEstimatedInfo': 'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.', 'tours.planner.waypoints': 'Punti di passaggio', 'tours.planner.waypointLabel': 'Punto di passaggio {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 83b9c49dd..8d326e49c 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -64,10 +64,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': '詳細を表示', 'tours.planner.collapseDetails': '詳細を隠す', 'tours.planner.safetyInfoLabel': '安全について', - 'tours.planner.restoredShort': '下書き', 'tours.planner.addPoint': '地点を追加', 'tours.planner.addPointActive': '地図をクリック', 'tours.planner.emptyHint': '「地点を追加」をオンにして地図をクリックします。2 地点からTREKがルートを計算します。', + 'tours.planner.walkingEstimated': '推定歩行時間', + 'tours.planner.walkingEstimatedShort': '推定時間', + 'tours.planner.walkingEstimatedInfoLabel': '歩行時間の推定方法', + 'tours.planner.walkingEstimatedInfo': '一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。', 'tours.planner.waypoints': 'ルート上の地点', 'tours.planner.waypointLabel': '地点{n}', 'tours.durationMinutes': '{count}分', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index a8aa32ecb..fee78855a 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -65,10 +65,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': '세부 정보 표시', 'tours.planner.collapseDetails': '세부 정보 숨기기', 'tours.planner.safetyInfoLabel': '안전 안내', - 'tours.planner.restoredShort': '초안', 'tours.planner.addPoint': '지점 추가', 'tours.planner.addPointActive': '지도를 클릭', 'tours.planner.emptyHint': '“지점 추가”를 켜고 지도를 클릭하세요. 지점이 두 개가 되면 TREK이 경로를 계산합니다.', + 'tours.planner.walkingEstimated': '예상 보행 시간', + 'tours.planner.walkingEstimatedShort': '예상 시간', + 'tours.planner.walkingEstimatedInfoLabel': '보행 시간을 추정하는 방법', + 'tours.planner.walkingEstimatedInfo': '일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.', 'tours.planner.waypoints': '경로 지점', 'tours.planner.waypointLabel': '지점 {n}', 'tours.durationMinutes': '{count}분', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 2c6f25caf..f58d6f017 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -67,10 +67,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Details tonen', 'tours.planner.collapseDetails': 'Details verbergen', 'tours.planner.safetyInfoLabel': 'Over veiligheid', - 'tours.planner.restoredShort': 'Concept', 'tours.planner.addPoint': 'Punt toevoegen', 'tours.planner.addPointActive': 'Klik op de kaart', 'tours.planner.emptyHint': 'Zet „Punt toevoegen” aan en klik op de kaart. Vanaf twee punten berekent TREK de route.', + 'tours.planner.walkingEstimated': 'Geschatte looptijd', + 'tours.planner.walkingEstimatedShort': 'Geschatte tijd', + 'tours.planner.walkingEstimatedInfoLabel': 'Hoe de looptijd wordt geschat', + 'tours.planner.walkingEstimatedInfo': 'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.', 'tours.planner.waypoints': 'Routepunten', 'tours.planner.waypointLabel': 'Routepunt {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 171561a01..42e10dc0a 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -69,10 +69,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Pokaż szczegóły', 'tours.planner.collapseDetails': 'Ukryj szczegóły', 'tours.planner.safetyInfoLabel': 'O bezpieczeństwie', - 'tours.planner.restoredShort': 'Szkic', 'tours.planner.addPoint': 'Dodaj punkt', 'tours.planner.addPointActive': 'Kliknij mapę', 'tours.planner.emptyHint': 'Włącz „Dodaj punkt” i kliknij mapę. Od dwóch punktów TREK wyznacza trasę.', + 'tours.planner.walkingEstimated': 'Szacowany czas marszu', + 'tours.planner.walkingEstimatedShort': 'Szac. czas marszu', + 'tours.planner.walkingEstimatedInfoLabel': 'Jak szacowany jest czas marszu', + 'tours.planner.walkingEstimatedInfo': 'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.', 'tours.planner.waypoints': 'Punkty trasy', 'tours.planner.waypointLabel': 'Punkt trasy {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 55ed76784..a9675e260 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -69,10 +69,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Показать подробности', 'tours.planner.collapseDetails': 'Скрыть подробности', 'tours.planner.safetyInfoLabel': 'О безопасности', - 'tours.planner.restoredShort': 'Черновик', 'tours.planner.addPoint': 'Добавить точку', 'tours.planner.addPointActive': 'Нажмите на карту', 'tours.planner.emptyHint': 'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.', + 'tours.planner.walkingEstimated': 'Примерное время в пути', + 'tours.planner.walkingEstimatedShort': 'Прим. время', + 'tours.planner.walkingEstimatedInfoLabel': 'Как оценивается время в пути', + 'tours.planner.walkingEstimatedInfo': 'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.', 'tours.planner.waypoints': 'Путевые точки', 'tours.planner.waypointLabel': 'Путевая точка {n}', 'tours.durationMinutes': '{count} мин', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index c69ef0875..b8a49d40b 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Zobraziť podrobnosti', 'tours.planner.collapseDetails': 'Skryť podrobnosti', 'tours.planner.safetyInfoLabel': 'O bezpečnosti', - 'tours.planner.restoredShort': 'Koncept', 'tours.planner.addPoint': 'Pridať bod', 'tours.planner.addPointActive': 'Kliknite do mapy', 'tours.planner.emptyHint': 'Zapnite „Pridať bod“ a kliknite do mapy. Od dvoch bodov TREK vypočíta trasu.', + 'tours.planner.walkingEstimated': 'Odhadovaný čas chôdze', + 'tours.planner.walkingEstimatedShort': 'Odhad. čas chôdze', + 'tours.planner.walkingEstimatedInfoLabel': 'Ako sa čas chôdze odhaduje', + 'tours.planner.walkingEstimatedInfo': 'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.', 'tours.planner.waypoints': 'Body trasy', 'tours.planner.waypointLabel': 'Bod trasy {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 6c1dd46b9..9d15dcd4a 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Visa detaljer', 'tours.planner.collapseDetails': 'Dölj detaljer', 'tours.planner.safetyInfoLabel': 'Om säkerhet', - 'tours.planner.restoredShort': 'Utkast', 'tours.planner.addPoint': 'Lägg till punkt', 'tours.planner.addPointActive': 'Klicka på kartan', 'tours.planner.emptyHint': 'Slå på ”Lägg till punkt” och klicka på kartan. Från två punkter räknar TREK ut rutten.', + 'tours.planner.walkingEstimated': 'Uppskattad gångtid', + 'tours.planner.walkingEstimatedShort': 'Uppsk. gångtid', + 'tours.planner.walkingEstimatedInfoLabel': 'Hur gångtiden uppskattas', + 'tours.planner.walkingEstimatedInfo': 'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.', 'tours.planner.waypoints': 'Ruttpunkter', 'tours.planner.waypointLabel': 'Ruttpunkt {n}', 'tours.durationMinutes': '{count} min', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 89c95f673..11f18d102 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -65,10 +65,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'แสดงรายละเอียด', 'tours.planner.collapseDetails': 'ซ่อนรายละเอียด', 'tours.planner.safetyInfoLabel': 'เกี่ยวกับความปลอดภัย', - 'tours.planner.restoredShort': 'ฉบับร่าง', 'tours.planner.addPoint': 'เพิ่มจุด', 'tours.planner.addPointActive': 'คลิกบนแผนที่', 'tours.planner.emptyHint': 'เปิด “เพิ่มจุด” แล้วคลิกบนแผนที่ เมื่อมีสองจุดขึ้นไป TREK จะคำนวณเส้นทาง', + 'tours.planner.walkingEstimated': 'เวลาเดินโดยประมาณ', + 'tours.planner.walkingEstimatedShort': 'เวลาโดยประมาณ', + 'tours.planner.walkingEstimatedInfoLabel': 'วิธีประมาณเวลาเดิน', + 'tours.planner.walkingEstimatedInfo': 'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก', 'tours.planner.waypoints': 'จุดแวะ', 'tours.planner.waypointLabel': 'จุดแวะ {n}', 'tours.durationMinutes': '{count} นาที', diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 62ae66a14..43eff280a 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Ayrıntıları göster', 'tours.planner.collapseDetails': 'Ayrıntıları gizle', 'tours.planner.safetyInfoLabel': 'Güvenlik hakkında', - 'tours.planner.restoredShort': 'Taslak', 'tours.planner.addPoint': 'Nokta ekle', 'tours.planner.addPointActive': 'Haritaya tıklayın', 'tours.planner.emptyHint': '“Nokta ekle”yi açın ve haritaya tıklayın. İki noktadan itibaren TREK rotayı hesaplar.', + 'tours.planner.walkingEstimated': 'Tahmini yürüyüş süresi', + 'tours.planner.walkingEstimatedShort': 'Tahm. süre', + 'tours.planner.walkingEstimatedInfoLabel': 'Yürüyüş süresi nasıl tahmin edilir', + 'tours.planner.walkingEstimatedInfo': 'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.', 'tours.planner.waypoints': 'Rota noktaları', 'tours.planner.waypointLabel': 'Rota noktası {n}', 'tours.durationMinutes': '{count} dk', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index cd44bf53f..8b754795b 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -68,10 +68,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Показати подробиці', 'tours.planner.collapseDetails': 'Сховати подробиці', 'tours.planner.safetyInfoLabel': 'Про безпеку', - 'tours.planner.restoredShort': 'Чернетка', 'tours.planner.addPoint': 'Додати точку', 'tours.planner.addPointActive': 'Натисніть на карту', 'tours.planner.emptyHint': 'Увімкніть «Додати точку» і натисніть на карту. Від двох точок TREK розрахує маршрут.', + 'tours.planner.walkingEstimated': 'Орієнтовний час у дорозі', + 'tours.planner.walkingEstimatedShort': 'Орієнт. час', + 'tours.planner.walkingEstimatedInfoLabel': 'Як оцінюється час у дорозі', + 'tours.planner.walkingEstimatedInfo': 'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.', 'tours.planner.waypoints': 'Маршрутні точки', 'tours.planner.waypointLabel': 'Маршрутна точка {n}', 'tours.durationMinutes': '{count} хв', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index fc0fa6ec9..745e10abc 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -66,10 +66,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': 'Hiện chi tiết', 'tours.planner.collapseDetails': 'Ẩn chi tiết', 'tours.planner.safetyInfoLabel': 'Về an toàn', - 'tours.planner.restoredShort': 'Bản nháp', 'tours.planner.addPoint': 'Thêm điểm', 'tours.planner.addPointActive': 'Nhấp vào bản đồ', 'tours.planner.emptyHint': 'Bật “Thêm điểm” rồi nhấp vào bản đồ. Từ hai điểm trở lên, TREK sẽ tính tuyến đường.', + 'tours.planner.walkingEstimated': 'Thời gian đi bộ ước tính', + 'tours.planner.walkingEstimatedShort': 'TG ước tính', + 'tours.planner.walkingEstimatedInfoLabel': 'Cách ước tính thời gian đi bộ', + 'tours.planner.walkingEstimatedInfo': 'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.', 'tours.planner.waypoints': 'Các điểm trên tuyến', 'tours.planner.waypointLabel': 'Điểm {n}', 'tours.durationMinutes': '{count} phút', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 464342424..f9710e0c2 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -63,10 +63,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': '顯示詳細資訊', 'tours.planner.collapseDetails': '隱藏詳細資訊', 'tours.planner.safetyInfoLabel': '關於安全', - 'tours.planner.restoredShort': '草稿', 'tours.planner.addPoint': '新增地點', 'tours.planner.addPointActive': '點擊地圖', 'tours.planner.emptyHint': '開啟「新增地點」,然後點擊地圖。有兩個地點後,TREK 會計算路線。', + 'tours.planner.walkingEstimated': '預估步行時間', + 'tours.planner.walkingEstimatedShort': '預估時間', + 'tours.planner.walkingEstimatedInfoLabel': '步行時間如何估算', + 'tours.planner.walkingEstimatedInfo': '依常用的健行時間規則(DIN 33466),根據距離、上坡與下坡計算,不含休息。', 'tours.planner.waypoints': '路線點', 'tours.planner.waypointLabel': '路線點 {n}', 'tours.durationMinutes': '{count} 分鐘', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index bbb990972..53b52f000 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -63,10 +63,13 @@ const tours: TranslationStrings = { 'tours.planner.expandDetails': '显示详情', 'tours.planner.collapseDetails': '隐藏详情', 'tours.planner.safetyInfoLabel': '关于安全', - 'tours.planner.restoredShort': '草稿', 'tours.planner.addPoint': '添加地点', 'tours.planner.addPointActive': '点击地图', 'tours.planner.emptyHint': '打开“添加地点”,然后点击地图。有两个地点后,TREK 会计算路线。', + 'tours.planner.walkingEstimated': '预计步行时间', + 'tours.planner.walkingEstimatedShort': '预计时间', + 'tours.planner.walkingEstimatedInfoLabel': '步行时间如何估算', + 'tours.planner.walkingEstimatedInfo': '按常用的徒步时间规则(DIN 33466),根据距离、上坡和下坡计算,不含休息。', 'tours.planner.waypoints': '路线点', 'tours.planner.waypointLabel': '路线点 {n}', 'tours.durationMinutes': '{count} 分钟',