i18n: fix review findings and polish ru/es strings

Fixes the 51 P0–P2 findings from the full review of the i18n branch and
the issues found while checking every new string on screen in ru and es.

- Translate errors and statuses that still reached ru/es in English:
  feed and tile errors, provider statuses, the ALPR chip, the traffic
  mode chip, voice plan step labels, fire feed values, receiver bands,
  scene save reasons and the Cesium attribution link.
- Show preset city names in the UI language (src/ui/placeNames.js);
  data and everything sent to the model stay English.
- Share layer names between the layer panel, HUD and voice card
  (src/ui/layerNames.js).
- Keep long ru/es strings whole: voice card error lines, analyst
  titles, the voice tier status, the Nepal evidence button, the ALPR
  retry chip. English layout is unchanged.
- Ask before a language switch drops unsaved work.
- Catalog fixes: glossary terms (пуск, пресет), gender agreement and
  consistent "peticiones" in es, fewer calques in both languages.
- Fix English text that was itself wrong (e.g. "Exit cockpit to fly to
  a weather", raw tool ids in voice plan steps).

Catalogs: es/ru 2535 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+83 -28
View File
@@ -318,6 +318,44 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
};
}
// AI tools (src/tools/queries/mobility.js) import this module, so it stays
// free of i18n: the card copy below calls the translate function `t` that the
// caller hands in. The browser selection passes the real one from src/i18n;
// without it the copy is English.
const msg = (text) => text;
/** English {placeholder} interpolation, the default `t` of the card copy. */
function englishText(text, values) {
if (!values) return text;
return text.replace(/\{([A-Za-z0-9_]+)\}/g, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
}
/** GTFS-realtime OccupancyStatus in words. */
function occupancyText(occupancy, t) {
switch (occupancy) {
case 'EMPTY':
return t('empty', { context: 'occupancy' });
case 'MANY_SEATS_AVAILABLE':
return t('many seats available', { context: 'occupancy' });
case 'FEW_SEATS_AVAILABLE':
return t('few seats available', { context: 'occupancy' });
case 'STANDING_ROOM_ONLY':
return t('standing room only', { context: 'occupancy' });
case 'CRUSHED_STANDING_ROOM_ONLY':
return t('crushed standing room only', { context: 'occupancy' });
case 'FULL':
return t('full', { context: 'occupancy' });
case 'NOT_ACCEPTING_PASSENGERS':
return t('not accepting passengers', { context: 'occupancy' });
case 'NOT_BOARDABLE':
return t('not boardable', { context: 'occupancy' });
default:
return occupancy.toLowerCase().replaceAll('_', ' ');
}
}
/**
* Build the human lines of the selection card from a vehicle record.
* @param {object} feed Registry entry.
@@ -325,6 +363,9 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
* @param {string} mode Resolved transit mode.
* @param {number} nowMs
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
* @param {object|null} [entry] Playback entry of the vehicle.
* @param {(text: string, values?: object) => string} [t] Translates the
* card's English text; English when omitted.
* @returns {{title:string, details:string[]}}
*/
export function buildTransitSelectionCopy(
@@ -334,44 +375,52 @@ export function buildTransitSelectionCopy(
nowMs,
fetchedAtMs = null,
entry = null,
t = englishText,
) {
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
const routeLabel = record.routeId
? `Route ${record.routeId}`
: record.label
? `Vehicle ${record.label}`
: `Vehicle ${record.id}`;
? t('Route {route}', { route: record.routeId })
: t('Vehicle {vehicle}', { vehicle: record.label || record.id });
const title = `${icon} ${routeLabel}`;
// The mode is said in words on its own line. A glyph and a route id do not
// tell a reader that the dot on Tremont Street is a subway train, and a
// subway drawn on a street reads as a bug until the card says what it is.
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
const kindWord = t(TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown);
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
const motion = [];
// What the SCREEN is doing leads, because that is what the reader can check.
const shown = entry ? displayMotion(entry, nowMs) : null;
if (shown?.word === 'STOPPED') motion.push('Stopped');
else if (shown?.word === 'NO FIX') motion.push('No recent fix');
else if (shown?.word === 'WAITING') motion.push('Waiting for update');
else if (shown) motion.push('Moving');
if (shown?.word === 'STOPPED')
motion.push(t('Stopped', { context: 'vehicle motion' }));
else if (shown?.word === 'NO FIX') motion.push(t('No recent fix'));
else if (shown?.word === 'WAITING') motion.push(t('Waiting for update'));
else if (shown) motion.push(t('Moving'));
if (motion.length) details.push(motion.join(' · '));
const reportedMotion = [];
if (Number.isFinite(record.speedMps))
reportedMotion.push(`reported ${Math.round(record.speedMps * 3.6)} km/h`);
reportedMotion.push(
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
);
if (Number.isFinite(record.bearing))
reportedMotion.push(`reported hdg ${Math.round(record.bearing)}°`);
reportedMotion.push(
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
// It describes the moment of the report, which the display may not have
// reached yet; presenting it as "now" is what put STOPPED on a moving metro.
const state = [];
if (record.status === 'STOPPED_AT' && record.stopId)
state.push(`Last report: stopped at stop ${record.stopId}`);
state.push(
t('Last report: stopped at stop {stop}', { stop: record.stopId }),
);
else if (record.status === 'INCOMING_AT' && record.stopId)
state.push(`Last report: arriving at stop ${record.stopId}`);
state.push(
t('Last report: arriving at stop {stop}', { stop: record.stopId }),
);
else if (record.status === 'IN_TRANSIT_TO' && record.stopId)
state.push(`Last report: next stop ${record.stopId}`);
state.push(t('Last report: next stop {stop}', { stop: record.stopId }));
if (record.occupancy && record.occupancy !== 'NO_DATA_AVAILABLE') {
state.push(record.occupancy.toLowerCase().replaceAll('_', ' '));
state.push(occupancyText(record.occupancy, t));
}
if (reportedMotion.length) details.push(reportedMotion.join(' · '));
if (state.length) details.push(state.join(' · '));
@@ -379,10 +428,10 @@ export function buildTransitSelectionCopy(
// That is a deliberate choice — a train nobody can see is not worth drawing —
// but it has to be stated, or a metro sitting on a road looks like an error.
if (UNDERGROUND_MODES.has(mode)) {
details.push('Shown at street level · depth not in the feed');
details.push(t('Shown at street level · depth not in the feed'));
}
details.push(
`Vehicle ${record.id}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
`${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
);
const ageS = Math.round(
(Number.isFinite(entry?.sample?.latestReportAgeMs)
@@ -391,8 +440,8 @@ export function buildTransitSelectionCopy(
);
const reported =
ageS < 90
? `Reported ${ageS} s ago`
: `Reported ${Math.round(ageS / 60)} min ago`;
? t('Reported {seconds} s ago', { seconds: ageS })
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
// A time the feed did not give for THIS vehicle is labelled, so the card
// never presents a borrowed or inferred time as the vehicle's own report.
const ownTime =
@@ -404,19 +453,25 @@ export function buildTransitSelectionCopy(
const lagS = Number.isFinite(entry?.sample?.actualDelayMs)
? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000))
: null;
const behind = lagS === null ? '' : ` · shown ${lagS} s behind`;
const behind =
lagS === null
? ''
: ` · ${t('shown {seconds} s behind', { seconds: lagS })}`;
details.splice(
motion.length ? 2 : 1,
0,
(ownTime ? reported : `${reported} (feed time)`) + behind,
(ownTime ? reported : t('{reported} (feed time)', { reported })) + behind,
);
if (entry?.track?.count) {
const duration = Math.max(
0,
Math.min(entry.playT, entry.fixes.at(-1).t) - entry.fixes[0].t,
);
const minutes = Math.round(duration / 60000);
details.push(
`Available trail: ${Math.round(duration / 60000)} min${entry.track.truncated || entry.trailTruncated ? ' (limited)' : ''}`,
entry.track.truncated || entry.trailTruncated
? t('Available trail: {minutes} min (limited)', { minutes })
: t('Available trail: {minutes} min', { minutes }),
);
}
return { title, details };
@@ -461,12 +516,12 @@ export const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */
export const TRANSIT_MODE_WORD = Object.freeze({
bus: 'Bus',
tram: 'Tram',
subway: 'Subway',
rail: 'Train',
ferry: 'Ferry',
unknown: 'Transit vehicle',
bus: msg('Bus'),
tram: msg('Tram'),
subway: msg('Subway'),
rail: msg('Train'),
ferry: msg('Ferry'),
unknown: msg('Transit vehicle'),
});
/** Short, fixed-width mode word for the detection card's class field. */
+2
View File
@@ -8,6 +8,7 @@ import {
} from './policy.js';
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import { t } from '../../i18n/index.js';
/**
* Click-to-select one vehicle and a card that follows it.
@@ -60,6 +61,7 @@ export function createSelection({ state, services, parts }) {
now,
entry.fetchedAt,
entry,
t,
);
// The TEXT is what the throttle is for, and it is republished only when
// it has changed: the anchor is live through its getter, so re-sending
@@ -0,0 +1,75 @@
// K-37: the Transit selection card follows the UI language. The copy builder
// stays free of i18n (AI tools import policy.js); the browser hands it t().
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { buildTransitSelectionCopy } from './policy.js';
const feed = { name: 'Metro Transit', region: 'Minneapolis–St Paul, MN' };
const now = 1_788_936_960_000;
const record = {
id: 'T1',
label: 'T1',
routeId: 'Blue',
lat: 44.9,
lon: -93.4,
bearing: 248,
speedMps: 11.2,
timestamp: 1_788_936_900,
timestampSource: 'header',
stopId: '57458',
status: 'STOPPED_AT',
occupancy: 'FEW_SEATS_AVAILABLE',
};
test('en: the copy is unchanged with and without the i18n t()', () => {
setLocale('en');
const plain = buildTransitSelectionCopy(feed, record, 'subway', now);
assert.deepEqual(
buildTransitSelectionCopy(feed, record, 'subway', now, null, null, t),
plain,
);
assert.deepEqual(plain.details, [
'Subway · Metro Transit · Minneapolis–St Paul, MN',
'Reported 60 s ago (feed time)',
'reported 40 km/h · reported hdg 248°',
'Last report: stopped at stop 57458 · few seats available',
'Shown at street level · depth not in the feed',
'Vehicle T1',
]);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Transit card is in the UI language`, () => {
setLocale(locale);
try {
const copy = buildTransitSelectionCopy(
feed,
record,
'subway',
now,
null,
null,
t,
);
const text = [copy.title, ...copy.details].join(' | ');
for (const english of [
'Route',
'Subway',
'Reported',
'reported',
'Last report',
'few seats',
'street level',
'Vehicle',
'feed time',
])
assert.ok(
!text.includes(english),
`${locale}: "${english}" in ${text}`,
);
} finally {
setLocale('en');
}
});
}