Add german translation

This commit is contained in:
Simon Stürz 2025-12-16 15:52:07 +01:00
parent 80897f0e62
commit 395e1ce5cb
3 changed files with 515 additions and 145 deletions

View File

@ -1,5 +1,8 @@
class DashboardApp {
constructor() {
this.locale = this.resolveLocale();
this.translations = this.buildTranslations();
this.elements = {
loginOverlay: document.getElementById('loginOverlay'),
loginForm: document.getElementById('loginForm'),
@ -72,6 +75,8 @@ class DashboardApp {
{ key: 'digitalInputMode', label: 'Digital input' }
];
this.translateDocument();
this.updateEasterEggScore();
this.renderStaticTemplates();
this.attachEventListeners();
this.initializePanelNavigation();
@ -80,6 +85,351 @@ class DashboardApp {
this.updateCarSelector();
}
resolveLocale() {
const overrideKey = 'evdash.language';
try {
const stored = window.localStorage.getItem(overrideKey);
if (stored && typeof stored === 'string')
return stored.trim().toLowerCase().startsWith('de') ? 'de' : 'en';
} catch (error) {
// ignore
}
const candidates = Array.isArray(navigator.languages) && navigator.languages.length
? navigator.languages
: [navigator.language || 'en'];
const normalized = candidates
.filter(Boolean)
.map(value => String(value).toLowerCase());
if (normalized.some(value => value === 'de' || value.startsWith('de-')))
return 'de';
return 'en';
}
buildTranslations() {
return {
en: {
'header.tagline': 'Monitor & troubleshoot EV chargers.',
'header.awaitingLogin': 'Awaiting login…',
'header.authenticateHint': 'Load the dashboard to authenticate.',
'header.logout': 'Logout',
'sidebar.dashboardSections': 'Dashboard sections',
'sidebar.sections': 'Sections',
'sidebar.workspace': 'Workspace',
'sidebar.overview': 'Overview',
'sidebar.chargers': 'Chargers',
'sidebar.chargersSubtitle': 'Live table & telemetry',
'sidebar.sessions': 'Charging sessions',
'sidebar.sessionsSubtitle': 'History of charging sessions',
'sidebar.help': 'Help',
'sidebar.helpSubtitle': 'API concept & debug logs',
'sidebar.version': 'Version',
'chargers.liveOverview': 'Live overview',
'chargers.title': 'Chargers',
'chargers.empty': 'No chargers loaded yet.',
'chargers.columns.name': 'Name',
'chargers.columns.car': 'Car',
'chargers.columns.energyManagerMode': 'Energy manager mode',
'chargers.columns.connected': 'Connected',
'chargers.columns.status': 'Status',
'chargers.columns.chargingCurrent': 'Charging current',
'chargers.columns.chargingPhases': 'Charging phases',
'chargers.columns.currentPower': 'Current power',
'chargers.columns.sessionEnergy': 'Session energy',
'chargers.columns.version': 'Version',
'chargers.columns.temperature': 'Temperature',
'chargers.columns.digitalInputMode': 'Digital input',
'sessions.history': 'History',
'sessions.title': 'Charging sessions',
'sessions.filterCar': 'Car',
'sessions.allCars': 'All cars',
'sessions.filterStartDate': 'Start date',
'sessions.filterEndDate': 'End date',
'sessions.fetch': 'Fetch sessions',
'sessions.downloadCsv': 'Download CSV',
'sessions.helper': 'Optionally filter charging sessions by car and time range before downloading.',
'sessions.columns.name': 'Name',
'sessions.columns.charger': 'Charger',
'sessions.columns.car': 'Car',
'sessions.columns.start': 'Start',
'sessions.columns.end': 'End',
'sessions.columns.energy': 'Energy (kWh)',
'sessions.emptyFetched': 'No charging sessions fetched yet.',
'sessions.noneFound': 'No charging sessions found.',
'sessions.noneInRange': 'No charging sessions match the selected time range.',
'sessions.fetchFailed': 'Failed to fetch charging sessions.',
'sessions.requestFailed': 'Unable to request charging sessions. Check the connection status.',
'sessions.displayFailed': 'Unable to display charging sessions.',
'sessions.startBeforeEnd': 'Start date must be earlier than end date.',
'sessions.sessionIdLabel': 'Session {id}',
'help.guides': 'Guides',
'help.title': 'API Contract',
'help.helper': 'Use <code>app.sendAction(action, payload)</code> after authenticating.',
'help.requestTemplate': 'Request template',
'help.responses': 'Responses',
'help.diagnostics': 'Diagnostics',
'help.lastMessage': 'Last WebSocket message',
'help.diagnosticsHelper': 'Sign in to reuse the stored session and inspect backend payloads.',
'help.noMessagesYet': 'No messages received yet.',
'help.debugging': 'Debugging',
'help.sessionsPayload': 'Charging sessions payload',
'help.sessionsPayloadHelper': 'Raw session JSON for troubleshooting.',
'help.reference': 'Reference',
'help.chargerTableBasics': 'Charger table basics',
'help.referenceBullet1': 'The dashboard keeps one row per charger ID and updates it with backend notifications.',
'help.referenceBullet2': 'Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.',
'help.referenceBullet3': 'Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.',
'easterEgg.hiddenTreat': 'Hidden treat',
'easterEgg.title': 'Grid Dash',
'easterEgg.close': 'Close',
'easterEgg.instructions': 'Use arrow keys or WASD to drive the tiny EV and catch lightning bolts. Press Esc or close to exit.',
'easterEgg.score': 'Score: {score}',
'easterEgg.hint': 'Stay inside the grid!',
'easterEgg.canvasLabel': 'Mini game canvas',
'login.title': 'Sign in',
'login.required': 'Authorization required.',
'login.username': 'Username',
'login.password': 'Password',
'login.helper': 'Contact the administrator to receive valid credentials.',
'login.signIn': 'Sign in',
'login.signingIn': 'Signing in…',
'login.emptyCredentials': 'Username and password are required.',
'login.failed': 'Login failed. Please try again.',
'login.networkError': 'Unable to reach the login endpoint. Please check your connection.',
'login.unexpectedResponse': 'Received an unexpected response from the server.',
'login.invalidResponse': 'Invalid response from server.',
'login.invalidRequest': 'The login request was malformed. Please reload the page and try again.',
'login.unauthorized': 'The provided credentials were not accepted.',
'connection.connecting': 'Connecting…',
'connection.authenticating': 'Authenticating…',
'connection.connected': 'Connected',
'connection.disconnected': 'Disconnected',
'connection.error': 'Connection error',
'connection.authenticationRequired': 'Authentication required',
'connection.loggedOut': 'Logged out',
'connection.sessionExpired': 'Session expired. Please sign in again.',
'connection.sessionExpiredRestore': 'Your session has expired. Please sign in again.',
'connection.restoreFailed': 'We could not restore your previous session. Please sign in again.',
'connection.loggedOutOverlay': 'You have been logged out.',
'connection.authFailed': 'Authentication failed. Please try again.',
'value.true': 'True',
'value.false': 'False',
'value.yes': 'Yes',
'value.no': 'No',
'value.unknownWithValue': 'Unknown ({value})',
'energyManagerMode.quick': 'Quick',
'energyManagerMode.eco': 'Eco',
'energyManagerMode.ecoTime': 'Eco + Time',
'digitalInputMode.chargingAllowed': 'Charging allowed',
'digitalInputMode.chargingAllowedInverted': 'Charging allowed inverted',
'digitalInputMode.pwmAndS0': 'PWM and S0 signaling',
'digitalInputMode.limitAndS0': 'Limit and S0 signaling',
'csv.sessionId': 'Session ID',
'csv.chargerName': 'Charger name',
'csv.chargerSerialNumber': 'Charger serial number',
'csv.car': 'Car',
'csv.start': 'Start',
'csv.end': 'End',
'csv.energyKwh': 'Energy [kWh]',
'csv.meterStartKwh': 'Meter start [kWh]',
'csv.meterEndKwh': 'Meter end [kWh]',
'ws.parseFailed': 'Failed to parse message: {error}'
},
de: {
'header.tagline': 'Überwachen & Fehleranalyse von EV-Ladestationen.',
'header.awaitingLogin': 'Warte auf Anmeldung…',
'header.authenticateHint': 'Dashboard laden, um dich zu authentifizieren.',
'header.logout': 'Abmelden',
'sidebar.dashboardSections': 'Dashboard-Bereiche',
'sidebar.sections': 'Bereiche',
'sidebar.workspace': 'Arbeitsbereich',
'sidebar.overview': 'Übersicht',
'sidebar.chargers': 'Ladestationen',
'sidebar.chargersSubtitle': 'Live-Tabelle & Telemetrie',
'sidebar.sessions': 'Ladevorgänge',
'sidebar.sessionsSubtitle': 'Historie der Ladevorgänge',
'sidebar.help': 'Hilfe',
'sidebar.helpSubtitle': 'API-Konzept & Debug-Logs',
'sidebar.version': 'Version',
'chargers.liveOverview': 'Live-Übersicht',
'chargers.title': 'Ladestationen',
'chargers.empty': 'Noch keine Ladestationen geladen.',
'chargers.columns.name': 'Name',
'chargers.columns.car': 'Fahrzeug',
'chargers.columns.energyManagerMode': 'Energiemanager-Modus',
'chargers.columns.connected': 'Verbunden',
'chargers.columns.status': 'Status',
'chargers.columns.chargingCurrent': 'Ladestrom',
'chargers.columns.chargingPhases': 'Ladephasen',
'chargers.columns.currentPower': 'Aktuelle Leistung',
'chargers.columns.sessionEnergy': 'Energie (Sitzung)',
'chargers.columns.version': 'Version',
'chargers.columns.temperature': 'Temperatur',
'chargers.columns.digitalInputMode': 'Digitaler Eingang',
'sessions.history': 'Historie',
'sessions.title': 'Ladevorgänge',
'sessions.filterCar': 'Fahrzeug',
'sessions.allCars': 'Alle Fahrzeuge',
'sessions.filterStartDate': 'Startdatum',
'sessions.filterEndDate': 'Enddatum',
'sessions.fetch': 'Ladevorgänge laden',
'sessions.downloadCsv': 'CSV herunterladen',
'sessions.helper': 'Optional nach Fahrzeug und Zeitraum filtern, bevor du die CSV herunterlädst.',
'sessions.columns.name': 'Name',
'sessions.columns.charger': 'Ladestation',
'sessions.columns.car': 'Fahrzeug',
'sessions.columns.start': 'Start',
'sessions.columns.end': 'Ende',
'sessions.columns.energy': 'Energie (kWh)',
'sessions.emptyFetched': 'Noch keine Ladevorgänge geladen.',
'sessions.noneFound': 'Keine Ladevorgänge gefunden.',
'sessions.noneInRange': 'Keine Ladevorgänge im ausgewählten Zeitraum.',
'sessions.fetchFailed': 'Ladevorgänge konnten nicht geladen werden.',
'sessions.requestFailed': 'Ladevorgänge konnten nicht angefragt werden. Bitte Verbindungsstatus prüfen.',
'sessions.displayFailed': 'Ladevorgänge können nicht angezeigt werden.',
'sessions.startBeforeEnd': 'Das Startdatum muss vor dem Enddatum liegen.',
'sessions.sessionIdLabel': 'Sitzung {id}',
'help.guides': 'Leitfäden',
'help.title': 'API-Vertrag',
'help.helper': 'Nach der Authentifizierung kannst du <code>app.sendAction(action, payload)</code> verwenden.',
'help.requestTemplate': 'Request-Vorlage',
'help.responses': 'Antworten',
'help.diagnostics': 'Diagnose',
'help.lastMessage': 'Letzte WebSocket-Nachricht',
'help.diagnosticsHelper': 'Anmelden, um die gespeicherte Sitzung zu nutzen und Backend-Payloads zu prüfen.',
'help.noMessagesYet': 'Noch keine Nachrichten empfangen.',
'help.debugging': 'Debugging',
'help.sessionsPayload': 'Ladevorgänge-Payload',
'help.sessionsPayloadHelper': 'Rohes Session-JSON zur Fehlersuche.',
'help.reference': 'Referenz',
'help.chargerTableBasics': 'Grundlagen der Ladestationen-Tabelle',
'help.referenceBullet1': 'Das Dashboard hält eine Zeile pro Ladestations-ID und aktualisiert sie über Backend-Benachrichtigungen.',
'help.referenceBullet2': 'Die Spalten folgen der Reihenfolge aus <code>EvDashEngine::packCharger</code>, sodass neue Eigenschaften automatisch erscheinen.',
'help.referenceBullet3': 'Branding (Farben, Schrift) wird über CSS-Variablen am Anfang dieser Datei gesteuert.',
'easterEgg.hiddenTreat': 'Verstecktes Extra',
'easterEgg.title': 'Grid Dash',
'easterEgg.close': 'Schließen',
'easterEgg.instructions': 'Mit Pfeiltasten oder WASD fahren, Blitze einsammeln. Mit Esc oder Schließen beenden.',
'easterEgg.score': 'Punkte: {score}',
'easterEgg.hint': 'Bleib im Raster!',
'easterEgg.canvasLabel': 'Mini-Spiel-Leinwand',
'login.title': 'Anmelden',
'login.required': 'Autorisierung erforderlich.',
'login.username': 'Benutzername',
'login.password': 'Passwort',
'login.helper': 'Wende dich an den Administrator, um gültige Zugangsdaten zu erhalten.',
'login.signIn': 'Anmelden',
'login.signingIn': 'Anmeldung…',
'login.emptyCredentials': 'Benutzername und Passwort sind erforderlich.',
'login.failed': 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
'login.networkError': 'Login-Endpunkt nicht erreichbar. Bitte Verbindung prüfen.',
'login.unexpectedResponse': 'Unerwartete Antwort vom Server erhalten.',
'login.invalidResponse': 'Ungültige Serverantwort.',
'login.invalidRequest': 'Die Login-Anfrage war fehlerhaft. Bitte Seite neu laden und erneut versuchen.',
'login.unauthorized': 'Die eingegebenen Zugangsdaten wurden nicht akzeptiert.',
'connection.connecting': 'Verbinden…',
'connection.authenticating': 'Authentifizierung…',
'connection.connected': 'Verbunden',
'connection.disconnected': 'Getrennt',
'connection.error': 'Verbindungsfehler',
'connection.authenticationRequired': 'Authentifizierung erforderlich',
'connection.loggedOut': 'Abgemeldet',
'connection.sessionExpired': 'Sitzung abgelaufen. Bitte erneut anmelden.',
'connection.sessionExpiredRestore': 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.',
'connection.restoreFailed': 'Die vorige Sitzung konnte nicht wiederhergestellt werden. Bitte erneut anmelden.',
'connection.loggedOutOverlay': 'Du wurdest abgemeldet.',
'connection.authFailed': 'Authentifizierung fehlgeschlagen. Bitte erneut versuchen.',
'value.true': 'Wahr',
'value.false': 'Falsch',
'value.yes': 'Ja',
'value.no': 'Nein',
'value.unknownWithValue': 'Unbekannt ({value})',
'energyManagerMode.quick': 'Schnell',
'energyManagerMode.eco': 'Eco',
'energyManagerMode.ecoTime': 'Eco + Zeit',
'digitalInputMode.chargingAllowed': 'Laden erlaubt',
'digitalInputMode.chargingAllowedInverted': 'Laden erlaubt (invertiert)',
'digitalInputMode.pwmAndS0': 'PWM- und S0-Signalisierung',
'digitalInputMode.limitAndS0': 'Limit- und S0-Signalisierung',
'csv.sessionId': 'Sitzungs-ID',
'csv.chargerName': 'Ladestationsname',
'csv.chargerSerialNumber': 'Seriennummer der Ladestation',
'csv.car': 'Fahrzeug',
'csv.start': 'Start',
'csv.end': 'Ende',
'csv.energyKwh': 'Energie [kWh]',
'csv.meterStartKwh': 'Zählerstand Start [kWh]',
'csv.meterEndKwh': 'Zählerstand Ende [kWh]',
'ws.parseFailed': 'Nachricht konnte nicht gelesen werden: {error}'
}
};
}
t(key, variables) {
const locale = this.locale in this.translations ? this.locale : 'en';
const table = this.translations[locale] || {};
const fallback = this.translations.en || {};
let text = (key && key in table) ? table[key] : (key in fallback ? fallback[key] : String(key));
if (variables && typeof variables === 'object') {
Object.entries(variables).forEach(([name, value]) => {
text = text.replaceAll(`{${name}}`, value === undefined || value === null ? '' : String(value));
});
}
return text;
}
translateDocument() {
try {
document.documentElement.lang = this.locale;
} catch (error) {
// ignore
}
const nodes = document.querySelectorAll('[data-i18n]');
nodes.forEach(node => {
const key = node.dataset.i18n;
if (!key)
return;
const attr = node.dataset.i18nAttr;
const text = this.t(key);
if (attr)
node.setAttribute(attr, text);
else if (node.dataset.i18nMode === 'html')
node.innerHTML = text;
else
node.textContent = text;
});
}
attachEventListeners() {
if (this.elements.loginForm) {
this.elements.loginForm.addEventListener('submit', event => {
@ -292,7 +642,7 @@ class DashboardApp {
const expiresAt = new Date(parsed.expiresAt);
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
this.showLoginOverlay(this.t('connection.sessionExpiredRestore'));
return;
}
@ -300,13 +650,13 @@ class DashboardApp {
this.tokenExpiry = expiresAt;
this.username = parsed.username || null;
this.scheduleTokenRefresh();
this.updateSessionUser();
this.hideLoginOverlay();
this.connectWebSocket();
this.updateSessionUser();
this.hideLoginOverlay();
this.connectWebSocket();
} catch (error) {
console.warn('Failed to restore session', error);
this.clearSession();
this.showLoginOverlay('We could not restore your previous session. Please sign in again.');
this.showLoginOverlay(this.t('connection.restoreFailed'));
}
}
@ -318,7 +668,7 @@ class DashboardApp {
const password = this.elements.password.value;
if (!username || !password) {
this.showLoginError('Username and password are required.');
this.showLoginError(this.t('login.emptyCredentials'));
return;
}
@ -331,7 +681,7 @@ class DashboardApp {
this.connectWebSocket(true);
})
.catch(error => {
const message = error && error.message ? error.message : 'Login failed. Please try again.';
const message = error && error.message ? error.message : this.t('login.failed');
this.showLoginError(message);
})
.finally(() => {
@ -353,7 +703,7 @@ class DashboardApp {
});
} catch (networkError) {
console.warn('Login request failed', networkError);
throw new Error('Unable to reach the login endpoint. Please check your connection.');
throw new Error(this.t('login.networkError'));
}
let data;
@ -361,7 +711,7 @@ class DashboardApp {
data = await response.json();
} catch (parseError) {
console.warn('Failed to parse login response', parseError);
throw new Error('Received an unexpected response from the server.');
throw new Error(this.t('login.unexpectedResponse'));
}
if (!response.ok || !data.success) {
@ -370,7 +720,7 @@ class DashboardApp {
}
if (!data.token || !data.expiresAt)
throw new Error('Invalid response from server.');
throw new Error(this.t('login.invalidResponse'));
return {
token: data.token,
@ -381,11 +731,11 @@ class DashboardApp {
describeLoginError(code) {
switch (code) {
case 'invalidRequest':
return 'The login request was malformed. Please reload the page and try again.';
return this.t('login.invalidRequest');
case 'unauthorized':
return 'The provided credentials were not accepted.';
return this.t('login.unauthorized');
default:
return 'Login failed. Please try again.';
return this.t('login.failed');
}
}
@ -421,7 +771,7 @@ class DashboardApp {
this.cars.clear();
this.resetChargerTable();
this.updateCarSelector();
this.renderChargingSessions([], 'No charging sessions fetched yet.');
this.renderChargingSessions([], this.t('sessions.emptyFetched'));
try {
window.localStorage.removeItem(this.sessionKey);
@ -434,13 +784,13 @@ class DashboardApp {
connectWebSocket(resetPending = false) {
if (!this.token) {
this.updateConnectionStatus('Awaiting login…', 'connecting');
this.updateConnectionStatus(this.t('header.awaitingLogin'), 'connecting');
return;
}
if (this.tokenExpiry && this.tokenExpiry <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
this.showLoginOverlay(this.t('connection.sessionExpiredRestore'));
return;
}
@ -451,7 +801,7 @@ class DashboardApp {
this.pendingRequests.clear();
clearTimeout(this.reconnectTimer);
this.updateConnectionStatus('Connecting…', 'connecting');
this.updateConnectionStatus(this.t('connection.connecting'), 'connecting');
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const host = window.location.hostname || 'localhost';
const port = 4449;
@ -460,7 +810,7 @@ class DashboardApp {
this.socket = new WebSocket(url);
this.socket.addEventListener('open', () => {
this.updateConnectionStatus('Authenticating…', 'authenticating');
this.updateConnectionStatus(this.t('connection.authenticating'), 'authenticating');
this.sendAuthenticate();
});
@ -469,7 +819,7 @@ class DashboardApp {
});
this.socket.addEventListener('error', () => {
this.updateConnectionStatus('Connection error', 'error');
this.updateConnectionStatus(this.t('connection.error'), 'error');
});
this.socket.addEventListener('close', () => {
@ -558,12 +908,12 @@ class DashboardApp {
if (data.success) {
const payload = data && data.payload ? data.payload : {};
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
this.renderChargingSessions(sessions, 'No charging sessions found.');
this.renderChargingSessions(sessions, this.t('sessions.noneFound'));
} else if (data.error === 'unauthenticated') {
this.onAuthenticationFailed('unauthenticated');
} else {
console.warn('GetChargingSessions request failed', data.error || 'unknownError');
this.renderChargingSessions([], 'Failed to fetch charging sessions.');
this.renderChargingSessions([], this.t('sessions.fetchFailed'));
}
return true;
}
@ -641,7 +991,7 @@ class DashboardApp {
}
onAuthenticationSucceeded() {
this.updateConnectionStatus('Connected', 'connected');
this.updateConnectionStatus(this.t('connection.connected'), 'connected');
this.updateSessionUser();
this.sendGetCars();
this.sendGetChargers();
@ -650,13 +1000,13 @@ class DashboardApp {
onAuthenticationFailed(reason) {
const message = reason === 'unauthenticated'
? 'Your session expired. Please sign in again.'
: 'Authentication failed. Please try again.';
? this.t('connection.sessionExpired')
: this.t('connection.authFailed');
console.warn('Authentication failed', reason);
this.clearSession();
this.showLoginOverlay(message);
this.updateConnectionStatus('Authentication required', 'error');
this.updateConnectionStatus(this.t('connection.authenticationRequired'), 'error');
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
@ -664,7 +1014,7 @@ class DashboardApp {
onSocketClosed() {
this.pendingRequests.clear();
this.updateConnectionStatus('Disconnected', 'error');
this.updateConnectionStatus(this.t('connection.disconnected'), 'error');
if (!this.token) {
this.showLoginOverlay();
return;
@ -729,7 +1079,7 @@ class DashboardApp {
const requestId = this.sendAction('GetChargingSessions', payload);
if (!requestId)
this.renderChargingSessions([], 'Unable to request charging sessions. Check the connection status.');
this.renderChargingSessions([], this.t('sessions.requestFailed'));
return requestId;
}
@ -816,11 +1166,11 @@ class DashboardApp {
const dot = document.createElement('span');
dot.className = `value-dot ${value ? 'value-dot-true' : 'value-dot-false'}`;
dot.setAttribute('role', 'img');
dot.setAttribute('aria-label', value ? 'True' : 'False');
dot.title = value ? 'True' : 'False';
dot.setAttribute('aria-label', value ? this.t('value.true') : this.t('value.false'));
dot.title = value ? this.t('value.true') : this.t('value.false');
const srText = document.createElement('span');
srText.className = 'sr-only';
srText.textContent = value ? 'True' : 'False';
srText.textContent = value ? this.t('value.true') : this.t('value.false');
cell.appendChild(dot);
cell.appendChild(srText);
return;
@ -959,7 +1309,7 @@ class DashboardApp {
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'All cars';
defaultOption.textContent = this.t('sessions.allCars');
select.appendChild(defaultOption);
const cars = Array.from(this.cars.values())
@ -993,25 +1343,25 @@ class DashboardApp {
if (key === 'energyManagerMode') {
const modes = {
0: 'Quick',
1: 'Eco',
2: 'Eco + Time'
0: this.t('energyManagerMode.quick'),
1: this.t('energyManagerMode.eco'),
2: this.t('energyManagerMode.ecoTime')
};
if (value in modes)
return modes[value];
return Number.isFinite(value) ? `Unknown (${value})` : '—';
return Number.isFinite(value) ? this.t('value.unknownWithValue', { value }) : '—';
}
if (key === 'digitalInputMode') {
const modes = {
0: 'Charging allowed',
1: 'Charging allowed inverted',
2: 'PWM and S0 signaling',
3: 'Limit and S0 signaling'
0: this.t('digitalInputMode.chargingAllowed'),
1: this.t('digitalInputMode.chargingAllowedInverted'),
2: this.t('digitalInputMode.pwmAndS0'),
3: this.t('digitalInputMode.limitAndS0')
};
if (value in modes)
return modes[value];
return Number.isFinite(value) ? `Unknown (${value})` : '—';
return Number.isFinite(value) ? this.t('value.unknownWithValue', { value }) : '—';
}
if ((key === 'currentPower' || key === 'sessionEnergy') && typeof value === 'number') {
@ -1023,7 +1373,7 @@ class DashboardApp {
}
if (typeof value === 'boolean')
return value ? 'Yes' : 'No';
return value ? this.t('value.yes') : this.t('value.no');
if (typeof value === 'number')
return this.formatNumber(value);
@ -1049,7 +1399,7 @@ class DashboardApp {
return;
if (!normalizedSessions.length) {
this.elements.chargingSessionsOutput.textContent = fallbackMessage || 'No charging sessions found.';
this.elements.chargingSessionsOutput.textContent = fallbackMessage || this.t('sessions.noneFound');
return;
}
@ -1057,7 +1407,7 @@ class DashboardApp {
this.elements.chargingSessionsOutput.textContent = JSON.stringify(normalizedSessions, null, 2);
} catch (error) {
console.warn('Failed to render charging sessions', error);
this.elements.chargingSessionsOutput.textContent = 'Unable to display charging sessions.';
this.elements.chargingSessionsOutput.textContent = this.t('sessions.displayFailed');
}
}
@ -1082,11 +1432,11 @@ class DashboardApp {
const cell = emptyRow.querySelector('td');
if (cell) {
if (!normalizedSessions.length) {
cell.textContent = fallbackMessage || 'No charging sessions fetched yet.';
cell.textContent = fallbackMessage || this.t('sessions.emptyFetched');
} else if (hasTimeRangeFilter) {
cell.textContent = 'No charging sessions match the selected time range.';
cell.textContent = this.t('sessions.noneInRange');
} else {
cell.textContent = fallbackMessage || 'No charging sessions found.';
cell.textContent = fallbackMessage || this.t('sessions.noneFound');
}
}
emptyRow.classList.remove('hidden');
@ -1120,7 +1470,7 @@ class DashboardApp {
this.elements.sessionEndFilter.setCustomValidity('');
if (Number.isFinite(startMs) && Number.isFinite(endMs) && startMs > endMs) {
const message = 'Start date must be earlier than end date.';
const message = this.t('sessions.startBeforeEnd');
if (this.elements.sessionStartFilter)
this.elements.sessionStartFilter.setCustomValidity(message);
if (this.elements.sessionEndFilter)
@ -1232,7 +1582,7 @@ class DashboardApp {
return session.property;
if (session.sessionId)
return `Session ${session.sessionId}`;
return this.t('sessions.sessionIdLabel', { id: session.sessionId });
return '—';
}
@ -1314,15 +1664,15 @@ class DashboardApp {
return '';
const columns = [
{ label: 'Session ID', key: 'sessionId' },
{ label: 'Charger name', key: 'chargerName' },
{ label: 'Charger serial number', key: 'chargerSerialNumber' },
{ label: 'Car', key: 'carName' },
{ label: 'Start', key: 'startTimestamp', formatter: value => this.formatCsvTimestamp(value) },
{ label: 'End', key: 'endTimestamp', formatter: value => this.formatCsvTimestamp(value) },
{ label: 'Energy [kWh]', key: 'sessionEnergy' },
{ label: 'Meter start [kWh]', key: 'energyStart' },
{ label: 'Meter end [kWh]', key: 'energyEnd' }
{ label: this.t('csv.sessionId'), key: 'sessionId' },
{ label: this.t('csv.chargerName'), key: 'chargerName' },
{ label: this.t('csv.chargerSerialNumber'), key: 'chargerSerialNumber' },
{ label: this.t('csv.car'), key: 'carName' },
{ label: this.t('csv.start'), key: 'startTimestamp', formatter: value => this.formatCsvTimestamp(value) },
{ label: this.t('csv.end'), key: 'endTimestamp', formatter: value => this.formatCsvTimestamp(value) },
{ label: this.t('csv.energyKwh'), key: 'sessionEnergy' },
{ label: this.t('csv.meterStartKwh'), key: 'energyStart' },
{ label: this.t('csv.meterEndKwh'), key: 'energyEnd' }
];
const lines = [];
@ -1657,7 +2007,7 @@ class DashboardApp {
updateEasterEggScore() {
if (!this.elements.easterEggScore)
return;
this.elements.easterEggScore.textContent = `Score: ${this.easterEggGame.score}`;
this.elements.easterEggScore.textContent = this.t('easterEgg.score', { score: this.easterEggGame.score });
}
sanitizeFilename(value) {
@ -1692,8 +2042,8 @@ class DashboardApp {
return;
const defaultLabel = document.body && document.body.dataset.mode === 'help'
? 'Load the dashboard to authenticate.'
: 'Awaiting login…';
? this.t('header.authenticateHint')
: this.t('header.awaitingLogin');
if (!this.token || !this.username) {
this.elements.sessionUsername.textContent = defaultLabel;
@ -1743,7 +2093,7 @@ class DashboardApp {
if (!this.elements.loginButton)
return;
this.elements.loginButton.disabled = loading;
this.elements.loginButton.textContent = loading ? 'Signing in…' : 'Sign in';
this.elements.loginButton.textContent = loading ? this.t('login.signingIn') : this.t('login.signIn');
}
generateRequestId() {
@ -1770,9 +2120,9 @@ class DashboardApp {
this.clearSession();
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
this.updateConnectionStatus('Logged out', 'connecting');
this.updateConnectionStatus(this.t('connection.loggedOut'), 'connecting');
this.updateSessionUser();
this.showLoginOverlay('You have been logged out.');
this.showLoginOverlay(this.t('connection.loggedOutOverlay'));
}
scheduleTokenRefresh() {
@ -1812,7 +2162,7 @@ class DashboardApp {
throw new Error(data && data.error ? data.error : 'refreshFailed');
if (!data.token || !data.expiresAt)
throw new Error('Invalid response from server.');
throw new Error(this.t('login.invalidResponse'));
this.persistSession({
token: data.token,
@ -1823,10 +2173,10 @@ class DashboardApp {
} catch (error) {
console.warn('Token refresh failed', error);
this.clearSession();
this.updateConnectionStatus('Authentication required', 'error');
this.updateConnectionStatus(this.t('connection.authenticationRequired'), 'error');
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
this.showLoginOverlay('Session expired. Please sign in again.');
this.showLoginOverlay(this.t('connection.sessionExpired'));
} finally {
this.refreshInFlight = false;
}

View File

@ -72,11 +72,31 @@
<body>
<main class="redirect-panel">
<img src="styles/pce/icon.svg" alt="EV Dash logo">
<h1>Help moved into the dashboard</h1>
<p>The help content now lives inside the built-in side panel. You will be redirected automatically.</p>
<a href="index.html#help">Open EV Dash</a>
<h1 id="redirectTitle">Help moved into the dashboard</h1>
<p id="redirectText">The help content now lives inside the built-in side panel. You will be redirected automatically.</p>
<a id="redirectLink" href="index.html#help">Open EV Dash</a>
</main>
<script>
const isGerman = (() => {
const candidates = Array.isArray(navigator.languages) && navigator.languages.length
? navigator.languages
: [navigator.language || 'en'];
return candidates
.filter(Boolean)
.map(value => String(value).toLowerCase())
.some(value => value === 'de' || value.startsWith('de-'));
})();
if (isGerman) {
try { document.documentElement.lang = 'de'; } catch (error) { /* ignore */ }
const title = document.getElementById('redirectTitle');
const text = document.getElementById('redirectText');
const link = document.getElementById('redirectLink');
if (title) title.textContent = 'Hilfe wurde ins Dashboard verschoben';
if (text) text.textContent = 'Die Hilfe befindet sich jetzt im integrierten Seitenbereich. Du wirst automatisch weitergeleitet.';
if (link) link.textContent = 'EV Dash öffnen';
}
window.addEventListener('load', () => {
try {
window.location.replace('index.html#help');

View File

@ -649,44 +649,44 @@
<img src="styles/pce/icon.svg" alt="EV Dash logo" class="brand-logo" id="brandLogo">
<div class="brand-text">
<h1>EV Dash</h1>
<p>Monitor & troubleshoot EV chargers.</p>
<p data-i18n="header.tagline">Monitor & troubleshoot EV chargers.</p>
</div>
</div>
<div class="session-panel">
<p class="status-indicator">
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
<span id="connectionStatus">Awaiting login…</span>
<span id="connectionStatus" data-i18n="header.awaitingLogin">Awaiting login…</span>
</p>
<div class="session-user">
<img src="icons/user.svg" alt="" aria-hidden="true" class="session-user-icon">
<span id="sessionUsername">Awaiting login…</span>
<span id="sessionUsername" data-i18n="header.awaitingLogin">Awaiting login…</span>
</div>
<button type="button" id="logoutButton" class="ghost hidden">Logout</button>
<button type="button" id="logoutButton" class="ghost hidden" data-i18n="header.logout">Logout</button>
</div>
</div>
</header>
<aside class="side-panel" aria-label="Dashboard sections">
<aside class="side-panel" aria-label="Dashboard sections" data-i18n="sidebar.dashboardSections" data-i18n-attr="aria-label">
<div class="side-panel-header">
<p class="eyebrow">Workspace</p>
<h2>Overview</h2>
<p class="eyebrow" data-i18n="sidebar.workspace">Workspace</p>
<h2 data-i18n="sidebar.overview">Overview</h2>
</div>
<nav class="side-nav" aria-label="Sections">
<nav class="side-nav" aria-label="Sections" data-i18n="sidebar.sections" data-i18n-attr="aria-label">
<button type="button" class="side-nav-button active" data-panel-target="chargers" aria-pressed="true">
<span>Chargers</span>
<span class="side-nav-subtitle">Live table & telemetry</span>
<span data-i18n="sidebar.chargers">Chargers</span>
<span class="side-nav-subtitle" data-i18n="sidebar.chargersSubtitle">Live table & telemetry</span>
</button>
<button type="button" class="side-nav-button" data-panel-target="chargingSessions" aria-pressed="false">
<span>Charging sessions</span>
<span class="side-nav-subtitle">History of charging sessions</span>
<span data-i18n="sidebar.sessions">Charging sessions</span>
<span class="side-nav-subtitle" data-i18n="sidebar.sessionsSubtitle">History of charging sessions</span>
</button>
<button type="button" class="side-nav-button" data-panel-target="help" aria-pressed="false">
<span>Help</span>
<span class="side-nav-subtitle">API concept & debug logs</span>
<span data-i18n="sidebar.help">Help</span>
<span class="side-nav-subtitle" data-i18n="sidebar.helpSubtitle">API concept & debug logs</span>
</button>
</nav>
<div class="side-panel-footer">
Version 1.0.0
<span data-i18n="sidebar.version">Version</span> 1.0.0
</div>
</aside>
@ -696,31 +696,31 @@
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">Live overview</p>
<h2 id="chargersTitle">Chargers</h2>
<p class="eyebrow" data-i18n="chargers.liveOverview">Live overview</p>
<h2 id="chargersTitle" data-i18n="chargers.title">Chargers</h2>
</div>
</div>
<div class="table-wrapper">
<table class="data-table chargers-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Car</th>
<th scope="col">Energy manager mode</th>
<th scope="col">Connected</th>
<th scope="col">Status</th>
<th scope="col">Charging current</th>
<th scope="col">Charging phases</th>
<th scope="col">Current power</th>
<th scope="col">Session energy</th>
<th scope="col">Version</th>
<th scope="col">Temperature</th>
<th scope="col">Digital input</th>
<th scope="col" data-i18n="chargers.columns.name">Name</th>
<th scope="col" data-i18n="chargers.columns.car">Car</th>
<th scope="col" data-i18n="chargers.columns.energyManagerMode">Energy manager mode</th>
<th scope="col" data-i18n="chargers.columns.connected">Connected</th>
<th scope="col" data-i18n="chargers.columns.status">Status</th>
<th scope="col" data-i18n="chargers.columns.chargingCurrent">Charging current</th>
<th scope="col" data-i18n="chargers.columns.chargingPhases">Charging phases</th>
<th scope="col" data-i18n="chargers.columns.currentPower">Current power</th>
<th scope="col" data-i18n="chargers.columns.sessionEnergy">Session energy</th>
<th scope="col" data-i18n="chargers.columns.version">Version</th>
<th scope="col" data-i18n="chargers.columns.temperature">Temperature</th>
<th scope="col" data-i18n="chargers.columns.digitalInputMode">Digital input</th>
</tr>
</thead>
<tbody id="chargerTableBody">
<tr id="chargerEmptyRow" class="empty-row">
<td colspan="12">No chargers loaded yet.</td>
<td colspan="12" data-i18n="chargers.empty">No chargers loaded yet.</td>
</tr>
</tbody>
</table>
@ -732,38 +732,38 @@
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">History</p>
<h2 id="chargingSessionsTitle">Charging sessions</h2>
<p class="eyebrow" data-i18n="sessions.history">History</p>
<h2 id="chargingSessionsTitle" data-i18n="sessions.title">Charging sessions</h2>
</div>
<div class="action-row">
<label class="sr-only" for="carFilter">Car</label>
<label class="sr-only" for="carFilter" data-i18n="sessions.filterCar">Car</label>
<select id="carFilter" name="carFilter">
<option value="">All cars</option>
<option value="" data-i18n="sessions.allCars">All cars</option>
</select>
<label class="sr-only" for="sessionStartFilter">Start date</label>
<label class="sr-only" for="sessionStartFilter" data-i18n="sessions.filterStartDate">Start date</label>
<input type="date" id="sessionStartFilter" name="sessionStartFilter">
<label class="sr-only" for="sessionEndFilter">End date</label>
<label class="sr-only" for="sessionEndFilter" data-i18n="sessions.filterEndDate">End date</label>
<input type="date" id="sessionEndFilter" name="sessionEndFilter">
<button type="button" id="fetchSessionsButton" class="primary">Fetch sessions</button>
<button type="button" id="downloadSessionsButton">Download CSV</button>
<button type="button" id="fetchSessionsButton" class="primary" data-i18n="sessions.fetch">Fetch sessions</button>
<button type="button" id="downloadSessionsButton" data-i18n="sessions.downloadCsv">Download CSV</button>
</div>
</div>
<p class="helper-text">Optionally filter charging sessions by car and time range before downloading.</p>
<p class="helper-text" data-i18n="sessions.helper">Optionally filter charging sessions by car and time range before downloading.</p>
<div class="table-wrapper">
<table class="data-table sessions-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Charger</th>
<th scope="col">Car</th>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col">Energy (kWh)</th>
<th scope="col" data-i18n="sessions.columns.name">Name</th>
<th scope="col" data-i18n="sessions.columns.charger">Charger</th>
<th scope="col" data-i18n="sessions.columns.car">Car</th>
<th scope="col" data-i18n="sessions.columns.start">Start</th>
<th scope="col" data-i18n="sessions.columns.end">End</th>
<th scope="col" data-i18n="sessions.columns.energy">Energy (kWh)</th>
</tr>
</thead>
<tbody id="chargingSessionsTableBody">
<tr id="chargingSessionsEmptyRow" class="empty-row">
<td colspan="6">No charging sessions fetched yet.</td>
<td colspan="6" data-i18n="sessions.emptyFetched">No charging sessions fetched yet.</td>
</tr>
</tbody>
</table>
@ -775,18 +775,18 @@
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">Guides</p>
<h2 id="helpTitle">API Contract</h2>
<p class="eyebrow" data-i18n="help.guides">Guides</p>
<h2 id="helpTitle" data-i18n="help.title">API Contract</h2>
</div>
<p class="helper-text">Use <code>app.sendAction(action, payload)</code> after authenticating.</p>
<p class="helper-text" data-i18n="help.helper" data-i18n-mode="html">Use <code>app.sendAction(action, payload)</code> after authenticating.</p>
</div>
<div class="grid-two-column">
<div>
<h3>Request template</h3>
<h3 data-i18n="help.requestTemplate">Request template</h3>
<pre id="requestTemplate"></pre>
</div>
<div>
<h3>Responses</h3>
<h3 data-i18n="help.responses">Responses</h3>
<pre id="responseTemplate"></pre>
</div>
</div>
@ -795,36 +795,36 @@
<article class="card" aria-live="polite">
<div class="card-header">
<div>
<p class="eyebrow">Diagnostics</p>
<h2>Last WebSocket message</h2>
<p class="eyebrow" data-i18n="help.diagnostics">Diagnostics</p>
<h2 data-i18n="help.lastMessage">Last WebSocket message</h2>
</div>
<p class="helper-text">Sign in to reuse the stored session and inspect backend payloads.</p>
<p class="helper-text" data-i18n="help.diagnosticsHelper">Sign in to reuse the stored session and inspect backend payloads.</p>
</div>
<pre id="incomingMessage">No messages received yet.</pre>
<pre id="incomingMessage" data-i18n="help.noMessagesYet">No messages received yet.</pre>
</article>
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">Debugging</p>
<h2>Charging sessions payload</h2>
<p class="eyebrow" data-i18n="help.debugging">Debugging</p>
<h2 data-i18n="help.sessionsPayload">Charging sessions payload</h2>
</div>
<p class="helper-text">Raw session JSON for troubleshooting.</p>
<p class="helper-text" data-i18n="help.sessionsPayloadHelper">Raw session JSON for troubleshooting.</p>
</div>
<pre id="chargingSessionsOutput">No charging sessions fetched yet.</pre>
<pre id="chargingSessionsOutput" data-i18n="sessions.emptyFetched">No charging sessions fetched yet.</pre>
</article>
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">Reference</p>
<h2>Charger table basics</h2>
<p class="eyebrow" data-i18n="help.reference">Reference</p>
<h2 data-i18n="help.chargerTableBasics">Charger table basics</h2>
</div>
</div>
<ul>
<li>The dashboard keeps one row per charger ID and updates it with backend notifications.</li>
<li>Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.</li>
<li>Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.</li>
<li data-i18n="help.referenceBullet1">The dashboard keeps one row per charger ID and updates it with backend notifications.</li>
<li data-i18n="help.referenceBullet2" data-i18n-mode="html">Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.</li>
<li data-i18n="help.referenceBullet3">Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.</li>
</ul>
</article>
</section>
@ -840,34 +840,34 @@
<div class="easter-egg-card" role="dialog" aria-modal="true" aria-labelledby="easterEggTitle">
<div class="easter-egg-header">
<div>
<p class="eyebrow">Hidden treat</p>
<h3 id="easterEggTitle">Grid Dash</h3>
<p class="eyebrow" data-i18n="easterEgg.hiddenTreat">Hidden treat</p>
<h3 id="easterEggTitle" data-i18n="easterEgg.title">Grid Dash</h3>
</div>
<button type="button" id="easterEggClose" class="ghost">Close</button>
<button type="button" id="easterEggClose" class="ghost" data-i18n="easterEgg.close">Close</button>
</div>
<p class="helper-text">Use arrow keys or WASD to drive the tiny EV and catch lightning bolts. Press Esc or close to exit.</p>
<p class="helper-text" data-i18n="easterEgg.instructions">Use arrow keys or WASD to drive the tiny EV and catch lightning bolts. Press Esc or close to exit.</p>
<div class="easter-egg-meta">
<span id="easterEggScore">Score: 0</span>
<span id="easterEggHint">Stay inside the grid!</span>
<span id="easterEggHint" data-i18n="easterEgg.hint">Stay inside the grid!</span>
</div>
<canvas id="easterEggCanvas" width="520" height="320" aria-label="Mini game canvas"></canvas>
<canvas id="easterEggCanvas" width="520" height="320" aria-label="Mini game canvas" data-i18n="easterEgg.canvasLabel" data-i18n-attr="aria-label"></canvas>
</div>
</div>
<section id="loginOverlay" class="login-view" aria-labelledby="loginTitle">
<div class="login-panel">
<h2 id="loginTitle">Sign in</h2>
<p>Authorization required.</p>
<h2 id="loginTitle" data-i18n="login.title">Sign in</h2>
<p data-i18n="login.required">Authorization required.</p>
<div id="loginError" class="error-message hidden" role="alert"></div>
<form id="loginForm" autocomplete="on">
<label for="username">Username
<label for="username"><span data-i18n="login.username">Username</span>
<input type="text" id="username" name="username" autocomplete="username" required placeholder="user">
</label>
<label for="password">Password
<label for="password"><span data-i18n="login.password">Password</span>
<input type="password" id="password" name="password" autocomplete="current-password" required placeholder="••••••••">
</label>
<p class="helper-text">Contact the administrator to receive valid credentials.</p>
<button type="submit" id="loginButton" class="primary">Sign in</button>
<p class="helper-text" data-i18n="login.helper">Contact the administrator to receive valid credentials.</p>
<button type="submit" id="loginButton" class="primary" data-i18n="login.signIn">Sign in</button>
</form>
</div>
</section>