Web: eight more UI languages behind a single language registry
Adds Swiss German, French, Italian, Dutch, Danish, Norwegian, Swedish and Finnish — ten selectable languages, each complete at 418/418 keys with matching placeholders and date arrays. A language used to be registered in four unrelated places (dictionary, settings dropdown, date-picker locale ternary, resolver fallback), so adding one meant touching all of them. LANGUAGES in i18n.js is now the single source of truth: adding a language is one entry plus one dictionary, and the dropdown derives from it. The registry also separates the stored code from the BCP-47 tag used for formatting. That distinction is what makes 'ch' safe — "ch" is a country, not a language, and would throw if handed to Intl; it maps to de-CH. Fixes locale handling along the way: fmtTime/fmtDatetime in calendar.js, month.js and week.js hardcoded 'de', so English users saw German-formatted times, and two toLocaleDateString() calls silently used the browser locale instead of the app language. All formatting now goes through getLocale(). Missing keys fall back language → English → German rather than straight to German, and the dead per-dictionary `locale` key is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { renderAgenda } from './views/agenda.js';
|
||||
import { renderQuarter } from './views/quarter.js';
|
||||
import { openColorPicker } from './color-picker.js';
|
||||
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||
import { t, setLang, getLang } from './i18n.js';
|
||||
import { t, setLang, getLocale } from './i18n.js';
|
||||
import { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective, baseColor, setInstanceDefaults } from './settings-sync.js';
|
||||
import { loadInstance, reloadInstance, instanceConfig } from './instance.js';
|
||||
import { APP_VERSION } from './version.js';
|
||||
@@ -1912,7 +1912,7 @@ function setDtValue(id, isoStr, mode) {
|
||||
const display = document.getElementById(id + '-display');
|
||||
if (display) {
|
||||
display.querySelector('.dt-display-text').textContent =
|
||||
formatDtDisplay(isoStr, mode, getLang());
|
||||
formatDtDisplay(isoStr, mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3963,7 +3963,7 @@ function renderBirthdaySettings() {
|
||||
const rowEl = document.createElement('div');
|
||||
rowEl.className = 'form-hint';
|
||||
rowEl.style.margin = '0 0 2px';
|
||||
const when = d.last_sync ? new Date(d.last_sync).toLocaleDateString() : '';
|
||||
const when = d.last_sync ? new Date(d.last_sync).toLocaleDateString(getLocale()) : '';
|
||||
rowEl.textContent = `${d.device_name} — ${t('birthday_devices_count', { n: d.count })}${when ? ' · ' + when : ''}`;
|
||||
devicesWrap.appendChild(rowEl);
|
||||
});
|
||||
@@ -4937,7 +4937,7 @@ function initAppPasswords() {
|
||||
appPwList.innerHTML = rows.length
|
||||
? rows.map(r => `<div class="app-pw-item">
|
||||
<span class="app-pw-name">${escHtml(r.label)}</span>
|
||||
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString() : t('app_pw_never_used')}</span>
|
||||
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString(getLocale()) : t('app_pw_never_used')}</span>
|
||||
<button class="btn btn-ghost btn-sm app-pw-del" data-id="${r.id}">${t('app_pw_revoke')}</button>
|
||||
</div>`).join('')
|
||||
: `<p class="text-muted">${t('app_pw_none')}</p>`;
|
||||
@@ -5138,10 +5138,10 @@ export function showToast(msg, isError = false) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function fmtDatetime(d) {
|
||||
return d.toLocaleString('de', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||
return d.toLocaleString(getLocale(), { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
|
||||
* mode : 'datetime' | 'date'
|
||||
*/
|
||||
import { t } from './i18n.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
const ITEM_H = 40; // px per scroll item
|
||||
const VISIBLE = 3; // visible items in time scroller
|
||||
@@ -250,16 +250,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||
/**
|
||||
* Format an ISO value for display in the UI
|
||||
* mode: 'datetime' | 'date'
|
||||
* lang: 'de' | 'en'
|
||||
* Formatting follows the active UI language via the i18n locale registry.
|
||||
*/
|
||||
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
||||
export function formatDtDisplay(isoStr, mode) {
|
||||
if (!isoStr) return '—';
|
||||
try {
|
||||
const d = mode === 'datetime'
|
||||
? new Date(isoStr.replace(' ', 'T'))
|
||||
: new Date(isoStr + 'T00:00:00');
|
||||
if (isNaN(d)) return isoStr;
|
||||
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
|
||||
const locale = getLocale();
|
||||
if (mode === 'datetime') {
|
||||
return d.toLocaleString(locale, {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
|
||||
3218
frontend/js/i18n.js
3218
frontend/js/i18n.js
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
// syncable value so that "not synced" works per browser, plus the declarative
|
||||
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
|
||||
|
||||
import { LANGUAGES } from './i18n.js';
|
||||
|
||||
// Canonical default colours — the single source for the web. Reset writes these.
|
||||
export const DEFAULT_COLORS = {
|
||||
primary_color: '#58B900',
|
||||
@@ -59,9 +61,11 @@ export const SETTING_GROUPS = [
|
||||
{
|
||||
titleKey: 'settings_language',
|
||||
rows: [
|
||||
{ key: 'language', labelKey: 'settings_language', type: 'select', opts: [
|
||||
{ v: 'de', label: 'Deutsch' }, { v: 'en', label: 'English' },
|
||||
] },
|
||||
// Derived from the i18n registry: adding a language there adds it here.
|
||||
// Each language is listed under its own endonym, so the labels are never
|
||||
// translated — a Finn looks for "Suomi", not "Finnisch".
|
||||
{ key: 'language', labelKey: 'settings_language', type: 'select',
|
||||
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
|
||||
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
|
||||
import { getLocale } from './i18n.js';
|
||||
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
@@ -39,7 +40,7 @@ export function birthdayIconSvg() {
|
||||
}
|
||||
|
||||
export function formatDate(d, opts = {}) {
|
||||
return d.toLocaleDateString('de', opts);
|
||||
return d.toLocaleDateString(getLocale(), opts);
|
||||
}
|
||||
|
||||
export function dateKey(d) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLang } from '../i18n.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
if (!events.length) {
|
||||
@@ -94,7 +94,7 @@ function isTodayDate(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||
const DAY_H = 30; // day-number row height
|
||||
@@ -251,7 +251,7 @@ function dateKey(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
|
||||
// Build the days array (7 days for week, 1 for day)
|
||||
@@ -352,7 +352,7 @@ function isSameDay(a, b) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
Reference in New Issue
Block a user