feat(i18n, CourtDrawingTool, MembersOverviewSection): enhance internationalization and UI components

- Added new i18n keys for improved translations in the CourtDrawingTool and MembersOverviewSection components.
- Updated CourtDrawingTool to utilize translation keys for various UI elements, enhancing user experience and accessibility.
- Enhanced MembersOverviewSection with translation support for search and filter functionalities, improving usability for non-German speakers.
- Introduced new scripts in package.json for i18n auditing and status checking, streamlining localization management.
This commit is contained in:
Torsten Schulz (local)
2026-03-20 09:05:15 +01:00
parent 542d741428
commit cbc5054f1f
22 changed files with 5863 additions and 508 deletions

View File

@@ -0,0 +1,187 @@
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'src', 'i18n', 'locales');
const BASE_LOCALE = 'de.json';
const CORE_NAMESPACES = [
'common',
'navigation',
'club',
'members',
'diary',
'trainingStats',
'courtDrawingTool'
];
const GERMAN_MARKERS = [
'Mitglied',
'Mitglieder',
'Training',
'Verein',
'Bearbeiten',
'Loeschen',
'Löschen',
'Zugriff',
'Aufschlag',
'Ziel',
'Schlag',
'Gruppe',
'Gruppen',
'Heute',
'Zur Startseite'
];
const PLACEHOLDER_PATTERN = /\{[^}]+\}/g;
const NATIVE_CHARACTER_HINTS = {
fr: /[àâæçéèêëîïôœùûüÿ]/i,
es: /[áéíóúñü¡¿]/i,
it: /[àèéìíîòóù]/i,
pl: /[ąćęłńóśźż]/i,
de: /[äöüß]/i,
'de-CH': /[äöü]/i,
ja: /[\u3040-\u30ff\u4e00-\u9faf]/,
zh: /[\u4e00-\u9faf]/,
th: /[\u0e00-\u0e7f]/,
tl: null,
fil: null,
'en-GB': null,
'en-US': null,
'en-AU': null
};
function flatten(obj, prefix = '') {
const out = {};
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(out, flatten(value, nextKey));
} else {
out[nextKey] = value;
}
}
return out;
}
function pickNamespaces(source) {
return Object.fromEntries(CORE_NAMESPACES.map(namespace => [namespace, source[namespace] || {}]));
}
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function getLocaleCode(localeFile) {
return localeFile.replace(/\.json$/, '');
}
function extractPlaceholders(value) {
if (typeof value !== 'string') {
return [];
}
return Array.from(value.matchAll(PLACEHOLDER_PATTERN)).map(match => match[0]).sort();
}
function auditLocale(localeFile, baseFlat) {
const data = loadJson(path.join(LOCALES_DIR, localeFile));
const flat = flatten(pickNamespaces(data));
const localeCode = getLocaleCode(localeFile);
const missing = Object.keys(baseFlat).filter(key => !(key in flat));
const sameAsDe = Object.keys(baseFlat).filter(key => flat[key] === baseFlat[key]);
const germanHits = Object.entries(flat)
.filter(([, value]) => typeof value === 'string' && GERMAN_MARKERS.some(marker => value.includes(marker)))
.map(([key, value]) => `${key} = ${value}`);
const placeholderMismatches = Object.entries(baseFlat)
.filter(([key, value]) => {
if (!(key in flat) || typeof value !== 'string' || typeof flat[key] !== 'string') {
return false;
}
const basePlaceholders = extractPlaceholders(value);
const localePlaceholders = extractPlaceholders(flat[key]);
return JSON.stringify(basePlaceholders) !== JSON.stringify(localePlaceholders);
})
.map(([key, value]) => `${key} = base:${value} | locale:${flat[key]}`);
const nativeCharHint = NATIVE_CHARACTER_HINTS[localeCode];
const nativeCharCoverage = nativeCharHint
? Object.entries(flat)
.filter(([, value]) => typeof value === 'string' && value.trim())
.filter(([, value]) => nativeCharHint.test(value))
.length
: null;
return {
localeFile,
localeCode,
missing,
sameAsDe,
germanHits,
placeholderMismatches,
nativeCharCoverage
};
}
function printSection(title, entries, limit = 20) {
console.log(title);
if (!entries.length) {
console.log(' none');
return;
}
for (const entry of entries.slice(0, limit)) {
console.log(` ${entry}`);
}
if (entries.length > limit) {
console.log(` ... ${entries.length - limit} more`);
}
}
function main() {
const localeArgs = process.argv.slice(2);
const availableLocales = fs.readdirSync(LOCALES_DIR)
.filter(file => file.endsWith('.json') && file !== BASE_LOCALE)
.sort();
const locales = localeArgs.length
? localeArgs.map(arg => (arg.endsWith('.json') ? arg : `${arg}.json`))
: availableLocales;
const base = loadJson(path.join(LOCALES_DIR, BASE_LOCALE));
const baseFlat = flatten(pickNamespaces(base));
let hasFailures = false;
for (const localeFile of locales) {
if (!availableLocales.includes(localeFile)) {
console.error(`Unknown locale: ${localeFile}`);
hasFailures = true;
continue;
}
const result = auditLocale(localeFile, baseFlat);
console.log(`\n## ${localeFile}`);
console.log(`missing keys: ${result.missing.length}`);
console.log(`same as de: ${result.sameAsDe.length}`);
console.log(`german marker hits: ${result.germanHits.length}`);
console.log(`placeholder mismatches: ${result.placeholderMismatches.length}`);
if (result.nativeCharCoverage !== null) {
console.log(`entries with native characters: ${result.nativeCharCoverage}`);
}
if (result.missing.length || result.germanHits.length || result.placeholderMismatches.length) {
hasFailures = true;
}
printSection('Missing keys', result.missing);
printSection('Same as de', result.sameAsDe);
printSection('German marker hits', result.germanHits);
printSection('Placeholder mismatches', result.placeholderMismatches);
}
if (hasFailures) {
process.exitCode = 1;
}
}
main();

