diff --git a/client/I18N_SETUP.md b/client/I18N_SETUP.md new file mode 100644 index 0000000..1c1b712 --- /dev/null +++ b/client/I18N_SETUP.md @@ -0,0 +1,240 @@ +# TieMeasureFlow i18n Setup Guide + +## Overview + +TieMeasureFlow supports complete internationalization (i18n) with: +- **Server-side**: Flask-Babel for Python/Jinja2 templates +- **Client-side**: alpinejs-i18n for JavaScript/Alpine.js +- **Supported languages**: Italian (IT - default) and English (EN) + +## Directory Structure + +``` +client/ +├── translations/ # Flask-Babel translations +│ ├── babel.cfg # Extraction config +│ ├── it/LC_MESSAGES/ +│ │ ├── messages.po # Italian strings +│ │ └── messages.mo # Compiled binary (generated) +│ └── en/LC_MESSAGES/ +│ ├── messages.po # English translations +│ └── messages.mo # Compiled binary (generated) +├── static/js/locales/ # Alpine.js i18n +│ ├── it.json # Italian client-side strings +│ └── en.json # English client-side strings +└── compile_translations.py # Utility to compile .po → .mo +``` + +## How It Works + +### Language Selection + +1. **User session**: Language stored in `session["language"]` (persistent) +2. **Browser fallback**: Uses `Accept-Language` header if no session +3. **Default**: Italian (IT) + +### Server-Side (Flask-Babel) + +**In Python code:** +```python +from flask_babel import gettext as _ + +flash(_("Profilo aggiornato con successo")) +``` + +**In Jinja2 templates:** +```jinja2 +

{{ _('Accedi al sistema') }}

