mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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': 'وقت الجري (مخطط)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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ý)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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':
|
||||
'Δώσε τον ρυθμό σου για να προγραμματιστεί ο χρόνος τρεξίματος και να αποθηκευτεί αυτό το τρέξιμο.',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': '走行時間 (予定)',
|
||||
|
||||
@@ -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': '달리기 시간 (계획)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': 'Время бега (план)',
|
||||
|
||||
@@ -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ý)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': 'เวลาวิ่ง (ตามแผน)',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': 'Час бігу (план)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': '跑步時間(計畫)',
|
||||
|
||||
@@ -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': '跑步时间(计划)',
|
||||
|
||||
Reference in New Issue
Block a user