diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
index 3d1beffba..10428dcbd 100644
--- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
+++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
@@ -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 ? (
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
new file mode 100644
index 000000000..8ab93f52a
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx
@@ -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( )
+ 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()
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
index f9c771f24..f7c84b720 100644
--- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
+++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx
@@ -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(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 (
@@ -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"
/>
{suffix}
diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
index b36284e31..fb7ab506c 100644
--- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx
+++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
@@ -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"
diff --git a/server/src/addons.ts b/server/src/addons.ts
index 890d656a1..2f72c50fa 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -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,
+];
diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts
index eca6024d9..5e3677f7e 100644
--- a/server/src/nest/admin/admin.service.ts
+++ b/server/src/nest/admin/admin.service.ts
@@ -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([
- 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(MCP_GATED_ADDON_IDS);
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
return {
diff --git a/server/tests/unit/nest/mcp-gated-addons.parity.test.ts b/server/tests/unit/nest/mcp-gated-addons.parity.test.ts
new file mode 100644
index 000000000..32275bb8b
--- /dev/null
+++ b/server/tests/unit/nest/mcp-gated-addons.parity.test.ts
@@ -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; where: Map } {
+ const ids = new Set();
+ const where = new Map();
+ 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)[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(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);
+ });
+});
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index d05f3bacc..6e09ecb27 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -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': 'بحث',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index 35266ed6b..96f4e815a 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index ab45f03f7..db15c62f9 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 7e1e3a16a..7e9ca8e85 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index 34833e5dd..67f2638ff 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 7659b123e..bd37a372a 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 10cd44bbb..7e28d056a 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 9e758893e..242421053 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index 7c44b625c..ffe32224c 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -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': 'Αναζήτηση',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 1a8468634..df2f73963 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 5bae1e9d8..0a337482b 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index 00c1deff4..973f50aa2 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index 310cf5c0e..76f535cb1 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -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': '検索',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index 41dbcbe9b..87cd2766e 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -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': '검색',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 23b103111..dcc8868a9 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 50a303542..f2ab2569f 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index c5d80e472..f495a8a3c 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -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': 'Искать',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 8dc5ade1c..0920aa021 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index ef87149b9..0253bcc98 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index ea6267c77..3dce1f824 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -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': 'Шукати',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index fd34df34e..633fb9df6 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -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',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 9653537c4..f34f8e1b9 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -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': '搜尋',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 188f12a22..99c12ccc2 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -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': '搜索',