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:
Scarriffle
2026-08-06 14:58:03 +02:00
parent c632dabc31
commit a45bf6ea73
8 changed files with 3229 additions and 34 deletions

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -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' },
],
},

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {