mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its neighbour. The driving limits saved on every keystroke. Typing "180" sent three settings writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so whichever the server committed last won: the number that decides every over-budget warning could quietly end up a tenth of what was typed, and only show it after the next reload. Typed locally now, committed on blur or Enter, and a value that did not actually change writes nothing. The empty-result message always blamed the name box. Four controls narrow that list and only one of them is the name, so picking a section with no hits produced `Nothing on the way matches ""` — empty quotes naming a filter the reader never set. The count line above it was gated the same way, so the section case lost its "x of y" as well. Both follow whatever actually narrowed the list. The check-out dropdown printed a bare ISO date, the only place in the planner that did; it goes through the same helper as the rail beside it. Switching an addon off left open MCP sessions holding its write tools. A `when:` gate runs once per session, at attach time, so the tools stay registered and callable while the REST half answers 404 for the same user in the same moment — and the list that decides whether to tear those sessions down had drifted: airtrail and collections gate tools and were missing from it, which this addon would have inherited. The list now sits beside ADDON_IDS and a parity test reads the gates out of the source and holds it there. Read out of the source rather than out of a runtime registry on purpose: a runtime set only holds the gates whose modules the caller happened to import, so it would pass in a suite that imports none — which is the shape of test that hid this in the first place. RoadtripLimitsCard gets the test it never had, including the one case that matters: three keystrokes are not three writes.
This commit is contained in:
@@ -515,13 +515,23 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
search.loading
|
||||
? t('roadtrip.poi.searchingHint')
|
||||
: filteredToNothing
|
||||
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
|
||||
// Four controls narrow this list and only one of them is the
|
||||
// name box. Blaming it regardless produced `Nothing on the
|
||||
// way matches ""` — empty quotes naming a filter the reader
|
||||
// never set — when a section, a plug type or a minimum power
|
||||
// was what emptied it.
|
||||
? corridor.nameFilter.trim()
|
||||
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
|
||||
: t('roadtrip.poi.noneMatchFilters')
|
||||
: t('roadtrip.poi.empty')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{corridor.nameFilter.trim() ? (
|
||||
{/* Shown whenever anything narrowed the list, not only the name
|
||||
box: a section or a plug filter hides hits just as much, and
|
||||
the reader is owed the same "x of y" either way. */}
|
||||
{corridor.visible.length !== search.results.length ? (
|
||||
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
|
||||
*
|
||||
* The one that matters is when a number is written. These fields had no local
|
||||
* state, so every keystroke was a settings PUT: typing "180" sent three, and
|
||||
* they raced. The value that decides whether a day is over budget could end up a
|
||||
* tenth of what was typed and only say so after the next reload.
|
||||
*/
|
||||
|
||||
function open(onSave?: (key: string, value: number) => void) {
|
||||
render(<RoadtripLimitsCard onSave={onSave} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
/** The three inputs, in the order the dialog lists them. */
|
||||
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_leg_minutes: 0,
|
||||
roadtrip_day_minutes: 0,
|
||||
roadtrip_range_km: 0,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripLimitsCard', () => {
|
||||
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '1' } })
|
||||
fireEvent.change(leg, { target: { value: '18' } })
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
|
||||
// Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180.
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(leg.value).toBe('180')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [, day] = inputs()
|
||||
fireEvent.change(day, { target: { value: '540' } })
|
||||
fireEvent.keyDown(day, { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
fireEvent.blur(inputs()[0])
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
|
||||
// `onSave` absent is how the caller says the reader may look but not change.
|
||||
open(undefined)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
// Nothing to assert but the absence of a crash: the commit path has no
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,20 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
placeholder: string
|
||||
onChange: (next: number) => void
|
||||
}): React.ReactElement {
|
||||
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
|
||||
// settings PUT per character — typing "180" produced three, carrying 1, 18 and
|
||||
// 180, unordered and concurrent over HTTP/2. Whichever the server committed
|
||||
// last won, so the limit that decides every over-budget warning could quietly
|
||||
// end up a tenth of what was typed, and only show it after the next reload.
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const shown = draft ?? (value ? String(value) : '')
|
||||
const commit = () => {
|
||||
if (draft === null) return
|
||||
const next = parseLimit(draft)
|
||||
setDraft(null)
|
||||
if (next !== (value ?? 0)) onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
@@ -47,9 +61,14 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={value || ''}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange(parseLimit(e.target.value))}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="w-7 text-caption text-content-faint">{suffix}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
@@ -71,7 +72,7 @@ interface RoadtripStopPopupProps {
|
||||
export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
// Through the same guard every other external link in the app goes through.
|
||||
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
|
||||
// routinely written without a scheme — `www.hotel.de` as a raw href is a
|
||||
@@ -181,8 +182,12 @@ export default function RoadtripStopPopup({
|
||||
onChange={value => setEndDayId(Number(value))}
|
||||
options={overnight!.days.map(d => ({
|
||||
value: String(d.id),
|
||||
// Through the shared helper, like every other day date in
|
||||
// this feature. Interpolating the column gave this one
|
||||
// dropdown a bare ISO date while the rail beside it showed
|
||||
// the same day in the reader's own order.
|
||||
label: d.date
|
||||
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${d.date}`
|
||||
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
|
||||
: t('roadtrip.stay.dayNumber', { number: d.number }),
|
||||
}))}
|
||||
size="sm"
|
||||
|
||||
@@ -14,3 +14,34 @@ export const ADDON_IDS = {
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
/**
|
||||
* The addons that gate an MCP surface, which is what decides whether switching
|
||||
* one off has to tear down the live sessions.
|
||||
*
|
||||
* A `when:` gate is evaluated once per session, at attach time. A client that
|
||||
* connected while an addon was on therefore keeps its tools registered and
|
||||
* callable after an admin switches it off — the gate never re-runs — while the
|
||||
* REST half answers 404 for the same user in the same moment. Only tearing the
|
||||
* sessions down closes that.
|
||||
*
|
||||
* Stated here rather than collected at import time, so nothing depends on which
|
||||
* modules a caller happens to have loaded, and guarded by a parity test that
|
||||
* reads the gates out of the source: as a hand-kept list this had already
|
||||
* drifted, and airtrail and collections were missing from it.
|
||||
*
|
||||
* MCP itself is on the list for a different reason — it gates the whole surface
|
||||
* rather than any single tool.
|
||||
*/
|
||||
export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
|
||||
ADDON_IDS.MCP,
|
||||
ADDON_IDS.AIRTRAIL,
|
||||
ADDON_IDS.ATLAS,
|
||||
ADDON_IDS.BUDGET,
|
||||
ADDON_IDS.COLLAB,
|
||||
ADDON_IDS.COLLECTIONS,
|
||||
ADDON_IDS.JOURNEY,
|
||||
ADDON_IDS.PACKING,
|
||||
ADDON_IDS.ROADTRIP,
|
||||
ADDON_IDS.VACAY,
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../addons';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { updateJwtSecret } from '../../config';
|
||||
// Import from sessionManager directly, NOT the ../../mcp barrel — the direct
|
||||
@@ -698,19 +698,17 @@ export class AdminService {
|
||||
}
|
||||
: null;
|
||||
|
||||
// Only these addons gate MCP tool/resource/prompt registration (see
|
||||
// registerTools/registerResources) — and only a real enabled-flip changes
|
||||
// what a session would register. Config-only saves, photo providers and
|
||||
// MCP-irrelevant addons must not tear down every live session (#1414).
|
||||
const MCP_RELEVANT_ADDONS = new Set<string>([
|
||||
ADDON_IDS.MCP,
|
||||
ADDON_IDS.PACKING,
|
||||
ADDON_IDS.BUDGET,
|
||||
ADDON_IDS.COLLAB,
|
||||
ADDON_IDS.ATLAS,
|
||||
ADDON_IDS.VACAY,
|
||||
ADDON_IDS.JOURNEY,
|
||||
]);
|
||||
// Only addons that gate MCP registration matter here — and only a real
|
||||
// enabled-flip changes what a session would register. Config-only saves,
|
||||
// photo providers and MCP-irrelevant addons must not tear down every live
|
||||
// session (#1414).
|
||||
//
|
||||
// The list lives beside ADDON_IDS and is held to the gates by a parity test.
|
||||
// As a copy kept here it had drifted: airtrail and collections gate tools
|
||||
// and were missing, so their write tools stayed callable on an open session
|
||||
// after an admin switched them off, while the REST half answered 404 for the
|
||||
// same user in the same moment.
|
||||
const MCP_RELEVANT_ADDONS = new Set<string>(MCP_GATED_ADDON_IDS);
|
||||
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* MCP-GATE-PARITY-001..002 — the list of MCP-gated addons against the gates.
|
||||
*
|
||||
* `MCP_GATED_ADDON_IDS` decides whether switching an addon off tears down the
|
||||
* live MCP sessions. A `when:` gate is evaluated once per session, at attach
|
||||
* time, so a client that connected while the addon was on keeps its tools
|
||||
* registered and callable afterwards — while the REST half answers 404 for the
|
||||
* same user in the same moment. An addon missing from the list is that hole.
|
||||
*
|
||||
* It was a hand-kept list and it had drifted: airtrail and collections gate
|
||||
* tools and were not on it. Read out of the source rather than out of the
|
||||
* module registry on purpose — a runtime set would only contain the gates whose
|
||||
* modules the caller happened to import, so it would pass in a suite that
|
||||
* imports none, which is exactly the shape of test that hid this.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
|
||||
|
||||
const SRC = join(__dirname, '../../../src');
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) return walk(full);
|
||||
return full.endsWith('.ts') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Every addon id named by an addonGate(...) or collabFeatureGate(...) call. */
|
||||
function gatedInSource(): { ids: Set<string>; where: Map<string, string[]> } {
|
||||
const ids = new Set<string>();
|
||||
const where = new Map<string, string[]>();
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const m of text.matchAll(/\baddonGate\(\s*ADDON_IDS\.([A-Z_]+)\s*\)/g)) {
|
||||
const id = (ADDON_IDS as Record<string, string>)[m[1]];
|
||||
if (!id) continue;
|
||||
ids.add(id);
|
||||
where.set(id, [...(where.get(id) ?? []), file]);
|
||||
}
|
||||
// Collab's sub-features ride the collab addon on top of their own flag.
|
||||
if (/\bcollabFeatureGate\(/.test(text) && !file.endsWith('addon-gate.ts')) {
|
||||
ids.add(ADDON_IDS.COLLAB);
|
||||
where.set(ADDON_IDS.COLLAB, [...(where.get(ADDON_IDS.COLLAB) ?? []), file]);
|
||||
}
|
||||
}
|
||||
return { ids, where };
|
||||
}
|
||||
|
||||
describe('MCP-gated addons', () => {
|
||||
it('MCP-GATE-PARITY-001: every addon that gates a tool is on the list', () => {
|
||||
const { ids, where } = gatedInSource();
|
||||
const listed = new Set<string>(MCP_GATED_ADDON_IDS);
|
||||
|
||||
const missing = [...ids].filter(id => !listed.has(id)).sort();
|
||||
expect(
|
||||
missing,
|
||||
missing.length
|
||||
? `gated but not listed: ${missing.map(id => `${id} (${where.get(id)?.[0]})`).join(', ')}`
|
||||
: '',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('MCP-GATE-PARITY-002: nothing on the list is there for no reason', () => {
|
||||
// The other direction, so a removed gate does not leave an entry behind that
|
||||
// tears down every session for nothing. `mcp` is the exception: it gates the
|
||||
// whole surface rather than any single tool, so it appears in no gate call.
|
||||
const { ids } = gatedInSource();
|
||||
const spurious = MCP_GATED_ADDON_IDS.filter(id => id !== ADDON_IDS.MCP && !ids.has(id)).sort();
|
||||
expect(spurious, spurious.length ? `listed but nothing gates on it: ${spurious.join(', ')}` : '').toEqual([]);
|
||||
});
|
||||
|
||||
it('MCP-GATE-PARITY-003: the source scan finds gates at all', () => {
|
||||
// Guards the guard: a regex that matched nothing would make both cases pass
|
||||
// on an empty set and quietly stop testing anything.
|
||||
const { ids } = gatedInSource();
|
||||
expect(ids.size).toBeGreaterThanOrEqual(5);
|
||||
expect(ids.has(ADDON_IDS.ROADTRIP)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'ضمن',
|
||||
'roadtrip.poi.filter': 'تصفية حسب الاسم',
|
||||
'roadtrip.poi.noMatch': 'لا يوجد على الطريق ما يطابق «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'لا يوجد شيء على الطريق يطابق عوامل التصفية هذه.',
|
||||
'roadtrip.poi.foundFiltered': '{count} من {total} على الطريق',
|
||||
'roadtrip.poi.truncated': 'في {count} من المقاطع كان هناك أكثر مما تتسع له استجابة واحدة — قلّص الممر لرؤية البقية.',
|
||||
'roadtrip.poi.search': 'بحث',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Num raio de',
|
||||
'roadtrip.poi.filter': 'Filtrar por nome',
|
||||
'roadtrip.poi.noMatch': 'Nada no trajeto corresponde a “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nada no caminho corresponde a esses filtros.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} no trajeto',
|
||||
'roadtrip.poi.truncated': 'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.',
|
||||
'roadtrip.poi.search': 'Buscar',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'En un radi de',
|
||||
'roadtrip.poi.filter': 'Filtra per nom',
|
||||
'roadtrip.poi.noMatch': 'Res del trajecte no coincideix amb «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Res del camí no coincideix amb aquests filtres.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} al trajecte',
|
||||
'roadtrip.poi.truncated': 'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.',
|
||||
'roadtrip.poi.search': 'Cerca',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Do',
|
||||
'roadtrip.poi.filter': 'Filtrovat podle názvu',
|
||||
'roadtrip.poi.noMatch': 'Na trase nic neodpovídá „{name}“.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nic na trase neodpovídá těmto filtrům.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} na trase',
|
||||
'roadtrip.poi.truncated': 'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.',
|
||||
'roadtrip.poi.search': 'Hledat',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Im Umkreis',
|
||||
'roadtrip.poi.filter': 'Nach Name filtern',
|
||||
'roadtrip.poi.noMatch': 'Unterwegs passt nichts zu „{name}“.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nichts auf dem Weg passt zu diesen Filtern.',
|
||||
'roadtrip.poi.foundFiltered': '{count} von {total} unterwegs',
|
||||
'roadtrip.poi.truncated': 'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.',
|
||||
'roadtrip.poi.search': 'Suchen',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Within',
|
||||
'roadtrip.poi.filter': 'Filter by name',
|
||||
'roadtrip.poi.noMatch': 'Nothing on the way matches “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nothing on the way matches these filters.',
|
||||
'roadtrip.poi.foundFiltered': '{count} of {total} on the way',
|
||||
'roadtrip.poi.truncated': '{count} stretches had more than fits in one answer — narrow the corridor to see the rest.',
|
||||
'roadtrip.poi.search': 'Search',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'En un radio de',
|
||||
'roadtrip.poi.filter': 'Filtrar por nombre',
|
||||
'roadtrip.poi.noMatch': 'Nada en la ruta coincide con «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nada en el camino coincide con estos filtros.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} en la ruta',
|
||||
'roadtrip.poi.truncated': 'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.',
|
||||
'roadtrip.poi.search': 'Buscar',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Dans un rayon de',
|
||||
'roadtrip.poi.filter': 'Filtrer par nom',
|
||||
'roadtrip.poi.noMatch': 'Rien sur le trajet ne correspond à « {name} ».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Rien sur le trajet ne correspond à ces filtres.',
|
||||
'roadtrip.poi.foundFiltered': '{count} sur {total} sur le trajet',
|
||||
'roadtrip.poi.truncated': 'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.',
|
||||
'roadtrip.poi.search': 'Rechercher',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Εντός',
|
||||
'roadtrip.poi.filter': 'Φιλτράρισμα κατά όνομα',
|
||||
'roadtrip.poi.noMatch': 'Τίποτα στη διαδρομή δεν ταιριάζει με «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Τίποτα στη διαδρομή δεν ταιριάζει με αυτά τα φίλτρα.',
|
||||
'roadtrip.poi.foundFiltered': '{count} από {total} στη διαδρομή',
|
||||
'roadtrip.poi.truncated': 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.',
|
||||
'roadtrip.poi.search': 'Αναζήτηση',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Ezen belül',
|
||||
'roadtrip.poi.filter': 'Szűrés név szerint',
|
||||
'roadtrip.poi.noMatch': 'Az útvonalon semmi sem egyezik ezzel: „{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Az útvonalon semmi sem felel meg ezeknek a szűrőknek.',
|
||||
'roadtrip.poi.foundFiltered': '{count} / {total} az útvonalon',
|
||||
'roadtrip.poi.truncated': '{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.',
|
||||
'roadtrip.poi.search': 'Keresés',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Dalam',
|
||||
'roadtrip.poi.filter': 'Saring menurut nama',
|
||||
'roadtrip.poi.noMatch': 'Tidak ada di rute yang cocok dengan “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Tidak ada di sepanjang rute yang cocok dengan filter ini.',
|
||||
'roadtrip.poi.foundFiltered': '{count} dari {total} di rute',
|
||||
'roadtrip.poi.truncated': 'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.',
|
||||
'roadtrip.poi.search': 'Cari',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Entro',
|
||||
'roadtrip.poi.filter': 'Filtra per nome',
|
||||
'roadtrip.poi.noMatch': 'Lungo il percorso non c’è nulla che corrisponda a “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Niente lungo il percorso corrisponde a questi filtri.',
|
||||
'roadtrip.poi.foundFiltered': '{count} di {total} lungo il percorso',
|
||||
'roadtrip.poi.truncated': 'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.',
|
||||
'roadtrip.poi.search': 'Cerca',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': '範囲',
|
||||
'roadtrip.poi.filter': '名前で絞り込む',
|
||||
'roadtrip.poi.noMatch': 'ルート沿いに「{name}」に一致するものはありません。',
|
||||
'roadtrip.poi.noneMatchFilters': 'この条件に合うものは道中にありません。',
|
||||
'roadtrip.poi.foundFiltered': 'ルート沿いの {total} 件中 {count} 件',
|
||||
'roadtrip.poi.truncated': '{count} 区間で一度に返せる件数を超えました。回廊を狭めると残りを確認できます。',
|
||||
'roadtrip.poi.search': '検索',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': '반경',
|
||||
'roadtrip.poi.filter': '이름으로 필터',
|
||||
'roadtrip.poi.noMatch': '경로상에 “{name}”와(과) 일치하는 곳이 없습니다.',
|
||||
'roadtrip.poi.noneMatchFilters': '이 필터에 맞는 곳이 경로에 없습니다.',
|
||||
'roadtrip.poi.foundFiltered': '경로상 {total}곳 중 {count}곳',
|
||||
'roadtrip.poi.truncated': '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.',
|
||||
'roadtrip.poi.search': '검색',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Binnen',
|
||||
'roadtrip.poi.filter': 'Filteren op naam',
|
||||
'roadtrip.poi.noMatch': 'Niets onderweg komt overeen met “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Niets onderweg voldoet aan deze filters.',
|
||||
'roadtrip.poi.foundFiltered': '{count} van {total} onderweg',
|
||||
'roadtrip.poi.truncated': 'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.',
|
||||
'roadtrip.poi.search': 'Zoeken',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'W promieniu',
|
||||
'roadtrip.poi.filter': 'Filtruj wedug nazwy',
|
||||
'roadtrip.poi.noMatch': 'Nic po drodze nie pasuje do „{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nic po drodze nie pasuje do tych filtrów.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} po drodze',
|
||||
'roadtrip.poi.truncated': 'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.',
|
||||
'roadtrip.poi.search': 'Szukaj',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'В радиусе',
|
||||
'roadtrip.poi.filter': 'Фильтр по названию',
|
||||
'roadtrip.poi.noMatch': 'По пути ничего не соответствует «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'На маршруте нет ничего, что подходит под эти фильтры.',
|
||||
'roadtrip.poi.foundFiltered': '{count} из {total} по пути',
|
||||
'roadtrip.poi.truncated': 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.',
|
||||
'roadtrip.poi.search': 'Искать',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Inom',
|
||||
'roadtrip.poi.filter': 'Filtrera på namn',
|
||||
'roadtrip.poi.noMatch': 'Inget längs vägen matchar ”{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Inget längs vägen matchar de här filtren.',
|
||||
'roadtrip.poi.foundFiltered': '{count} av {total} längs vägen',
|
||||
'roadtrip.poi.truncated': 'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.',
|
||||
'roadtrip.poi.search': 'Sök',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Mesafe',
|
||||
'roadtrip.poi.filter': 'Ada göre süz',
|
||||
'roadtrip.poi.noMatch': 'Yol boyunca “{name}” ile eşleşen bir şey yok.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Yol boyunca bu filtrelere uyan bir yer yok.',
|
||||
'roadtrip.poi.foundFiltered': 'yol üzerinde {total} içinden {count}',
|
||||
'roadtrip.poi.truncated': '{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.',
|
||||
'roadtrip.poi.search': 'Ara',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'У радіусі',
|
||||
'roadtrip.poi.filter': 'Фільтр за назвою',
|
||||
'roadtrip.poi.noMatch': 'Дорогою ніщо не відповідає «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'На маршруті немає нічого, що відповідає цим фільтрам.',
|
||||
'roadtrip.poi.foundFiltered': '{count} з {total} дорогою',
|
||||
'roadtrip.poi.truncated': 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.',
|
||||
'roadtrip.poi.search': 'Шукати',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': 'Trong vòng',
|
||||
'roadtrip.poi.filter': 'Lọc theo tên',
|
||||
'roadtrip.poi.noMatch': 'Không có gì trên đường khớp với “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Không có gì trên đường phù hợp với các bộ lọc này.',
|
||||
'roadtrip.poi.foundFiltered': '{count} trong {total} trên đường',
|
||||
'roadtrip.poi.truncated': 'Có {count} đoạn nhiều hơn mức một phản hồi chứa được — thu hẹp hành lang để xem phần còn lại.',
|
||||
'roadtrip.poi.search': 'Tìm kiếm',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': '範圍內',
|
||||
'roadtrip.poi.filter': '依名稱篩選',
|
||||
'roadtrip.poi.noMatch': '沿途沒有與「{name}」相符的地點。',
|
||||
'roadtrip.poi.noneMatchFilters': '沿途沒有符合這些篩選條件的地點。',
|
||||
'roadtrip.poi.foundFiltered': '沿途 {total} 個中的 {count} 個',
|
||||
'roadtrip.poi.truncated': '有 {count} 段的結果超出單次回應上限,縮小走廊寬度可查看其餘部分。',
|
||||
'roadtrip.poi.search': '搜尋',
|
||||
|
||||
@@ -72,6 +72,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.within': '范围内',
|
||||
'roadtrip.poi.filter': '按名称筛选',
|
||||
'roadtrip.poi.noMatch': '沿途没有与“{name}”匹配的地点。',
|
||||
'roadtrip.poi.noneMatchFilters': '沿途没有符合这些筛选条件的地点。',
|
||||
'roadtrip.poi.foundFiltered': '沿途 {total} 个中的 {count} 个',
|
||||
'roadtrip.poi.truncated': '有 {count} 段的结果超出单次响应上限,缩小走廊宽度可查看其余部分。',
|
||||
'roadtrip.poi.search': '搜索',
|
||||
|
||||
Reference in New Issue
Block a user