From db43bad43227f4e2a18fc2ec91af014eac1caf07 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 9 Jun 2026 19:33:40 +0200 Subject: [PATCH] Web: reminders UI, per-calendar reminder toggle, popup/grid polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event popup: new "Reminders" section for local events β€” show/add/remove multiple reminders, prefilled with the global default on new events; sent in the local create/update body and synced like any other event field. - Sidebar calendar list: per-calendar reminder bell toggle (PUTs the new reminders_enabled flag); disabled state shown dimmed. - Date picker: a day click now commits immediately in date-only mode instead of requiring an extra "Save" click. - Start/End: moving the end before the start now shifts the start back (mirroring the start handler) instead of showing an error toast. - Calendar dropdown: group calendars moved under a "Groups" optgroup; dropped the πŸ‘₯ emoji prefix (a native can't render the group icon SVG). + const localCals = state.localCalendars.filter(c => !c.sidebar_hidden); + const makeLocalOpt = cal => { const opt = document.createElement('option'); opt.value = `local-${cal.id}`; - opt.textContent = cal.group ? `πŸ‘₯ ${cal.name}` : cal.name; + opt.textContent = cal.name; if (`local-${cal.id}` === selectedId) opt.selected = true; - sel.appendChild(opt); - }); + return opt; + }; + localCals.filter(c => !c.group).forEach(cal => sel.appendChild(makeLocalOpt(cal))); + const groupCals = localCals.filter(c => c.group); + if (groupCals.length) { + const og = document.createElement('optgroup'); + og.label = t('groups_title'); + groupCals.forEach(cal => og.appendChild(makeLocalOpt(cal))); + sel.appendChild(og); + } // iCal subscriptions are read-only, not shown here // Google calendars (read/write) state.googleAccounts.forEach(acc => { @@ -1542,6 +1593,10 @@ function openNewEventModal(date) { toggleAlldayFields(false); populateCalendarSelect(null); updatePrivateRow(false); + // New local event: prefill with the global default reminder if one is set. + const defMin = state.settings && state.settings.default_reminder_minutes; + setEventReminders(defMin != null ? [defMin] : []); + updateRemindersRow(); resetColorPicker(''); resetRecurrenceUI(); document.getElementById('ev-delete').classList.add('hidden'); @@ -1578,6 +1633,8 @@ function openCopyEditModal(ev, targetCal) { else selectedId = `${targetCal.type}-${targetCal.id}`; populateCalendarSelect(selectedId); updatePrivateRow(ev.private); + setEventReminders(ev.reminders || []); + updateRemindersRow(); resetColorPicker(ev.color || ''); resetRecurrenceUI(); @@ -1611,6 +1668,8 @@ function openEditEventModal(ev) { populateCalendarSelect(ev.calendar_id); updatePrivateRow(ev.private); + setEventReminders(ev.reminders || []); + updateRemindersRow(); resetColorPicker(ev.color || ''); // Recurrence @@ -1658,6 +1717,70 @@ function resetColorPicker(color) { preview.style.background = color || 'var(--primary)'; } +// ── Reminders (local events only) ───────────────────────── +const REMINDER_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; + +function reminderLabel(min) { + if (min <= 0) return t('reminder_at_start'); + if (min < 60) return t('reminder_min', { n: min }); + if (min < 1440) return t('reminder_hour', { n: min / 60 }); + return t('reminder_day', { n: min / 1440 }); +} + +function setEventReminders(arr) { + state.eventReminders = Array.isArray(arr) + ? arr.map(Number).filter(n => !isNaN(n)) : []; + renderReminderRows(); +} + +function renderReminderRows() { + const list = document.getElementById('ev-reminders-list'); + if (!list) return; + list.innerHTML = ''; + state.eventReminders.forEach((min, idx) => { + const row = document.createElement('div'); + row.className = 'ev-reminder-row'; + const sel = document.createElement('select'); + // Keep a non-catalog value (from an old/imported reminder) selectable. + const opts = REMINDER_OPTIONS.includes(min) ? REMINDER_OPTIONS : [min, ...REMINDER_OPTIONS]; + opts.forEach(v => { + const o = document.createElement('option'); + o.value = String(v); + o.textContent = reminderLabel(v); + if (v === min) o.selected = true; + sel.appendChild(o); + }); + sel.addEventListener('change', () => { + state.eventReminders[idx] = parseInt(sel.value, 10); + }); + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'icon-btn ev-reminder-remove'; + rm.innerHTML = '×'; + rm.addEventListener('click', () => { + state.eventReminders.splice(idx, 1); + renderReminderRows(); + }); + row.appendChild(sel); + row.appendChild(rm); + list.appendChild(row); + }); +} + +function addReminderRow() { + const def = (state.settings && state.settings.default_reminder_minutes != null) + ? state.settings.default_reminder_minutes : 10; + state.eventReminders.push(REMINDER_OPTIONS.includes(def) ? def : 10); + renderReminderRows(); +} + +// Reminders apply to local events only (the server stores them on local events). +function updateRemindersRow() { + const calVal = document.getElementById('ev-calendar').value || ''; + const isLocal = calVal.startsWith('local-'); + document.getElementById('ev-reminders-group').style.display = isLocal ? '' : 'none'; +} + function buildRruleFromUI() { const sel = document.getElementById('ev-recurrence').value; if (!sel) return null; @@ -1740,8 +1863,13 @@ function bindEventModal() { toggleAlldayFields(e.target.checked); }); - // The "Privat" toggle is only relevant for local calendars. - document.getElementById('ev-calendar').addEventListener('change', () => updatePrivateRow()); + // The "Privat" toggle and reminders are only relevant for local calendars. + document.getElementById('ev-calendar').addEventListener('change', () => { + updatePrivateRow(); + updateRemindersRow(); + }); + + document.getElementById('ev-reminder-add').addEventListener('click', addReminderRow); // Date/time pickers with auto-adjustment logic [ @@ -1781,19 +1909,21 @@ function bindEventModal() { } } } else { - // Validate end is not before start + // End moved before start β†’ move the start back instead of erroring + // (mirror of the start handler, which moves the end). if (mode === 'datetime') { const startVal = document.getElementById('ev-start').value; - if (startVal && new Date(result) <= new Date(startVal)) { - const corrected = new Date(new Date(startVal).getTime() + 3600000); - setDtValue('ev-end', toLocalDatetimeInput(corrected), 'datetime'); - showToast(t('error_end_before_start'), true); + if (startVal && new Date(result) < new Date(startVal)) { + const os = oldStart ? new Date(oldStart) : null; + const oe = oldEnd ? new Date(oldEnd) : null; + const duration = (os && oe && oe > os) ? (oe - os) : 3600000; + const ns = new Date(new Date(result).getTime() - duration); + setDtValue('ev-start', toLocalDatetimeInput(ns), 'datetime'); } } else { const startVal = document.getElementById('ev-start-date').value; if (startVal && result < startVal) { - setDtValue('ev-end-date', startVal, 'date'); - showToast(t('error_end_before_start'), true); + setDtValue('ev-start-date', result, 'date'); } } } @@ -1896,7 +2026,7 @@ function bindEventModal() { ); } else if (ev.source === 'local') { await api.put(`/local/events/${encodeURIComponent(ev.id)}`, - { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate } + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate, reminders: state.eventReminders } ); } else if (ev.source === 'ical') { showToast(t('event_readonly'), true); @@ -1919,6 +2049,7 @@ function bindEventModal() { color: color || null, rrule: rrule || null, private: ev.source === 'local' ? isPrivate : ev.private, + reminders: ev.source === 'local' ? state.eventReminders.slice() : ev.reminders, }); showToast(t('event_updated')); } else if (isGoogle) { @@ -1933,7 +2064,7 @@ function bindEventModal() { await api.post('/local/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, - rrule: rrule || null, private: isPrivate, + rrule: rrule || null, private: isPrivate, reminders: state.eventReminders, }); showToast(t('event_created')); } else if (isHA) { diff --git a/frontend/js/date-picker.js b/frontend/js/date-picker.js index 80678dd..c2b34db 100644 --- a/frontend/js/date-picker.js +++ b/frontend/js/date-picker.js @@ -148,6 +148,9 @@ export function openDatePicker(anchor, value, mode = 'datetime') { card.querySelectorAll('.dtp-day').forEach(el => { el.addEventListener('click', () => { selDate = new Date(parseInt(el.dataset.ts)); + // In date-only mode there's nothing else to choose β€” commit the day + // immediately instead of forcing an extra "Save" click. + if (mode === 'date') { done(buildResult()); return; } if (el.classList.contains('other')) { viewYear = selDate.getFullYear(); viewMonth = selDate.getMonth(); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 3fb1d3f..efd841c 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -94,6 +94,14 @@ const translations = { private_visibility_hidden: 'Ausblenden', created_by: 'Erstellt von: {name}', event_private: 'Privat', + reminders: 'Benachrichtigungen', + reminder_add: 'Benachrichtigung hinzufΓΌgen', + reminder_at_start: 'Zur Startzeit', + reminder_min: 'vor {n} Min.', + reminder_hour: 'vor {n} Std.', + reminder_day: 'vor {n} Tag(en)', + calendar_reminders_on: 'Benachrichtigungen aktiviert', + calendar_reminders_off: 'Benachrichtigungen deaktiviert', share: 'Teilen', import: 'Importieren', export: 'Exportieren', @@ -367,6 +375,14 @@ const translations = { private_visibility_hidden: 'Hide completely', created_by: 'Created by: {name}', event_private: 'Private', + reminders: 'Reminders', + reminder_add: 'Add reminder', + reminder_at_start: 'At start time', + reminder_min: '{n} min before', + reminder_hour: '{n} h before', + reminder_day: '{n} day(s) before', + calendar_reminders_on: 'Reminders enabled', + calendar_reminders_off: 'Reminders disabled', share: 'Share', import: 'Import', export: 'Export', diff --git a/frontend/js/version.js b/frontend/js/version.js index edad6d1..c061003 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v44'; +export const APP_VERSION = 'v45';