Web: reminders UI, per-calendar reminder toggle, popup/grid polish
- 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 <select> can't render the icon SVG). - Month grid: selectedDate defaults to today so the highlight no longer follows the cells while navigating before any click. - Today cell tint now derives from --today-color instead of hardcoded blue. - version v44 -> v45. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,10 @@ let weekStartDay = 'monday';
|
||||
|
||||
let state = {
|
||||
currentDate: new Date(),
|
||||
selectedDate: null, // separate from currentDate; used for month-view selection
|
||||
// Default to today so the month-view selection is anchored to a fixed date.
|
||||
// (If left null the view falls back to currentDate, which then appears to
|
||||
// "follow" the cells while navigating/scrolling before any explicit click.)
|
||||
selectedDate: new Date(),
|
||||
currentView: 'month',
|
||||
events: [],
|
||||
accounts: [],
|
||||
@@ -681,6 +684,8 @@ function renderCalendarList() {
|
||||
// Eye-off (hide external calendar) and trash (delete local/ical) icons.
|
||||
const EYE_OFF = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>`;
|
||||
const TRASH = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
||||
const BELL = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>`;
|
||||
const BELL_OFF = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31L18 14.68z"/></svg>`;
|
||||
|
||||
// Build a single flat list of all calendars. The source/account is shown
|
||||
// inline (small, grey) next to the name and section headers are gone, so the
|
||||
@@ -690,6 +695,7 @@ function renderCalendarList() {
|
||||
(acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
|
||||
entries.push({ key: `caldav:${cal.id}`, source: 'caldav', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled,
|
||||
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||
sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
||||
});
|
||||
});
|
||||
@@ -697,6 +703,7 @@ function renderCalendarList() {
|
||||
state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => {
|
||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
||||
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||
sourceLabel: t('cal_local'), groupVisible: cal.id === groupVisibleId,
|
||||
remove: { icon: TRASH, title: t('remove_cal') } });
|
||||
});
|
||||
@@ -715,12 +722,14 @@ function renderCalendarList() {
|
||||
state.icalSubscriptions.forEach(sub => {
|
||||
entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
|
||||
name: sub.name, color: sub.color, enabled: sub.enabled,
|
||||
reminders: true, remindersEnabled: sub.reminders_enabled !== false,
|
||||
sourceLabel: t('cal_ical'), remove: { icon: TRASH, title: t('remove_ical_sub') } });
|
||||
});
|
||||
state.googleAccounts.forEach(acc => {
|
||||
(acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
|
||||
entries.push({ key: `google:${cal.id}`, source: 'google', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled,
|
||||
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||
sourceLabel: acc.email, remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
||||
});
|
||||
});
|
||||
@@ -728,6 +737,7 @@ function renderCalendarList() {
|
||||
(acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
|
||||
entries.push({ key: `homeassistant:${cal.id}`, source: 'homeassistant', dataId: `data-cal-id="${cal.id}"`,
|
||||
name: cal.name, color: cal.color || '#03a9f4', enabled: cal.enabled,
|
||||
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||
sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
||||
});
|
||||
});
|
||||
@@ -756,6 +766,7 @@ function renderCalendarList() {
|
||||
<span class="cal-item-name" data-source="${e.source}">${escHtml(e.name)}</span>
|
||||
${e.isGroupCal ? `<span class="cal-shared-flag" title="${escHtml(e.sourceLabel)}">${groupIconSvg('people', 13)}</span>` : ''}
|
||||
${e.groupVisible ? `<span class="cal-shared-flag" title="${t('group_visible_flag')}">${groupIconSvg('people', 13)}</span>` : ''}
|
||||
${e.reminders ? `<button class="icon-btn mini-btn cal-item-bell ${e.remindersEnabled ? '' : 'off'}" data-source="${e.source}" ${e.dataId} title="${e.remindersEnabled ? t('calendar_reminders_on') : t('calendar_reminders_off')}">${e.remindersEnabled ? BELL : BELL_OFF}</button>` : ''}
|
||||
${e.remove ? `<button class="icon-btn mini-btn cal-item-remove" data-source="${e.source}" ${e.dataId} title="${e.remove.title}">${e.remove.icon}</button>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
@@ -825,6 +836,37 @@ function renderCalendarList() {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reminder bell handlers (toggle reminders_enabled per calendar) ──
|
||||
container.querySelectorAll('.cal-item-bell').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const source = btn.dataset.source;
|
||||
const id = parseInt(btn.dataset.calId || btn.dataset.subId);
|
||||
let cal = null;
|
||||
if (source === 'caldav') {
|
||||
for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) cal = c; }
|
||||
} else if (source === 'local') {
|
||||
cal = state.localCalendars.find(c => c.id === id);
|
||||
} else if (source === 'ical') {
|
||||
cal = state.icalSubscriptions.find(s => s.id === id);
|
||||
} else if (source === 'google') {
|
||||
for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) cal = c; }
|
||||
} else if (source === 'homeassistant') {
|
||||
for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) cal = c; }
|
||||
}
|
||||
if (!cal) return;
|
||||
const newVal = cal.reminders_enabled === false; // toggle
|
||||
const path = source === 'ical'
|
||||
? `/ical/subscriptions/${id}`
|
||||
: `/${source}/calendars/${id}`;
|
||||
try {
|
||||
await api.put(path, { reminders_enabled: newVal });
|
||||
cal.reminders_enabled = newVal;
|
||||
renderCalendarList();
|
||||
} catch (err) { showToast(err.message, true); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── Color dot handlers ─────────────────────────────────
|
||||
container.querySelectorAll('.cal-item-dot').forEach(dot => {
|
||||
dot.addEventListener('click', async e => {
|
||||
@@ -1463,15 +1505,24 @@ function populateCalendarSelect(selectedId) {
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
// Local calendars (group calendars marked with 👥 so group events can be
|
||||
// created from anywhere, not just the group view).
|
||||
state.localCalendars.filter(c => !c.sidebar_hidden).forEach(cal => {
|
||||
// Local calendars. Group calendars are collected under a "Groups" optgroup
|
||||
// (no emoji — a native <select> 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) {
|
||||
|
||||
Reference in New Issue
Block a user