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 }}
-