diff --git a/frontend/TRANSLATION_WORKFLOW.md b/frontend/TRANSLATION_WORKFLOW.md new file mode 100644 index 00000000..f4c8c602 --- /dev/null +++ b/frontend/TRANSLATION_WORKFLOW.md @@ -0,0 +1,132 @@ +# Translation Workflow + +This is the project standard for translating UI texts safely and batch-wise. + +## Scope +Always translate these core namespaces first: + +- `common` +- `navigation` +- `club` +- `members` +- `diary` +- `trainingStats` +- `courtDrawingTool` + +`de.json` is the source of truth. All other locales are validated against it. + +## Acceptance Criteria +- No missing keys in the target locale +- No German fallback text left in non-German locales for the translated batch +- No placeholder mismatches such as `{count}` becoming `{anzahl}` +- Native characters must be used correctly +- `npm run build` must pass + +## Native Characters +Do not replace native characters with ASCII fallbacks. Use the actual writing system or accents of the target language. + +Examples: +- German: use `ä`, `ö`, `ü`, `ß`, not `ae`, `oe`, `ue`, `ss` +- French: use `é`, `è`, `à`, `ç` +- Spanish: use `á`, `é`, `í`, `ó`, `ú`, `ñ` +- Italian: use `à`, `è`, `é`, `ì`, `ò`, `ù` +- Polish: use `ą`, `ć`, `ę`, `ł`, `ń`, `ó`, `ś`, `ź`, `ż` +- Japanese: use Japanese scripts directly, not romanized fallback +- Chinese: use Chinese characters directly, not pinyin fallback +- Thai: use Thai script directly, not Latin transliteration + +## Batches +- Batch A: `fr`, `es` +- Batch B: `it`, `pl` +- Batch C: `ja`, `zh` +- Batch D: `th`, `tl`, `fil` + +Run one batch at a time. Finish the whole batch before moving on. + +## Order Per Language +1. `common` +2. `navigation` +3. `club` +4. `members` +5. `diary` +6. `trainingStats` +7. `courtDrawingTool` + +This keeps global UI stable first, then the larger feature areas. + +## Per-Language Process +1. Run the status report to see the current batch state. +2. Open the target locale JSON. +3. Translate only visible UI text. +4. Keep product and domain names unchanged where appropriate: + - `myTischtennis` + - `click-TT` + - `TTR` + - `QTTR` +5. Keep placeholders unchanged: + - correct: `"{count} open"` + - wrong: `"{anzahl} offen"` +6. Save the file as valid UTF-8 JSON. + +## Audit +Run the status report before starting a batch: + +```bash +cd frontend +npm run i18n:status +``` + +Run the audit before and after translation work: + +```bash +cd frontend +npm run i18n:audit -- fr es +``` + +The audit checks: +- missing keys in the core namespaces +- values still identical to German +- obvious German words left in non-German locales +- placeholder mismatches +- native-character coverage hints for languages that should visibly use them + +## Build Check +After each language batch: + +```bash +cd frontend +npm run build +``` + +## Suggested Working Loop +1. `npm run i18n:status` +2. Pick one batch +3. Translate one namespace across the whole batch, or one whole language, but stay consistent +4. Run `npm run i18n:audit -- ` +5. Fix remaining `same as de`, German leftovers, or placeholder issues +6. Run `npm run build` +7. Do a manual UI pass + +## Manual UI Check +Review these areas in the browser: +- `/members` +- `/diary` +- `/training-stats` +- exercise editor / court drawing dialog + +Check especially: +- clipped labels +- wrong placeholders +- mixed-language screens +- broken interpolation text + +## Definition of Done Per Batch +- `missing keys: 0` +- `german marker hits: 0` +- `placeholder mismatches: 0` +- `same as de` only for intentional product names or abbreviations +- manual spot check completed on: + - `/members` + - `/diary` + - `/training-stats` + - exercise editor / court drawing dialog diff --git a/frontend/package.json b/frontend/package.json index d03a9e0d..96244bc6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "serve": "vite preview" + "serve": "vite preview", + "i18n:audit": "node scripts/audit-i18n.js", + "i18n:status": "node scripts/translation-status.js" }, "dependencies": { "axios": "^1.7.3", diff --git a/frontend/scripts/audit-i18n.js b/frontend/scripts/audit-i18n.js new file mode 100644 index 00000000..263e9dee --- /dev/null +++ b/frontend/scripts/audit-i18n.js @@ -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(); diff --git a/frontend/scripts/translation-status.js b/frontend/scripts/translation-status.js new file mode 100644 index 00000000..d352b5f2 --- /dev/null +++ b/frontend/scripts/translation-status.js @@ -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(); diff --git a/frontend/src/components/CourtDrawingTool.vue b/frontend/src/components/CourtDrawingTool.vue index 6e0e60a3..cdabfbd4 100644 --- a/frontend/src/components/CourtDrawingTool.vue +++ b/frontend/src/components/CourtDrawingTool.vue @@ -7,8 +7,8 @@
@@ -31,8 +31,8 @@
@@ -79,8 +79,8 @@
@@ -104,8 +104,8 @@
-
4. Folgeschläge
-

