fix(tours): read a run pace the way runners write it, and keep it

A pace typed as "5.30" or "5,45" is now read as 5:30 and 5:45, the way
it is written in much of Europe, instead of quietly becoming 5:18 and
5:27. One digit after the point stays a tenth of a minute ("5,5" is
5:30); anything else is marked as no pace.

The pace field no longer flashes the out-of-bounds error while a pace
is still being typed: the first digit of "12:30" reads as 1:00, so a
pace outside the bounds now reaches the planner only on leaving the
field or pressing Enter, like text that is no pace already did.

The pace unit is translated ("мин/км", "分/km" and so on) in the field,
the sum the running time is and the pace pills, so what is shown
matches what assistive tech reads out.

A run planned over in place again keeps the pace it holds, so the
runner need not type it again before Save.
This commit is contained in:
Maurice
2026-10-20 12:58:19 +02:00
parent c83eb42730
commit 1d9b9bf838
40 changed files with 164 additions and 41 deletions
@@ -198,7 +198,7 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
const total = plannedTotalText(times, t);
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
const pace = runPaceText(tour);
const pace = runPaceText(tour, t);
const timeParts = [
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
// RUN-01: the pace the running time is planned at.
@@ -269,7 +269,7 @@ export default function TourDayRowFacts({
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
);
const pace = runPaceText(tour);
const pace = runPaceText(tour, t);
if (pace) add(Gauge, pace, t('tours.run.pace'), 'plan-tour-run-pace');
if (times.breakMinutes != null && times.breakMinutes > 0)
add(
@@ -187,10 +187,34 @@ describe('useGpxConversion', () => {
result.current.adopt('new');
});
const draft = vi.mocked(controller.startDerivedTour).mock.calls[0]?.[0];
expect(draft).toMatchObject({ tourType: 'run', runProfile: 'running' });
expect(draft).toMatchObject({ tourType: 'run', runProfile: 'running', runPaceSecondsPerKm: null });
expect(draft).not.toHaveProperty('paddle');
});
it('RUN-01: a run planned over in place again keeps the pace it holds', async () => {
const run = {
...imported,
tour_type: 'run' as const,
run_profile: 'running' as const,
run_pace_seconds_per_km: 330,
max_hiking_difficulty: null,
};
const controller = planner({
mode: { type: 'view-gpx', placeId: run.place_id, tour: run },
readOnlyGpxTour: { tour: run, routeGeometry: null },
} as Partial<TourPlannerController>);
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
act(() => {
result.current.adopt('replace');
});
expect(controller.startDerivedTour).toHaveBeenCalledWith(
expect.objectContaining({ inPlace: true, tourType: 'run', runProfile: 'running', runPaceSecondsPerKm: 330 })
);
});
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
@@ -44,7 +44,8 @@ export function useGpxConversion(planner: TourPlannerController) {
const paddle = tour?.tour_type === 'kayak';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
// RUN-01: a run import is planned as a run for its profile; its pace is the runner's to enter.
// RUN-01: a run import is planned as a run for its profile, at the pace it holds; a fresh
// import holds none, so its pace is the runner's to enter.
const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null;
useEffect(() => () => abort.current?.abort(), []);
@@ -111,7 +112,9 @@ export function useGpxConversion(planner: TourPlannerController) {
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
...(runProfile ? { tourType: 'run' as const, runProfile } : {}),
...(runProfile
? { tourType: 'run' as const, runProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null }
: {}),
});
if (started) setState(IDLE);
return started;
@@ -14,9 +14,9 @@ vi.mock('./tourRouting', () => ({ routeWalkingTour, routeRun, enrichTourElevatio
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import { useTourPlanner } from './useTourPlanner';
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
// FE-TOUR-RUN-001 to FE-TOUR-RUN-007
// FE-TOUR-RUN-001 to FE-TOUR-RUN-008
const routed = {
coordinates: [
@@ -211,4 +211,35 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => {
expect(result.current.tourType).toBe('run');
expect(result.current.runProfile).toBe('running');
});
it('FE-TOUR-RUN-008: a run planned over keeps the pace it holds, and a draft without one starts with none', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 78 }));
const draft: DerivedTourDraft = {
name: 'River loop',
difficulty: 1,
points: [
{ lat: 48, lng: 11 },
{ lat: 48.01, lng: 11.02 },
],
sourcePlaceId: 91,
inPlace: true,
revision: 3,
tourType: 'run',
runProfile: 'running',
};
act(() => {
result.current.runPace.load(400);
result.current.startDerivedTour({ ...draft, runPaceSecondsPerKm: 330 });
});
await settle();
expect(result.current.tourType).toBe('run');
expect(result.current.runPace.paceSecondsPerKm).toBe(330);
expect(result.current.runPaceMissing).toBe(false);
expect(result.current.canSave).toBe(true);
act(() => {
result.current.startDerivedTour(draft);
});
expect(result.current.runPace.paceSecondsPerKm).toBeNull();
expect(result.current.runPaceMissing).toBe(true);
});
});
@@ -43,6 +43,8 @@ export interface DerivedTourDraft {
paddle?: PaddleFieldValues
bikeProfile?: BikeProfile
runProfile?: RunProfile
/** RUN-01: a run planned over keeps the pace it was planned at, so the runner need not type it again. */
runPaceSecondsPerKm?: number | null
/** CW-07: where the points came from, said in the planner until the draft is saved. */
source?: DraftSource
}
@@ -639,6 +641,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
runProfileRef.current = draft.runProfile
setRunProfileState(draft.runProfile)
}
loadRunPace(draft.runPaceSecondsPerKm ?? null)
setNameState(draft.name)
setDescriptionState(draft.description ?? '')
setWebsiteState(draft.website ?? '')
@@ -658,7 +661,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setHasUnsavedChanges(true)
return true
},
[adoptRevision, loadPaddle, returnToNeutral])
[adoptRevision, loadPaddle, loadRunPace, returnToNeutral])
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
const toggleElevationProfile = useCallback(() => {
@@ -55,7 +55,7 @@ describe('RunFields (RUN-01)', () => {
render(<Stateful />);
expect(paceField()).toHaveValue('');
expect(paceField()).toHaveAttribute('placeholder', '0:00');
expect(screen.getByText('min/km')).toBeInTheDocument();
expect(screen.getByText('tours.run.paceUnit')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('tours.run.paceNeeded');
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription(
@@ -70,7 +70,7 @@ describe('RunFields (RUN-01)', () => {
expect(screen.getByTestId('pace')).toHaveTextContent('360');
const sum = screen.getByTestId('tour-run-time-sum');
expect(sum).toHaveTextContent('8 km');
expect(sum).toHaveTextContent('6:00 /km');
expect(sum).toHaveTextContent('6:00 tours.run.paceUnit');
expect(sum).toHaveTextContent('48 min');
expect(sum).toHaveAttribute('aria-label', 'tours.run.timeSum');
expect(screen.queryByRole('status')).toBeNull();
@@ -99,11 +99,23 @@ describe('RunFields (RUN-01)', () => {
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-TOUR-RUN-FIELDS-005: a pace out of bounds is marked, and clearing the field leaves no pace', () => {
it('FE-TOUR-RUN-FIELDS-005: a pace out of bounds is marked on leaving the field, not while typed, and clearing it leaves no pace', () => {
render(<Stateful />);
fireEvent.focus(paceField());
// The first digit of "12:30" reads as 1:00, under the fastest pace: no error while typing on.
fireEvent.change(paceField(), { target: { value: '1' } });
expect(screen.getByTestId('pace')).toHaveTextContent('none');
expect(screen.queryByRole('alert')).toBeNull();
fireEvent.change(paceField(), { target: { value: '12:30' } });
expect(screen.getByTestId('pace')).toHaveTextContent('750');
fireEvent.change(paceField(), { target: { value: '1:30' } });
expect(screen.getByTestId('pace')).toHaveTextContent('750');
expect(screen.queryByRole('alert')).toBeNull();
fireEvent.blur(paceField());
expect(screen.getByTestId('pace')).toHaveTextContent('90');
expect(paceField()).toHaveValue('1:30');
expect(screen.getByRole('alert')).toHaveTextContent('tours.run.paceInvalid');
fireEvent.focus(paceField());
fireEvent.change(paceField(), { target: { value: '' } });
fireEvent.keyDown(paceField(), { key: 'Enter' });
expect(screen.getByTestId('pace')).toHaveTextContent('none');
+18 -10
View File
@@ -14,7 +14,7 @@ import { FigureBadge } from '../paddle/FigureBadge';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { InfoDot, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH } from './runPace';
import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid } from './runPace';
const PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity };
@@ -55,12 +55,15 @@ type RunPlanner = Pick<
> & { walkingDurationMinutes: number | null };
/**
* The pace as min:s per km. A readable pace reaches the planner while it is
* typed; text that is no pace reaches it as NaN on leaving the field, so the
* planner marks it and holds Save back, and the text stays for the runner to
* fix. The field never offers a pace of its own: the placeholder is the format.
* The pace as min:s per km. A pace a run can be saved with reaches the planner
* while it is typed. Anything else waits for leaving the field (or Enter), so
* the first digit of "12:30" flashes no error: then a pace out of bounds
* reaches the planner as it is and text that is no pace as NaN, the planner
* marks either and holds Save back, and the text stays for the runner to fix.
* The field never offers a pace of its own: the placeholder is the format.
*/
function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolean }) {
const { t } = useTranslation();
const [draft, setDraft] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const { paceSecondsPerKm: pace, setPace } = planner.runPace;
@@ -86,12 +89,14 @@ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolea
onChange={(event) => {
setDraft(event.target.value);
const parsed = parseRunPace(event.target.value);
if (parsed === null || !Number.isNaN(parsed)) setPace(parsed);
if (!runPaceInvalid(parsed)) setPace(parsed);
}}
onBlur={() => {
setFocused(false);
if (draft !== null && Number.isNaN(parseRunPace(draft))) setPace(Number.NaN);
else setDraft(null);
if (draft === null) return;
const parsed = parseRunPace(draft);
setPace(parsed);
if (!Number.isNaN(parsed)) setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
@@ -105,7 +110,7 @@ function RunPaceInput({ planner, locked }: { planner: RunPlanner; locked: boolea
className="pointer-events-none absolute inset-y-0 end-3 flex items-center text-content-faint"
style={fs(12, 'body')}
>
min/km
{t('tours.run.paceUnit')}
</span>
</div>
);
@@ -137,7 +142,10 @@ function RunTimeSum({ planner }: { planner: RunPlanner }) {
<span aria-hidden="true" className={operator} style={fs(12)}>
×
</span>
<FigureBadge icon={<Gauge size={11} strokeWidth={2} aria-hidden="true" />} value={`${formatRunPace(pace)} /km`} />
<FigureBadge
icon={<Gauge size={11} strokeWidth={2} aria-hidden="true" />}
value={`${formatRunPace(pace)} ${t('tours.run.paceUnit')}`}
/>
<span aria-hidden="true" className={operator} style={fs(12)}>
=
</span>
@@ -19,7 +19,7 @@ export function RunHeadPills({
}) {
const { t } = useTranslation();
if (tour.tour_type !== 'run') return null;
const pace = runPaceText(tour);
const pace = runPaceText(tour, t);
return (
<>
{tour.run_profile && (
@@ -35,10 +35,12 @@ const run: TourListItem = {
describe('a run wherever a tour shows (RUN-01)', () => {
it('FE-TOUR-RUN-MARK-001: says a run pace as min:s per km, and nothing for a run without one or another type', () => {
expect(runPaceText(run)).toBe('6:00 min/km');
expect(runPaceText({ ...run, run_pace_seconds_per_km: null })).toBeNull();
expect(runPaceText({ ...run, run_pace_seconds_per_km: 5 })).toBeNull();
expect(runPaceText({ ...run, tour_type: 'hike' })).toBeNull();
const t = (key: string) => (key === 'tours.run.paceUnit' ? 'мин/км' : key);
// The unit is the user's language's, as the sum the time is reads it.
expect(runPaceText(run, t)).toBe('6:00 мин/км');
expect(runPaceText({ ...run, run_pace_seconds_per_km: null }, t)).toBeNull();
expect(runPaceText({ ...run, run_pace_seconds_per_km: 5 }, t)).toBeNull();
expect(runPaceText({ ...run, tour_type: 'hike' }, t)).toBeNull();
});
it('FE-TOUR-RUN-MARK-002: the time is named as planned, and the run goes by its own icon and maps', () => {
@@ -4,18 +4,22 @@ import { formatRunPace, parseRunPace, runPaceInvalid } from './runPace';
// FE-TOUR-RUN-PACE-001 to FE-TOUR-RUN-PACE-003
describe('run pace (RUN-01)', () => {
it('FE-TOUR-RUN-PACE-001: reads min:s, whole minutes and minutes with a part, and nothing as no pace', () => {
it('FE-TOUR-RUN-PACE-001: reads min:s, whole minutes, minutes and a tenth, two digits after a point as seconds, and nothing as no pace', () => {
expect(parseRunPace('6:00')).toBe(360);
expect(parseRunPace(' 5:07 ')).toBe(307);
expect(parseRunPace('6')).toBe(360);
expect(parseRunPace('5,5')).toBe(330);
expect(parseRunPace('5.25')).toBe(315);
expect(parseRunPace('5.5')).toBe(330);
// "5.30" and "5,45" are 5:30 and 5:45 as written in much of Europe, not 5.3 or 5.45 minutes.
expect(parseRunPace('5.30')).toBe(330);
expect(parseRunPace('5,45')).toBe(345);
expect(parseRunPace('5.05')).toBe(305);
expect(parseRunPace('')).toBeNull();
expect(parseRunPace(' ')).toBeNull();
});
it('FE-TOUR-RUN-PACE-002: anything else is no pace, seconds of 60 or more included', () => {
for (const text of ['5:60', '5:7', 'fast', '6 min', '-5', '5:30:00', '123']) {
for (const text of ['5:60', '5:7', '5.60', '5,75', '5.305', 'fast', '6 min', '-5', '5:30:00', '123']) {
expect(parseRunPace(text)).toBeNaN();
}
});
+13 -6
View File
@@ -3,13 +3,21 @@ import { runPaceSecondsPerKmSchema } from '@trek/shared';
/** The longest text the pace field takes: "12:30" and a little room. */
export const RUN_PACE_INPUT_MAX_LENGTH = 6;
/** Minutes and two-digit seconds as whole seconds, or NaN for seconds of 60 or more. */
function clockSeconds(minutes: string, seconds: string): number {
const secondsNumber = Number(seconds);
return secondsNumber < 60 ? Number(minutes) * 60 + secondsNumber : Number.NaN;
}
const CLOCK = /^(\d{1,2}):(\d{2})$/;
const MINUTES = /^(\d{1,2})(?:[.,](\d{1,2}))?$/;
/**
* RUN-01: a pace as typed, read as whole seconds per kilometre. "5:30" is
* minutes and seconds, "6" whole minutes and "5,5" or "5.5" minutes and a
* part. An empty field is no pace (null); anything else, seconds of 60 or more
* tenth. Two digits after the point or comma are seconds, the way "5.30" or
* "5,30" is written for 5:30 in much of Europe, not hundredths of a minute.
* An empty field is no pace (null); anything else, seconds of 60 or more
* included, is NaN, so the planner marks it and holds Save back while the
* text stays for the runner to fix. Whether the pace is in bounds is the
* contract's to say, not this one's.
@@ -18,13 +26,12 @@ export function parseRunPace(text: string): number | null {
const value = text.trim();
if (value === '') return null;
const clock = CLOCK.exec(value);
if (clock) {
const seconds = Number(clock[2]);
return seconds < 60 ? Number(clock[1]) * 60 + seconds : Number.NaN;
}
if (clock) return clockSeconds(clock[1], clock[2]);
const minutes = MINUTES.exec(value);
if (!minutes) return Number.NaN;
return Math.round(Number(`${minutes[1]}.${minutes[2] ?? '0'}`) * 60);
const [, whole, part = '0'] = minutes;
if (part.length === 2) return clockSeconds(whole, part);
return Math.round(Number(`${whole}.${part}`) * 60);
}
/** A pace as the runner reads it, minutes and seconds per kilometre: 360 is "6:00". */
@@ -2,13 +2,15 @@ import type { TourListItem } from '@trek/shared';
import { formatRunPace, runPaceInvalid } from './runPace';
/**
* RUN-01: a saved run's pace as its facts say it, "6:00 min/km", or null for
* another type and for a run without one (an import, a run whose runner has
* not entered one yet): unknown stays unknown.
* RUN-01: a saved run's pace as its facts say it, "6:00 min/km" with the unit
* in the user's language, or null for another type and for a run without one
* (an import, a run whose runner has not entered one yet): unknown stays
* unknown.
*/
export function runPaceText(
tour: Pick<TourListItem, 'tour_type'> & Partial<Pick<TourListItem, 'run_pace_seconds_per_km'>>
tour: Pick<TourListItem, 'tour_type'> & Partial<Pick<TourListItem, 'run_pace_seconds_per_km'>>,
t: (key: string) => string
): string | null {
const pace = tour.tour_type === 'run' ? (tour.run_pace_seconds_per_km ?? null) : null;
return pace === null || runPaceInvalid(pace) ? null : `${formatRunPace(pace)} min/km`;
return pace === null || runPaceInvalid(pace) ? null : `${formatRunPace(pace)} ${t('tours.run.paceUnit')}`;
}
+1
View File
@@ -426,6 +426,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'كيف يُحسب وقت الجري',
'tours.run.paceInfo':
'وتيرتك الخاصة لكل كيلومتر. وقت الجري هو المسافة × الوتيرة؛ ولا تُحتسب الصعودات والنزولات ولا الصعوبة التقنية.',
'tours.run.paceUnit': 'دقيقة/كم',
'tours.run.paceInvalid': 'أدخل الوتيرة بصيغة دقيقة:ثانية لكل كم، من {fastest} إلى {slowest}.',
'tours.run.paceNeeded': 'أدخل وتيرتك لتخطيط وقت الجري وحفظ هذا الجري.',
'tours.run.timeEstimated': 'وقت الجري (مخطط)',
+1
View File
@@ -416,6 +416,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Qaçış vaxtı necə hesablanır',
'tours.run.paceInfo':
'Kilometr başına öz tempiniz. Qaçış vaxtı məsafə × tempdir; yoxuşlar, enişlər və texniki çətinlik nəzərə alınmır.',
'tours.run.paceUnit': 'dəq/km',
'tours.run.paceInvalid': 'Tempi km başına dəq:san kimi, {fastest} ilə {slowest} arasında daxil edin.',
'tours.run.paceNeeded': 'Qaçış vaxtını planlaşdırmaq və bu qaçışı saxlamaq üçün tempinizi daxil edin.',
'tours.run.timeEstimated': 'Qaçış vaxtı (planlaşdırılan)',
+1
View File
@@ -422,6 +422,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Como o tempo de corrida é calculado',
'tours.run.paceInfo':
'Seu próprio ritmo por quilômetro. O tempo de corrida é distância × ritmo; subidas, descidas e dificuldade técnica não entram.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Informe o ritmo como min:s por km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Informe seu ritmo para planejar o tempo de corrida e salvar esta corrida.',
'tours.run.timeEstimated': 'Tempo de corrida (planejado)',
+1
View File
@@ -423,6 +423,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Com es calcula el temps de cursa',
'tours.run.paceInfo':
'El teu ritme per quilòmetre. El temps de cursa és distància × ritme; el desnivell i la dificultat tècnica no hi compten.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Introdueix el ritme com a min:s per km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Introdueix el teu ritme per planificar el temps de cursa i desar aquesta cursa.',
'tours.run.timeEstimated': 'Temps de cursa (previst)',
+1
View File
@@ -415,6 +415,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Jak se počítá čas běhu',
'tours.run.paceInfo':
'Vaše vlastní tempo na kilometr. Čas běhu je vzdálenost × tempo; převýšení a technická obtížnost se nezapočítávají.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Zadejte tempo jako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Zadejte své tempo, aby se naplánoval čas běhu a běh šel uložit.',
'tours.run.timeEstimated': 'Čas běhu (plánovaný)',
+1
View File
@@ -425,6 +425,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Wie die Laufzeit berechnet wird',
'tours.run.paceInfo':
'Deine eigene Pace pro Kilometer. Die Laufzeit ist Distanz × Pace; Höhenmeter und technische Schwierigkeit gehen nicht ein.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Gib die Pace als Min:Sek pro km ein, von {fastest} bis {slowest}.',
'tours.run.paceNeeded': 'Gib deine Pace ein, um die Laufzeit zu planen und diesen Lauf zu speichern.',
'tours.run.timeEstimated': 'Laufzeit (geplant)',
+1
View File
@@ -414,6 +414,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'How the running time is worked out',
'tours.run.paceInfo':
'Your own pace per kilometre. The running time is distance × pace; climbs, descents and how technical the way is are not included.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Enter the pace as min:sec per km, from {fastest} to {slowest}.',
'tours.run.paceNeeded': 'Enter your pace to plan the running time and save this run.',
'tours.run.timeEstimated': 'Running time (planned)',
+1
View File
@@ -422,6 +422,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Cómo se calcula el tiempo de carrera',
'tours.run.paceInfo':
'Tu propio ritmo por kilómetro. El tiempo de carrera es distancia × ritmo; el desnivel y la dificultad técnica no se tienen en cuenta.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Introduce el ritmo como min:s por km, de {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Introduce tu ritmo para planificar el tiempo de carrera y guardar esta carrera.',
'tours.run.timeEstimated': 'Tiempo de carrera (previsto)',
+1
View File
@@ -417,6 +417,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Kuidas jooksuaeg arvutatakse',
'tours.run.paceInfo':
'Sinu enda tempo kilomeetri kohta. Jooksuaeg on vahemaa × tempo; tõusud, langused ja tehniline raskus ei lähe arvesse.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Sisesta tempo kujul min:s kilomeetri kohta, vahemikus {fastest} kuni {slowest}.',
'tours.run.paceNeeded': 'Sisesta oma tempo, et jooksuaeg planeerida ja see jooks salvestada.',
'tours.run.timeEstimated': 'Jooksuaeg (planeeritud)',
+1
View File
@@ -428,6 +428,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Comment le temps de course est calculé',
'tours.run.paceInfo':
'Ton allure à toi, par kilomètre. Le temps de course est distance × allure ; dénivelé et difficulté technique ne sont pas pris en compte.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Saisis l’allure en min:s par km, de {fastest} à {slowest}.',
'tours.run.paceNeeded': 'Saisis ton allure pour planifier le temps de course et enregistrer cette course.',
'tours.run.timeEstimated': 'Temps de course (prévu)',
+1
View File
@@ -430,6 +430,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Πώς υπολογίζεται ο χρόνος τρεξίματος',
'tours.run.paceInfo':
'Ο δικός σου ρυθμός ανά χιλιόμετρο. Ο χρόνος τρεξίματος είναι απόσταση × ρυθμός· ανηφόρες, κατηφόρες και τεχνική δυσκολία δεν λαμβάνονται υπόψη.',
'tours.run.paceUnit': 'λεπ/km',
'tours.run.paceInvalid': 'Δώσε τον ρυθμό ως λεπ:δευτ ανά km, από {fastest} έως {slowest}.',
'tours.run.paceNeeded':
'Δώσε τον ρυθμό σου για να προγραμματιστεί ο χρόνος τρεξίματος και να αποθηκευτεί αυτό το τρέξιμο.',
+1
View File
@@ -422,6 +422,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Hogyan számoljuk a futásidőt',
'tours.run.paceInfo':
'A saját tempód kilométerenként. A futásidő távolság × tempó; a szintemelkedés és a technikai nehézség nem számít bele.',
'tours.run.paceUnit': 'perc/km',
'tours.run.paceInvalid': 'Add meg a tempót perc:mp/km formában, {fastest} és {slowest} között.',
'tours.run.paceNeeded': 'Add meg a tempódat a futásidő tervezéséhez és a futás mentéséhez.',
'tours.run.timeEstimated': 'Futásidő (tervezett)',
+1
View File
@@ -417,6 +417,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Cara waktu lari dihitung',
'tours.run.paceInfo':
'Pace milikmu sendiri per kilometer. Waktu lari adalah jarak × pace; tanjakan, turunan, dan tingkat kesulitan teknis tidak dihitung.',
'tours.run.paceUnit': 'mnt/km',
'tours.run.paceInvalid': 'Masukkan pace sebagai mnt:dtk per km, dari {fastest} sampai {slowest}.',
'tours.run.paceNeeded': 'Masukkan pace-mu untuk merencanakan waktu lari dan menyimpan lari ini.',
'tours.run.timeEstimated': 'Waktu lari (rencana)',
+1
View File
@@ -425,6 +425,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Come si calcola il tempo di corsa',
'tours.run.paceInfo':
'Il tuo passo al chilometro. Il tempo di corsa è distanza × passo; dislivello e difficoltà tecnica non sono considerati.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Inserisci il passo come min:s al km, da {fastest} a {slowest}.',
'tours.run.paceNeeded': 'Inserisci il tuo passo per pianificare il tempo di corsa e salvare questa corsa.',
'tours.run.timeEstimated': 'Tempo di corsa (previsto)',
+1
View File
@@ -405,6 +405,7 @@ const tours: TranslationStrings = {
'tours.run.pace': '予定ペース',
'tours.run.paceInfoLabel': '走行時間の計算方法',
'tours.run.paceInfo': '1 kmあたりのあなた自身のペースです。走行時間は距離 × ペースで、登り、下り、技術的な難しさは含みません。',
'tours.run.paceUnit': '分/km',
'tours.run.paceInvalid': 'ペースは km あたり 分:秒 で、{fastest} から {slowest} の間で入力してください。',
'tours.run.paceNeeded': '走行時間を計画してこのランを保存するには、ペースを入力してください。',
'tours.run.timeEstimated': '走行時間 (予定)',
+1
View File
@@ -403,6 +403,7 @@ const tours: TranslationStrings = {
'tours.run.pace': '계획 페이스',
'tours.run.paceInfoLabel': '달리기 시간 계산 방법',
'tours.run.paceInfo': '킬로미터당 나만의 페이스입니다. 달리기 시간은 거리 × 페이스이며, 오르막, 내리막, 기술적 난이도는 포함되지 않습니다.',
'tours.run.paceUnit': '분/km',
'tours.run.paceInvalid': '페이스를 km당 분:초로, {fastest}에서 {slowest} 사이로 입력하세요.',
'tours.run.paceNeeded': '달리기 시간을 계획하고 이 달리기를 저장하려면 페이스를 입력하세요.',
'tours.run.timeEstimated': '달리기 시간 (계획)',
+1
View File
@@ -424,6 +424,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Hoe de looptijd wordt berekend',
'tours.run.paceInfo':
'Je eigen tempo per kilometer. De looptijd is afstand × tempo; hoogtemeters en technische moeilijkheid tellen niet mee.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Vul het tempo in als min:sec per km, van {fastest} tot {slowest}.',
'tours.run.paceNeeded': 'Vul je tempo in om de looptijd te plannen en deze looptocht op te slaan.',
'tours.run.timeEstimated': 'Looptijd (gepland)',
+1
View File
@@ -431,6 +431,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Jak liczony jest czas biegu',
'tours.run.paceInfo':
'Twoje własne tempo na kilometr. Czas biegu to dystans × tempo; przewyższenia i trudność techniczna nie są uwzględniane.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Wpisz tempo jako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Wpisz swoje tempo, aby zaplanować czas biegu i zapisać ten bieg.',
'tours.run.timeEstimated': 'Czas biegu (planowany)',
+1
View File
@@ -435,6 +435,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Как считается время бега',
'tours.run.paceInfo':
'Ваш собственный темп на километр. Время бега равно расстоянию × темп; набор высоты и техническая сложность не учитываются.',
'tours.run.paceUnit': 'мин/км',
'tours.run.paceInvalid': 'Введите темп как мин:с на км, от {fastest} до {slowest}.',
'tours.run.paceNeeded': 'Введите свой темп, чтобы рассчитать время бега и сохранить эту пробежку.',
'tours.run.timeEstimated': 'Время бега (план)',
+1
View File
@@ -416,6 +416,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Ako sa počíta čas behu',
'tours.run.paceInfo':
'Vaše vlastné tempo na kilometer. Čas behu je vzdialenosť × tempo; prevýšenie a technická náročnosť sa nezapočítavajú.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Zadajte tempo ako min:s na km, od {fastest} do {slowest}.',
'tours.run.paceNeeded': 'Zadajte svoje tempo, aby sa naplánoval čas behu a beh sa dal uložiť.',
'tours.run.timeEstimated': 'Čas behu (plánovaný)',
+1
View File
@@ -413,6 +413,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Så räknas löptiden ut',
'tours.run.paceInfo':
'Ditt eget tempo per kilometer. Löptiden är sträcka × tempo; stigningar och teknisk svårighet räknas inte in.',
'tours.run.paceUnit': 'min/km',
'tours.run.paceInvalid': 'Ange tempot som min:sek per km, från {fastest} till {slowest}.',
'tours.run.paceNeeded': 'Ange ditt tempo för att planera löptiden och spara löpturen.',
'tours.run.timeEstimated': 'Löptid (planerad)',
+1
View File
@@ -394,6 +394,7 @@ const tours: TranslationStrings = {
'tours.run.pace': 'เพซที่วางแผนไว้',
'tours.run.paceInfoLabel': 'วิธีคำนวณเวลาวิ่ง',
'tours.run.paceInfo': 'เพซของคุณเองต่อกิโลเมตร เวลาวิ่งคือระยะทาง × เพซ ไม่รวมทางขึ้น ทางลง และความยากทางเทคนิค',
'tours.run.paceUnit': 'นาที/km',
'tours.run.paceInvalid': 'ใส่เพซในรูปแบบ นาที:วินาที ต่อ km ตั้งแต่ {fastest} ถึง {slowest}',
'tours.run.paceNeeded': 'ใส่เพซของคุณเพื่อวางแผนเวลาวิ่งและบันทึกการวิ่งนี้',
'tours.run.timeEstimated': 'เวลาวิ่ง (ตามแผน)',
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 848;
const GENERAL_KEY_COUNT = 849;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+1
View File
@@ -416,6 +416,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Koşu süresi nasıl hesaplanır',
'tours.run.paceInfo':
'Kilometre başına kendi tempon. Koşu süresi mesafe × tempodur; tırmanışlar, inişler ve teknik zorluk hesaba katılmaz.',
'tours.run.paceUnit': 'dk/km',
'tours.run.paceInvalid': 'Tempoyu km başına dk:sn olarak, {fastest} ile {slowest} arasında gir.',
'tours.run.paceNeeded': 'Koşu süresini planlamak ve bu koşuyu kaydetmek için temponu gir.',
'tours.run.timeEstimated': 'Koşu süresi (planlanan)',
+1
View File
@@ -430,6 +430,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Як рахується час бігу',
'tours.run.paceInfo':
'Ваш власний темп на кілометр. Час бігу дорівнює відстані × темп; набір висоти й технічна складність не враховуються.',
'tours.run.paceUnit': 'хв/км',
'tours.run.paceInvalid': 'Введіть темп як хв:с на км, від {fastest} до {slowest}.',
'tours.run.paceNeeded': 'Введіть свій темп, щоб розрахувати час бігу й зберегти цю пробіжку.',
'tours.run.timeEstimated': 'Час бігу (план)',
+1
View File
@@ -414,6 +414,7 @@ const tours: TranslationStrings = {
'tours.run.paceInfoLabel': 'Cách tính thời gian chạy',
'tours.run.paceInfo':
'Nhịp chạy của riêng bạn trên mỗi kilômét. Thời gian chạy là quãng đường × nhịp chạy; độ dốc lên xuống và độ khó kỹ thuật không được tính.',
'tours.run.paceUnit': 'phút/km',
'tours.run.paceInvalid': 'Nhập nhịp chạy dạng phút:giây mỗi km, từ {fastest} đến {slowest}.',
'tours.run.paceNeeded': 'Nhập nhịp chạy của bạn để lên kế hoạch thời gian chạy và lưu buổi chạy này.',
'tours.run.timeEstimated': 'Thời gian chạy (dự kiến)',
+1
View File
@@ -376,6 +376,7 @@ const tours: TranslationStrings = {
'tours.run.pace': '計畫配速',
'tours.run.paceInfoLabel': '跑步時間如何計算',
'tours.run.paceInfo': '你自己的每公里配速。跑步時間等於距離 × 配速;爬升、下降與技術難度不計入。',
'tours.run.paceUnit': '分/公里',
'tours.run.paceInvalid': '請以每公里 分:秒 輸入配速,範圍 {fastest} 至 {slowest}。',
'tours.run.paceNeeded': '輸入你的配速,以規劃跑步時間並儲存這次跑步。',
'tours.run.timeEstimated': '跑步時間(計畫)',
+1
View File
@@ -377,6 +377,7 @@ const tours: TranslationStrings = {
'tours.run.pace': '计划配速',
'tours.run.paceInfoLabel': '跑步时间如何计算',
'tours.run.paceInfo': '你自己的每公里配速。跑步时间等于距离 × 配速;爬升、下降和技术难度不计入。',
'tours.run.paceUnit': '分/公里',
'tours.run.paceInvalid': '请按每公里 分:秒 输入配速,范围 {fastest} 至 {slowest}。',
'tours.run.paceNeeded': '输入你的配速,以计划跑步时间并保存这次跑步。',
'tours.run.timeEstimated': '跑步时间(计划)',