View File

@@ -0,0 +1,79 @@
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LOCALES_DIR = path.join(ROOT, 'src', 'i18n', 'locales');
const BASE_LOCALE = 'de.json';
const CORE_NAMESPACES = [
'common',
'navigation',
'club',
'members',
'diary',
'trainingStats',
'courtDrawingTool'
];
const BATCHES = {
A: ['fr', 'es'],
B: ['it', 'pl'],
C: ['ja', 'zh'],
D: ['th', 'tl', 'fil']
};
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function flatten(obj, prefix = '', result = {}) {
for (const [key, value] of Object.entries(obj || {})) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flatten(value, nextKey, result);
} else {
result[nextKey] = value;
}
}
return result;
}
function pickNamespaces(source) {
return Object.fromEntries(CORE_NAMESPACES.map(namespace => [namespace, source[namespace] || {}]));
}
function statusFor(localeFile, baseFlat) {
const locale = localeFile.replace(/\.json$/, '');
const data = loadJson(path.join(LOCALES_DIR, localeFile));
const flat = flatten(pickNamespaces(data));
const keys = Object.keys(baseFlat);
const missing = keys.filter(key => !(key in flat)).length;
const sameAsDe = keys.filter(key => flat[key] === baseFlat[key]).length;
const translated = keys.length - missing - sameAsDe;
const coverage = ((keys.length - missing) / keys.length) * 100;
const actualTranslationCoverage = (translated / keys.length) * 100;
return { locale, missing, sameAsDe, translated, coverage, actualTranslationCoverage };
}
function printGroup(title, locales, baseFlat) {
console.log(`\n# ${title}`);
console.log('locale | translated | same as de | missing | core coverage | actual translation');
console.log('--- | ---: | ---: | ---: | ---: | ---:');
for (const locale of locales) {
const result = statusFor(`${locale}.json`, baseFlat);
console.log(
`${result.locale} | ${result.translated} | ${result.sameAsDe} | ${result.missing} | ${result.coverage.toFixed(1)}% | ${result.actualTranslationCoverage.toFixed(1)}%`
);
}
}
function main() {
const base = loadJson(path.join(LOCALES_DIR, BASE_LOCALE));
const baseFlat = flatten(pickNamespaces(base));
printGroup('Batch A', BATCHES.A, baseFlat);
printGroup('Batch B', BATCHES.B, baseFlat);
printGroup('Batch C', BATCHES.C, baseFlat);
printGroup('Batch D', BATCHES.D, baseFlat);
}
main();