+ +{# With variables #} +

{{ _('Benvenuto, %(name)s!', name=user.display_name) }}

+``` + +### Client-Side (Alpine.js i18n) + +**Load i18n in base template:** +```html + + +``` + +**Use in templates:** +```html + + +

+``` + +## Workflow + +### 1. Add New Strings + +**Python/Templates:** +1. Wrap strings with `_()` or `{{ _() }}` +2. Extract strings: `pybabel extract -F translations/babel.cfg -o translations/messages.pot .` +3. Update catalogs: `pybabel update -i translations/messages.pot -d translations` +4. Edit `translations/*/LC_MESSAGES/messages.po` files +5. Compile: `python compile_translations.py` + +**JavaScript/Alpine:** +1. Add keys to `static/js/locales/it.json` (Italian strings) +2. Add translations to `static/js/locales/en.json` +3. No compilation needed (loaded at runtime) + +### 2. Compile Translations + +After editing .po files: + +```bash +cd client +python compile_translations.py +``` + +This generates `.mo` binary files that Flask-Babel uses at runtime. + +### 3. Language Switching + +**Endpoint**: `GET /set-language/` + +**Example**: +```html +English +Italiano +``` + +**With Alpine.js**: +```html + +``` + +## Translation Keys + +### Server-Side (messages.po) + +56 strings covering: +- **Login**: "Accedi al sistema", "Credenziali non valide", etc. +- **Navbar**: "Misure", "Ricette", "Statistiche", etc. +- **Profile**: "Profilo Utente", "Salva Modifiche", etc. +- **Flash messages**: "Benvenuto, %(name)s!", "Logout effettuato", etc. +- **Common UI**: "Caricamento...", "Salva", "Annulla", "Conferma", etc. +- **Measurements**: "Conforme", "Non Conforme", etc. + +### Client-Side (locales/*.json) + +Structured keys: +```json +{ + "numpad": { "title", "clear", "submit", "decimal", "close" }, + "measurement": { "pass", "warning", "fail", "value", "next" }, + "common": { "loading", "save", "cancel", "confirm", "delete", "edit", ... }, + "theme": { "light", "dark", "toggle" }, + "language": { "switch", "it", "en" }, + "auth": { "login", "username", "password", "signIn", ... }, + "nav": { "measurements", "recipes", "statistics", ... }, + "profile": { "title", "displayName", "language", "theme", ... } +} +``` + +## Configuration + +**config.py** must define: +```python +BABEL_DEFAULT_LOCALE = 'it' +BABEL_TRANSLATION_DIRECTORIES = 'translations' +LANGUAGES = { + 'it': 'Italiano', + 'en': 'English' +} +``` + +**app.py** provides: +- `get_locale()`: Language selector function +- `/set-language/`: Language switch endpoint +- `inject_globals()`: Exposes `current_language` and `languages` to templates + +## Best Practices + +1. **Always use Italian as msgid**: Italian strings are the canonical keys +2. **Keep client/server keys synced**: Use similar naming across .po and .json +3. **Test both languages**: Switch languages and verify all pages +4. **Recompile after editing**: Run `compile_translations.py` after changing .po files +5. **Use variables for dynamic content**: `_('Welcome, %(name)s!', name=user)` or `$t('auth.welcome', {name})` +6. **Avoid hardcoded strings**: Always use translation functions + +## Troubleshooting + +**Translations not appearing:** +- Verify `.mo` files exist: `find translations -name "*.mo"` +- Check session language: `session["language"]` or browser console +- Recompile: `python compile_translations.py` +- Restart Flask server + +**Client-side i18n not working:** +- Check browser console for fetch errors +- Verify JSON files exist: `static/js/locales/{it,en}.json` +- Check Alpine.js i18n plugin is loaded +- Verify `Alpine.store('i18n')` is initialized + +**Missing translations show as keys:** +- Add missing keys to .po or .json files +- Recompile .po files if server-side +- Reload page if client-side + +## Commands Reference + +```bash +# Extract translatable strings from code +pybabel extract -F translations/babel.cfg -o translations/messages.pot . + +# Initialize new language (first time) +pybabel init -i translations/messages.pot -d translations -l en + +# Update existing catalogs with new strings +pybabel update -i translations/messages.pot -d translations + +# Compile .po to .mo (required after editing) +python compile_translations.py + +# Check translation statistics +msgfmt --statistics translations/it/LC_MESSAGES/messages.po +msgfmt --statistics translations/en/LC_MESSAGES/messages.po +``` + +## Future Expansion + +To add a new language (e.g., German): + +1. **Server-side**: + ```bash + pybabel init -i translations/messages.pot -d translations -l de + # Edit translations/de/LC_MESSAGES/messages.po + python compile_translations.py + ``` + +2. **Client-side**: + - Create `static/js/locales/de.json` + - Add to config: `LANGUAGES = {..., 'de': 'Deutsch'}` + +3. **Update loader**: Modify base template to support new locale + +## Resources + +- Flask-Babel: https://python-babel.github.io/flask-babel/ +- Babel: http://babel.pocoo.org/ +- Alpine.js i18n: https://github.com/alpine-collective/alpine-i18n +- PO file format: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html diff --git a/client/config.py b/client/config.py index 3ef0db5..51bd2f2 100644 --- a/client/config.py +++ b/client/config.py @@ -10,6 +10,7 @@ class Config: # Flask SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key") + DEBUG = os.getenv("FLASK_DEBUG", "0") == "1" # API Server connection API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000") @@ -17,6 +18,9 @@ class Config: # Session SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS) + PERMANENT_SESSION_LIFETIME = 28800 # 8 hours + WTF_CSRF_TIME_LIMIT = 3600 # 1 hour # Babel i18n BABEL_DEFAULT_LOCALE = "it" diff --git a/client/pytest.ini b/client/pytest.ini new file mode 100644 index 0000000..6605410 --- /dev/null +++ b/client/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* diff --git a/client/requirements.txt b/client/requirements.txt index bb25435..8340b35 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -9,3 +9,7 @@ urllib3>=2.0.0 # Utilities python-dotenv>=1.0.0 + +# Testing +pytest>=8.0.0 +coverage>=7.0.0 diff --git a/client/static/css/input.css b/client/static/css/input.css index b5c61c9..2cb0d51 100644 --- a/client/static/css/input.css +++ b/client/static/css/input.css @@ -1,3 +1,29 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Tablet Touch Targets (44px minimum) */ +@layer components { + .touch-target { + min-height: 44px; + min-width: 44px; + } +} + +@media (pointer: coarse) { + button, + .btn, + [role="button"], + a.inline-flex { + min-height: 44px; + } + + /* Flash message dismiss button */ + .fixed button[x-data] { + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/client/static/css/themes.css b/client/static/css/themes.css index 722dad8..2221253 100644 --- a/client/static/css/themes.css +++ b/client/static/css/themes.css @@ -372,3 +372,31 @@ textarea:focus-visible { [x-cloak] { display: none !important; } + + +/* ============================================================ + Tablet Touch Targets (44px minimum per WCAG 2.5.5) + ============================================================ */ + +.touch-target { + min-height: 44px; + min-width: 44px; +} + +@media (pointer: coarse) { + button, + .btn, + [role="button"], + a.inline-flex { + min-height: 44px; + } + + /* Flash message dismiss button */ + .fixed button[x-data] { + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/client/static/js/barcode.js b/client/static/js/barcode.js index bd193ac..e6cd460 100644 --- a/client/static/js/barcode.js +++ b/client/static/js/barcode.js @@ -5,6 +5,8 @@ */ function barcodeScanner() { + const _t = (key) => (window.BARCODE_I18N && window.BARCODE_I18N[key]) || key; + return { scanning: false, result: null, @@ -19,7 +21,7 @@ function barcodeScanner() { // Check library availability if (!window.Html5Qrcode) { - this.error = 'Scanner library not loaded'; + this.error = _t('scanner_lib_not_loaded'); this.scanning = false; return; } @@ -29,7 +31,7 @@ function barcodeScanner() { const devices = await Html5Qrcode.getCameras(); if (!devices || devices.length === 0) { - this.error = 'Nessuna fotocamera disponibile'; + this.error = _t('no_camera_available'); this.scanning = false; return; } @@ -72,7 +74,7 @@ function barcodeScanner() { } catch (err) { console.error('Scanner initialization error:', err); - this.error = 'Impossibile accedere alla fotocamera'; + this.error = _t('camera_access_error'); this.scanning = false; } }, diff --git a/client/static/js/csv-export.js b/client/static/js/csv-export.js index 7a9d021..8602d81 100644 --- a/client/static/js/csv-export.js +++ b/client/static/js/csv-export.js @@ -5,6 +5,8 @@ */ function csvExport() { + const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key; + return { // Locale-specific settings delimiter: ';', // Italian Excel standard @@ -50,20 +52,20 @@ function csvExport() { buildMeasurementCSV(measurements) { const headers = [ 'ID', - 'Subtask ID', - 'Nome Sottotask', - 'Valore Misurato', - 'Unità', - 'Valore Nominale', - 'Tolleranza +', - 'Tolleranza -', - 'Scarto', - 'Esito', - 'Numero Lotto', - 'Numero Seriale', - 'Metodo Input', - 'Data Misurazione', - 'Operatore' + _t('subtask_id'), + _t('subtask_name'), + _t('measured_value'), + _t('unit'), + _t('nominal_value'), + _t('tolerance_plus'), + _t('tolerance_minus'), + _t('deviation'), + _t('result'), + _t('lot_number'), + _t('serial_number'), + _t('input_method'), + _t('measurement_date'), + _t('operator') ]; let csv = headers.join(this.delimiter) + '\n'; @@ -97,28 +99,28 @@ function csvExport() { */ buildTaskSummaryCSV(task) { // Header section - let csv = 'RIEPILOGO ESECUZIONE TASK\n\n'; + let csv = _t('task_summary_title') + '\n\n'; - csv += `Task ID${this.delimiter}${task.id}\n`; - csv += `Nome Task${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`; - csv += `Ricetta${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`; - csv += `Operatore${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`; - csv += `Data Inizio${this.delimiter}${this.formatDateTime(task.started_at)}\n`; - csv += `Data Fine${this.delimiter}${this.formatDateTime(task.completed_at)}\n`; - csv += `Stato${this.delimiter}${task.status || ''}\n`; - csv += `Lotto${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`; - csv += `Seriale${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`; + csv += `${_t('task_id')}${this.delimiter}${task.id}\n`; + csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`; + csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`; + csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`; + csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`; + csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`; + csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`; + csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`; + csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`; // Statistics - csv += `\nSTATISTICHE\n`; + csv += `\n${_t('statistics')}\n`; const stats = this.calculateStats(task.measurements); - csv += `Totale Misure${this.delimiter}${stats.total}\n`; - csv += `Passate${this.delimiter}${stats.passed}\n`; - csv += `Fallite${this.delimiter}${stats.failed}\n`; - csv += `Percentuale Successo${this.delimiter}${this.formatNumber(stats.passRate)}%\n`; + csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`; + csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`; + csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`; + csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`; // Measurements detail - csv += `\nDETTAGLIO MISURE\n`; + csv += `\n${_t('measurement_details')}\n`; csv += this.buildMeasurementCSV(task.measurements); return csv; diff --git a/client/templates/base.html b/client/templates/base.html index 0b1df4f..e6f21a6 100644 --- a/client/templates/base.html +++ b/client/templates/base.html @@ -134,7 +134,7 @@ {{ message }} -