Optional weitere Bälle als Liste aufbauen.

+
{{ $t('courtDrawingTool.stepAdditionalStrokes') }}
+

{{ $t('courtDrawingTool.stepAdditionalStrokesHint') }}

{{ index + 2 }}
{{ formatAdditionalStroke(stroke) }}
- +
-
Noch keine Folgeschläge angelegt.
+
{{ $t('courtDrawingTool.noAdditionalStrokes') }}
- Seite + {{ $t('courtDrawingTool.strokeSide') }}
-

Kürzel: {{ getFullCode() }}

-

Titel: {{ getFullTitle() }}

+

{{ $t('courtDrawingTool.codeLabel') }}: {{ getFullCode() }}

+

{{ $t('courtDrawingTool.titleLabel') }}: {{ getFullTitle() }}

@@ -390,9 +390,9 @@ export default { }, startPositionOptions() { return [ - { value: 'AS1', short: 'AS1', label: 'links' }, - { value: 'AS2', short: 'AS2', label: 'mitte' }, - { value: 'AS3', short: 'AS3', label: 'rechts' } + { value: 'AS1', short: 'AS1', label: this.$t('courtDrawingTool.startLeft') }, + { value: 'AS2', short: 'AS2', label: this.$t('courtDrawingTool.startMiddle') }, + { value: 'AS3', short: 'AS3', label: this.$t('courtDrawingTool.startRight') } ]; }, mainSpinOptions() { @@ -406,14 +406,14 @@ export default { }, additionalStrokeTypeOptions() { return [ - { value: 'US', short: 'US', label: 'Schupf' }, - { value: 'OS', short: 'OS', label: 'Konter' }, - { value: 'TS', short: 'TS', label: 'Topspin' }, - { value: 'F', short: 'F', label: 'Flip' }, - { value: 'B', short: 'B', label: 'Block' }, - { value: 'SCH', short: 'SCH', label: 'Schuss' }, - { value: 'SAB', short: 'SAB', label: 'Schnittabwehr' }, - { value: 'BAB', short: 'BAB', label: 'Ballonabwehr' } + { value: 'US', short: 'US', label: this.$t('courtDrawingTool.strokeTypePush') }, + { value: 'OS', short: 'OS', label: this.$t('courtDrawingTool.strokeTypeCounter') }, + { value: 'TS', short: 'TS', label: this.$t('courtDrawingTool.strokeTypeTopspin') }, + { value: 'F', short: 'F', label: this.$t('courtDrawingTool.strokeTypeFlip') }, + { value: 'B', short: 'B', label: this.$t('courtDrawingTool.strokeTypeBlock') }, + { value: 'SCH', short: 'SCH', label: this.$t('courtDrawingTool.strokeTypeSmash') }, + { value: 'SAB', short: 'SAB', label: this.$t('courtDrawingTool.strokeTypeChopDefense') }, + { value: 'BAB', short: 'BAB', label: this.$t('courtDrawingTool.strokeTypeLobDefense') } ]; }, // Reihenfolge der Positionen für Hauptschlag basierend auf Richtung @@ -542,20 +542,20 @@ export default { }, formatTargetPosition(position) { const labels = { - '1': 'Vorhand lang', - '2': 'Mitte lang', - '3': 'Rückhand lang', - '4': 'Vorhand halblang', - '5': 'Mitte halblang', - '6': 'Rückhand halblang', - '7': 'Vorhand kurz', - '8': 'Mitte kurz', - '9': 'Rückhand kurz' + '1': this.$t('courtDrawingTool.targetForehandLong'), + '2': this.$t('courtDrawingTool.targetMiddleLong'), + '3': this.$t('courtDrawingTool.targetBackhandLong'), + '4': this.$t('courtDrawingTool.targetForehandHalfLong'), + '5': this.$t('courtDrawingTool.targetMiddleHalfLong'), + '6': this.$t('courtDrawingTool.targetBackhandHalfLong'), + '7': this.$t('courtDrawingTool.targetForehandShort'), + '8': this.$t('courtDrawingTool.targetMiddleShort'), + '9': this.$t('courtDrawingTool.targetBackhandShort') }; return labels[String(position)] || String(position); }, formatAdditionalStroke(stroke) { - return `${this.formatStrokeSide(stroke.side)} ${this.formatAdditionalStrokeType(stroke.type)} nach ${this.formatTargetPosition(stroke.targetPosition)}`; + return `${this.formatStrokeSide(stroke.side)} ${this.formatAdditionalStrokeType(stroke.type)} ${this.$t('courtDrawingTool.toTarget')} ${this.formatTargetPosition(stroke.targetPosition)}`; }, removeAdditionalStroke(index) { this.additionalStrokes.splice(index, 1); @@ -1620,40 +1620,22 @@ export default { getFullTitle() { if (!this.selectedStartPosition || !this.strokeType) return ''; - const strokeName = this.strokeType === 'VH' ? 'Vorhand' : 'Rückhand'; - let title = `Aufschlag ${strokeName}`; + const strokeName = this.strokeType === 'VH' ? this.$t('courtDrawingTool.forehand') : this.$t('courtDrawingTool.backhand'); + let title = `${this.$t('courtDrawingTool.serviceTitle')} ${strokeName}`; if (this.spinType) { - title = `Aufschlag ${strokeName} ${this.spinType}`; + title = `${this.$t('courtDrawingTool.serviceTitle')} ${strokeName} ${this.spinType}`; } if (this.targetPosition) { - // Ausgeschriebene Beschreibungen für Zielpositionen - const targetDescriptionMap = { - '1': 'Vorhand Lang', '2': 'Mitte Lang', '3': 'Rückhand Lang', - '4': 'Vorhand Halblang', '5': 'Mitte Halblang', '6': 'Rückhand Halblang', - '7': 'Vorhand Kurz', '8': 'Mitte Kurz', '9': 'Rückhand Kurz' - }; - const targetDescription = targetDescriptionMap[this.targetPosition] || this.targetPosition; + const targetDescription = this.formatTargetPosition(this.targetPosition); title += ` → ${targetDescription}`; } // Zusätzliche Schläge hinzufügen if (this.additionalStrokes.length > 0) { this.additionalStrokes.forEach(stroke => { - const strokeNameMap = { - 'US': 'Schupf', 'OS': 'Konter', 'TS': 'Topspin', 'F': 'Flip', 'B': 'Block', - 'SCH': 'Schuss', 'SAB': 'Schnittabwehr', 'BAB': 'Ballonabwehr' - }; - const sideNameMap = { - 'VH': 'Vorhand', 'RH': 'Rückhand' - }; - const targetDescriptionMap = { - '1': 'Vorhand Lang', '2': 'Mitte Lang', '3': 'Rückhand Lang', - '4': 'Vorhand Halblang', '5': 'Mitte Halblang', '6': 'Rückhand Halblang', - '7': 'Vorhand Kurz', '8': 'Mitte Kurz', '9': 'Rückhand Kurz' - }; - const strokeName = strokeNameMap[stroke.type] || stroke.type; - const sideName = sideNameMap[stroke.side] || stroke.side; - const targetDescription = targetDescriptionMap[stroke.targetPosition] || stroke.targetPosition; + const strokeName = this.formatAdditionalStrokeType(stroke.type); + const sideName = this.formatStrokeSide(stroke.side); + const targetDescription = this.formatTargetPosition(stroke.targetPosition); title += ` / ${sideName} ${strokeName} → ${targetDescription}`; }); } diff --git a/frontend/src/components/members/MembersOverviewSection.vue b/frontend/src/components/members/MembersOverviewSection.vue index 1794ec90..09c08f86 100644 --- a/frontend/src/components/members/MembersOverviewSection.vue +++ b/frontend/src/components/members/MembersOverviewSection.vue @@ -36,7 +36,7 @@
- Suche und Filter + {{ $t('members.searchAndFilter') }}
@@ -69,11 +69,11 @@ - +
- +
- @@ -91,7 +91,7 @@ min="0" max="120" class="filter-select age-range-input" - placeholder="bis" + :placeholder="$t('members.ageToPlaceholder')" @input="$emit('update:selected-age-to', $event.target.value)" >
@@ -150,7 +150,7 @@
- Sammelaktionen + {{ $t('members.bulkActions') }}