mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user