-
Hintergrundfarbe
-
-
-
-
Reset
+
+
+ Alle synchronisieren
+ Diese Einstellungen zwischen deinen Geräten teilen
+
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index d5e845a..8eab897 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -1,5 +1,5 @@
import { api } from './api.js';
-import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
+import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml } from './utils.js';
import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
@@ -7,6 +7,7 @@ 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 { DEFAULT_COLORS, SETTING_GROUPS, SYNCABLE_KEYS, loadLocal, saveLocal, mergeEffective } from './settings-sync.js';
import { APP_VERSION } from './version.js';
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
@@ -123,15 +124,22 @@ export async function initCalendar() {
api.get('/homeassistant/accounts').catch(() => []),
]);
- state.settings = settings;
+ // Per-setting sync: the server's raw response is the shared source; effective
+ // settings resolve each syncable key against the account-wide sync flags and
+ // this browser's local copy (see settings-sync.js / backend/SETTINGS_SYNC.md).
+ state.serverSettings = settings;
+ state.syncFlags = { ...(settings.sync_flags || {}) };
+ state.settings = mergeEffective(settings, state.syncFlags, loadLocal());
state.accounts = accounts;
state.localCalendars = localCalendars;
state.icalSubscriptions = icalSubscriptions;
state.googleAccounts = googleAccounts;
state.haAccounts = haAccounts;
- state.currentView = settings.default_view || 'month';
- state.dimPast = settings.dim_past_events;
- weekStartDay = settings.week_start_day || 'monday';
+ state.currentView = state.settings.default_view || 'month';
+ state.dimPast = state.settings.dim_past_events;
+ weekStartDay = state.settings.week_start_day || 'monday';
+ // Follow the effective (possibly synced) UI language.
+ if (state.settings.language) setLang(state.settings.language);
// URL state takes precedence over defaults (settings + today)
const urlState = readUrlState();
@@ -3296,54 +3304,14 @@ function openSettingsModal() {
}
function populateSettings() {
- const s = state.settings;
- document.getElementById('cfg-default-view').value = s.default_view || 'month';
- document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
- const colors = [
- { id: 'cfg-primary', val: s.primary_color || '#4285f4' },
- { id: 'cfg-accent', val: s.accent_color || '#ea4335' },
- { id: 'cfg-today', val: s.today_color || '#4285f4' },
- { id: 'cfg-month-divider', val: s.month_divider_color || '#7090c0' },
- { id: 'cfg-month-label', val: s.month_label_color || '#7090c0' },
- ];
- colors.forEach(({ id, val }) => {
- document.getElementById(id + '-hex').value = val.toUpperCase();
- document.getElementById(id + '-preview').style.background = val;
- });
+ // Appearance / syncable settings render into the unified sync table.
+ renderSettingsTable();
- // Override-Farben — leeres Hex-Input bedeutet "Default verwenden",
- // aber die Preview zeigt trotzdem die aktuell wirksame Farbe.
- [
- { id: 'cfg-text-color', val: s.text_color, fallback: DEFAULT_TEXT_COLOR },
- { id: 'cfg-line-color', val: s.line_color, fallback: DEFAULT_LINE_COLOR },
- { id: 'cfg-bg-color', val: s.bg_color, fallback: DEFAULT_BG_COLOR },
- ].forEach(({ id, val, fallback }) => {
- const hex = document.getElementById(id + '-hex');
- const prev = document.getElementById(id + '-preview');
- if (!hex || !prev) return;
- hex.value = val ? String(val).toUpperCase() : '';
- prev.style.background = val || fallback;
- });
- document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
- document.getElementById('cfg-language').value = getLang();
+ // Account settings (kept as a dedicated profile block, not synced per-device).
+ const s = state.settings;
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
renderGroupVisibleList(s.group_visible_calendar_id);
- // Share-Icon-Picker in Darstellung
- const shareIconPicker = document.getElementById('cfg-share-icon');
- if (shareIconPicker) {
- const cur = s.share_calendar_icon || 'share';
- shareIconPicker.innerHTML = SHARE_ICON_KEYS.map(k =>
- `
${shareIconSvg(k, 20)} `
- ).join('');
- shareIconPicker.querySelectorAll('.group-icon-opt').forEach(btn => {
- btn.addEventListener('click', () => {
- shareIconPicker.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on'));
- btn.classList.add('on');
- });
- });
- }
-
// Profile chapter: name (from cached user) + email (fresh from /profile).
const pu = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('cfg-display-name').value = pu.display_name || pu.username || '';
@@ -3355,20 +3323,6 @@ function populateSettings() {
}).catch(() => {});
initAppPasswords();
- // Set active contrast/hour-height/duration buttons
- [
- { id: 'cfg-text-contrast', val: s.text_contrast || 3 },
- { id: 'cfg-line-contrast', val: s.line_contrast || 3 },
- { id: 'cfg-hour-height', val: s.hour_height || 60 },
- { id: 'cfg-event-duration', val: s.default_event_duration_minutes || 60 },
- ].forEach(({ id, val }) => {
- const sel = document.getElementById(id);
- if (!sel) return;
- sel.querySelectorAll('.contrast-btn').forEach(btn => {
- btn.classList.toggle('active', String(btn.dataset.val) === String(val));
- });
- });
-
// Show users nav button only for admins
const user = JSON.parse(localStorage.getItem('user') || '{}');
const usersNavBtn = document.getElementById('settings-nav-users');
@@ -3385,6 +3339,189 @@ function populateSettings() {
renderAllAccounts();
}
+// ── Settings sync table ───────────────────────────────────
+const SYNC_ICON_SVG = '
';
+const RESET_ICON_SVG = '
';
+
+function normalizeHex(raw, fallback) {
+ let v = (raw || '').trim();
+ if (!v) return fallback;
+ if (!v.startsWith('#')) v = '#' + v;
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : fallback;
+}
+
+function renderSettingsTable() {
+ const container = document.getElementById('settings-appearance-table');
+ if (!container) return;
+ container.innerHTML = SETTING_GROUPS.map(group =>
+ `
${escHtml(t(group.titleKey))}
` +
+ group.rows.map(settingRowHtml).join('')
+ ).join('');
+ SETTING_GROUPS.forEach(g => g.rows.forEach(wireSettingRow));
+ updateSyncAllToggle();
+}
+
+function settingRowHtml(def) {
+ const synced = !!state.syncFlags[def.key];
+ const val = state.settings[def.key];
+ let valueHtml = '';
+ if (def.type === 'select') {
+ valueHtml = `
` + def.opts.map(o => {
+ const lbl = o.tk ? t(o.tk) : o.label;
+ const sel = String(o.v) === String(val) ? ' selected' : '';
+ return `${escHtml(lbl)} `;
+ }).join('') + ' ';
+ } else if (def.type === 'toggle') {
+ valueHtml = `
`;
+ } else if (def.type === 'color') {
+ const hex = normalizeHex(val, DEFAULT_COLORS[def.key]);
+ valueHtml = `
+
+
+
${RESET_ICON_SVG}
+
`;
+ } else if (def.type === 'icon') {
+ const cur = val || 'share';
+ valueHtml = `
` + SHARE_ICON_KEYS.map(k =>
+ `${shareIconSvg(k, 18)} `
+ ).join('') + '
';
+ }
+ return `
+
${SYNC_ICON_SVG}
+
${escHtml(t(def.labelKey))}
+
${valueHtml}
+
`;
+}
+
+function wireSettingRow(def) {
+ const row = document.querySelector(`.settings-row[data-row="${def.key}"]`);
+ if (!row) return;
+
+ const syncBtn = row.querySelector('[data-sync]');
+ if (syncBtn) syncBtn.addEventListener('click', () => {
+ const on = !state.syncFlags[def.key];
+ state.syncFlags[def.key] = on;
+ syncBtn.classList.toggle('on', on);
+ syncBtn.setAttribute('aria-checked', String(on));
+ updateSyncAllToggle();
+ });
+
+ if (def.type === 'select') {
+ // Hour height affects the theme — preview it live; other selects apply on Save.
+ if (def.key === 'hour_height') {
+ const sel = row.querySelector('[data-skey]');
+ if (sel) sel.addEventListener('change', () => {
+ state.settings.hour_height = Number(sel.value);
+ applyTheme(state.settings);
+ });
+ }
+ } else if (def.type === 'toggle') {
+ const vt = row.querySelector('[data-vtoggle]');
+ if (vt) vt.addEventListener('click', () => {
+ const on = !vt.classList.contains('on');
+ vt.classList.toggle('on', on);
+ vt.setAttribute('aria-checked', String(on));
+ });
+ } else if (def.type === 'color') {
+ const hex = row.querySelector('[data-shex]');
+ const prev = row.querySelector('[data-sprev]');
+ const reset = row.querySelector('[data-sreset]');
+ const apply = (color) => {
+ prev.style.background = color;
+ state.settings[def.key] = color;
+ applyTheme(state.settings);
+ };
+ prev.addEventListener('click', async () => {
+ const picked = await openColorPicker(prev, hex.value || DEFAULT_COLORS[def.key]);
+ if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
+ });
+ hex.addEventListener('input', () => {
+ const norm = normalizeHex(hex.value, null);
+ if (norm) apply(norm);
+ });
+ hex.addEventListener('change', () => {
+ const norm = normalizeHex(hex.value, DEFAULT_COLORS[def.key]);
+ hex.value = norm; apply(norm);
+ });
+ reset.addEventListener('click', () => {
+ const d = DEFAULT_COLORS[def.key];
+ hex.value = d; apply(d);
+ });
+ } else if (def.type === 'icon') {
+ row.querySelectorAll('.group-icon-opt').forEach(btn => {
+ btn.addEventListener('click', () => {
+ row.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on'));
+ btn.classList.add('on');
+ });
+ });
+ }
+}
+
+// Read the current value of every syncable web setting from the table DOM.
+function readAppearanceTable() {
+ const out = {};
+ const numKeys = new Set(['hour_height', 'default_event_duration_minutes']);
+ SETTING_GROUPS.forEach(g => g.rows.forEach(def => {
+ if (def.type === 'select') {
+ const el = document.querySelector(`[data-skey="${def.key}"]`);
+ let v = el ? el.value : state.settings[def.key];
+ if (numKeys.has(def.key)) v = Number(v);
+ out[def.key] = v;
+ } else if (def.type === 'toggle') {
+ const el = document.querySelector(`[data-vtoggle="${def.key}"]`);
+ out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
+ } else if (def.type === 'color') {
+ const el = document.querySelector(`[data-shex="${def.key}"]`);
+ out[def.key] = normalizeHex(el && el.value, DEFAULT_COLORS[def.key]);
+ } else if (def.type === 'icon') {
+ const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
+ out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
+ }
+ }));
+ return out;
+}
+
+function updateSyncAllToggle() {
+ const btn = document.getElementById('cfg-sync-all');
+ if (!btn) return;
+ const allOn = SYNCABLE_KEYS.every(k => state.syncFlags[k]);
+ btn.classList.toggle('on', allOn);
+ btn.setAttribute('aria-checked', String(allOn));
+}
+
+function toggleSyncAll() {
+ const next = !SYNCABLE_KEYS.every(k => state.syncFlags[k]);
+ SYNCABLE_KEYS.forEach(k => { state.syncFlags[k] = next; });
+ document.querySelectorAll('.settings-row .sync-icon[data-sync]').forEach(el => {
+ const on = !!state.syncFlags[el.dataset.sync];
+ el.classList.toggle('on', on);
+ el.setAttribute('aria-checked', String(on));
+ });
+ updateSyncAllToggle();
+}
+
+// Save profile identity fields (name/login/email/hidden) — server rotates the
+// JWT if the login name changed.
+async function saveProfileFields() {
+ const email = document.getElementById('cfg-email').value.trim();
+ const displayName = document.getElementById('cfg-display-name').value.trim();
+ const loginName = document.getElementById('cfg-login-name').value.trim();
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ const body = { email: email || null };
+ if (displayName) body.display_name = displayName;
+ if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
+ const dh = document.getElementById('cfg-directory-hidden');
+ if (dh) body.directory_hidden = dh.checked;
+ const res = await api.put('/profile/', body);
+ if (res && res.access_token) localStorage.setItem('token', res.access_token);
+ const updated = { ...user };
+ if (displayName) updated.display_name = displayName;
+ if (body.username) updated.username = body.username.toLowerCase();
+ localStorage.setItem('user', JSON.stringify(updated));
+ const dd = document.getElementById('dropdown-username');
+ if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer';
+}
+
function activateSettingsPanel(panel) {
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === panel));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel));
@@ -4139,97 +4276,15 @@ async function loadUsers() {
}
function bindSettingsModal() {
- ['cfg-primary','cfg-accent','cfg-today','cfg-month-divider','cfg-month-label'].forEach(prefix => {
- const preview = document.getElementById(prefix + '-preview');
- const hex = document.getElementById(prefix + '-hex');
- preview.addEventListener('click', async () => {
- const picked = await openColorPicker(preview, hex.value || '#4285f4');
- if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
- });
- hex.addEventListener('change', () => {
- let val = hex.value.trim();
- if (!val.startsWith('#')) val = '#' + val;
- if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
- });
- });
-
- // Optional override colours (text / line / background) — empty = use default.
- // Live-apply to the page so the user sees the effect while typing, not only after Save.
- const overrideFieldMap = {
- 'cfg-text-color': 'text_color',
- 'cfg-line-color': 'line_color',
- 'cfg-bg-color': 'bg_color',
- };
- const liveApplyOverride = (prefix, value) => {
- const field = overrideFieldMap[prefix];
- if (!field) return;
- state.settings[field] = value || null;
- applyTheme(state.settings);
- };
- [
- { prefix: 'cfg-text-color', defaultColor: DEFAULT_TEXT_COLOR },
- { prefix: 'cfg-line-color', defaultColor: DEFAULT_LINE_COLOR },
- { prefix: 'cfg-bg-color', defaultColor: DEFAULT_BG_COLOR },
- ].forEach(({ prefix, defaultColor }) => {
- const preview = document.getElementById(prefix + '-preview');
- const hex = document.getElementById(prefix + '-hex');
- const reset = document.getElementById(prefix + '-reset');
- if (!preview || !hex || !reset) return;
-
- const normalize = (raw) => {
- let v = (raw || '').trim();
- if (!v) return '';
- if (!v.startsWith('#')) v = '#' + v;
- return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null;
- };
-
- preview.addEventListener('click', async () => {
- const picked = await openColorPicker(preview, hex.value || defaultColor);
- if (picked) {
- hex.value = picked.toUpperCase();
- preview.style.background = picked;
- liveApplyOverride(prefix, picked);
- }
- });
-
- const onTyped = () => {
- const norm = normalize(hex.value);
- if (norm === '') {
- preview.style.background = defaultColor;
- liveApplyOverride(prefix, null);
- } else if (norm) {
- preview.style.background = norm;
- liveApplyOverride(prefix, norm);
- }
- };
- hex.addEventListener('input', onTyped);
- hex.addEventListener('change', () => {
- const norm = normalize(hex.value);
- if (norm) hex.value = norm;
- });
-
- reset.addEventListener('click', () => {
- hex.value = '';
- preview.style.background = defaultColor;
- liveApplyOverride(prefix, null);
- });
- });
+ // Global "sync everything" master toggle (per-row toggles are wired per render).
+ const syncAllBtn = document.getElementById('cfg-sync-all');
+ if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
// Panel navigation
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
});
- // Contrast / hour-height selectors
- document.querySelectorAll('.contrast-selector').forEach(sel => {
- sel.addEventListener('click', e => {
- const btn = e.target.closest('.contrast-btn');
- if (!btn) return;
- sel.querySelectorAll('.contrast-btn').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- });
- });
-
document.getElementById('btn-add-user').onclick = () => {
document.getElementById('add-user-form').classList.toggle('hidden');
};
@@ -4249,71 +4304,35 @@ function bindSettingsModal() {
} catch (e) { showToast(e.message, true); }
};
- // Profile chapter save (name/login/email → /profile, separate from settings).
- const profileSaveBtn = document.getElementById('cfg-profile-save');
- if (profileSaveBtn) profileSaveBtn.onclick = async () => {
- const email = document.getElementById('cfg-email').value.trim();
- const displayName = document.getElementById('cfg-display-name').value.trim();
- const loginName = document.getElementById('cfg-login-name').value.trim();
- const user = JSON.parse(localStorage.getItem('user') || '{}');
- const body = { email: email || null };
- if (displayName) body.display_name = displayName;
- if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
- const dh = document.getElementById('cfg-directory-hidden');
- if (dh) body.directory_hidden = dh.checked;
- try {
- const res = await api.put('/profile/', body);
- if (res && res.access_token) localStorage.setItem('token', res.access_token);
- const updated = { ...user };
- if (displayName) updated.display_name = displayName;
- if (body.username) updated.username = body.username.toLowerCase();
- localStorage.setItem('user', JSON.stringify(updated));
- const dd = document.getElementById('dropdown-username');
- if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer';
- showToast(t('profile_saved'));
- } catch (e) { showToast(e.message, true); }
- };
-
+ // Unified save: appearance sync table + sync flags + account settings + profile.
document.getElementById('settings-save').onclick = async () => {
- const getActive = (id) => {
- const btn = document.querySelector(`#${id} .contrast-btn.active`);
- return btn ? Number(btn.dataset.val) : null;
- };
- // Optional override colours: empty input → null (use default).
- // Tolerant: accepts both "ff0000" and "#ff0000".
- const colourOrNull = (id) => {
- let v = (document.getElementById(id).value || '').trim();
- if (!v) return null;
- if (!v.startsWith('#')) v = '#' + v;
- return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null;
- };
- const settings = {
- default_view: document.getElementById('cfg-default-view').value,
- week_start_day: document.getElementById('cfg-week-start').value,
- primary_color: document.getElementById('cfg-primary-hex').value,
- accent_color: document.getElementById('cfg-accent-hex').value,
- today_color: document.getElementById('cfg-today-hex').value,
- month_divider_color: document.getElementById('cfg-month-divider-hex').value,
- month_label_color: document.getElementById('cfg-month-label-hex').value,
- text_color: colourOrNull('cfg-text-color-hex'),
- line_color: colourOrNull('cfg-line-color-hex'),
- bg_color: colourOrNull('cfg-bg-color-hex'),
- dim_past_events: document.getElementById('cfg-dim-past').checked,
- default_event_duration_minutes: getActive('cfg-event-duration') || 60,
- hour_height: getActive('cfg-hour-height') || 44,
- language: document.getElementById('cfg-language').value,
- private_event_visibility: document.getElementById('cfg-private-visibility').value,
- };
- const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
- settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
- const shareIconPicker = document.getElementById('cfg-share-icon');
- settings.share_calendar_icon = shareIconPicker?.querySelector('.group-icon-opt.on')?.dataset.shareIcon || null;
try {
- await api.put('/settings/', settings);
- state.settings = { ...state.settings, ...settings };
- state.dimPast = settings.dim_past_events;
- weekStartDay = settings.week_start_day;
- setLang(settings.language);
+ const appearance = readAppearanceTable();
+ // Persist every syncable value into this browser's local copy so that
+ // "not synced" keys survive per-device.
+ const local = loadLocal();
+ for (const k of SYNCABLE_KEYS) local[k] = appearance[k];
+ saveLocal(local);
+ // Account settings (kept out of the sync table — always account-wide).
+ const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
+ const payload = {
+ sync_flags: state.syncFlags,
+ private_event_visibility: document.getElementById('cfg-private-visibility').value,
+ group_visible_calendar_id: gvVal ? parseInt(gvVal) : null,
+ };
+ // Push only the values whose sync flag is on.
+ for (const k of SYNCABLE_KEYS) {
+ if (state.syncFlags[k]) payload[k] = appearance[k];
+ }
+ await api.put('/settings/', payload);
+ await saveProfileFields();
+ // Recompute effective settings: server holds the synced values, unsynced
+ // keys resolve from the freshly-saved local copy.
+ state.serverSettings = { ...state.serverSettings, ...payload };
+ state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal());
+ state.dimPast = state.settings.dim_past_events;
+ weekStartDay = state.settings.week_start_day;
+ setLang(appearance.language);
applyTheme(state.settings);
showToast(t('settings_saved'));
closeModal('modal-settings');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index ab7a2d7..5800f97 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -65,6 +65,10 @@ const translations = {
settings_nav_calendars: 'Kalender',
settings_nav_google: 'Google Konten',
settings_nav_users: 'Benutzerverwaltung',
+ settings_sync_all: 'Alle synchronisieren',
+ settings_sync_all_desc: 'Diese Einstellungen zwischen deinen Geräten teilen',
+ settings_sync_this: 'Zwischen Geräten synchronisieren',
+ settings_share_icon: 'Teilen-Symbol',
settings_colors: 'Farben',
settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe',
settings_today_color: 'Heutige-Tag-Farbe',
@@ -413,6 +417,10 @@ const translations = {
settings_nav_calendars: 'Calendars',
settings_nav_google: 'Google Accounts',
settings_nav_users: 'User Management',
+ settings_sync_all: 'Sync all',
+ settings_sync_all_desc: 'Share these settings across your devices',
+ settings_sync_this: 'Sync across devices',
+ settings_share_icon: 'Share icon',
settings_colors: 'Colors',
settings_primary_color: 'Primary color', settings_accent_color: 'Accent color',
settings_today_color: 'Today highlight color',
diff --git a/frontend/js/settings-sync.js b/frontend/js/settings-sync.js
new file mode 100644
index 0000000..7e0deb2
--- /dev/null
+++ b/frontend/js/settings-sync.js
@@ -0,0 +1,104 @@
+// Per-setting cross-device sync for the web client.
+//
+// The server is the sole authority for WHICH settings sync (it returns a fully
+// resolved `sync_flags` map). This module owns the browser-local copy of every
+// syncable value so that "not synced" works per browser, plus the declarative
+// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
+
+// Canonical default colours — the single source for the web. Reset writes these.
+export const DEFAULT_COLORS = {
+ primary_color: '#4285F4',
+ accent_color: '#EA4335',
+ today_color: '#4285F4',
+ text_color: '#FFFFFF',
+ bg_color: '#000000',
+ line_color: '#3A3A52',
+ month_divider_color: '#7090C0',
+ month_label_color: '#7090C0',
+};
+
+// The syncable settings the web client exposes, grouped into table sections.
+// `type`: 'select' | 'toggle' | 'color' | 'icon'.
+// Option labels use i18n keys via `tk`, or a literal `label`.
+export const SETTING_GROUPS = [
+ {
+ titleKey: 'settings_calendar_view',
+ rows: [
+ { key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
+ { v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
+ { v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
+ { v: 'agenda', tk: 'view_agenda' },
+ ] },
+ { key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
+ { v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
+ ] },
+ { key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
+ { key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
+ { v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
+ { v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
+ ] },
+ { key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
+ { v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
+ { v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
+ ] },
+ ],
+ },
+ {
+ titleKey: 'settings_language',
+ rows: [
+ { key: 'language', labelKey: 'settings_language', type: 'select', opts: [
+ { v: 'de', label: 'Deutsch' }, { v: 'en', label: 'English' },
+ ] },
+ { key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
+ ],
+ },
+ {
+ titleKey: 'settings_colors',
+ rows: [
+ { key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
+ { key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
+ { key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
+ { key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
+ { key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
+ { key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
+ { key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
+ { key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
+ ],
+ },
+];
+
+// Flat list of every syncable key the web manages (table order).
+export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
+
+const LOCAL_KEY = 'settingsLocal';
+
+export function loadLocal() {
+ try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
+ catch (_) { return {}; }
+}
+
+export function saveLocal(obj) {
+ try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
+}
+
+// Effective value of a syncable key: synced → server value; otherwise the
+// browser-local value (falling back to the server value if we have none yet).
+export function effectiveValue(key, server, flags, local) {
+ if (flags[key]) return server[key];
+ return (key in local && local[key] != null) ? local[key] : server[key];
+}
+
+// Build the effective settings object: a copy of the raw server settings with
+// each syncable key resolved to its effective value. As a side effect, mirror
+// every effective value into the local copy so that flipping a flag OFF later
+// retains the currently-visible value.
+export function mergeEffective(server, flags, local) {
+ const eff = { ...server };
+ for (const key of SYNCABLE_KEYS) {
+ const val = effectiveValue(key, server, flags, local);
+ eff[key] = val;
+ if (val != null) local[key] = val;
+ }
+ saveLocal(local);
+ return eff;
+}
diff --git a/frontend/sw.js b/frontend/sw.js
index 9df0597..aecdda6 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v25';
+const CACHE_VERSION = 'calendarr-v26';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {