From 6baa07379fda39e91a4d583d655b8442d859b435 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 04:20:49 +0200 Subject: [PATCH] Rework birthdays to a single calendar + UI fixes (web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - one dedicated birthday calendar per user; enable it in Settings > Calendars (or from the New-birthday dialog) — it then shows in the sidebar like any calendar (colour/visibility) - New-birthday dialog: no target picker; day/month selects + a year field that hides when "year unknown"; fixed the year-unknown checkbox layout - create split-button is now one seamless pill; the caret opens the menu - Settings > Calendars gains a Birthdays section (enable + notify-days-before) - removed the birthday toggle from the generic new-calendar modal - profile settings: right-align the section save button for consistency - bump service-worker cache to v25 so assets refresh Co-Authored-By: Claude Opus 4.8 --- frontend/css/app.css | 29 +++++-- frontend/index.html | 53 ++++++------ frontend/js/calendar.js | 173 ++++++++++++++++++++++++++++------------ frontend/js/i18n.js | 14 ++++ frontend/sw.js | 2 +- 5 files changed, 186 insertions(+), 85 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d2846f8..cdda4d2 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -165,15 +165,28 @@ a { color: var(--primary); text-decoration: none; } } .btn-fab:active { transform: translateY(0) scale(.985); } -/* Split create button: main "Erstellen" action + a caret that opens a small - menu (new event / new birthday). */ -.create-split { display: flex; align-items: stretch; gap: 4px; margin: 16px 12px 8px; } -.create-split .btn-fab { margin: 0; } -.create-split .create-main { flex: 1; justify-content: center; } -.create-split .create-caret { flex: 0 0 auto; padding: 12px 12px; } +/* Split create button: one seamless pill — main "Erstellen" action on the left, + a caret section on the right that opens a small menu (new event / new birthday). */ +.create-split { + display: flex; align-items: stretch; margin: 16px 12px 8px; + border-radius: 999px; + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent); +} +.create-split .btn-fab { margin: 0; box-shadow: none; border-radius: 0; } +.create-split .btn-fab:hover { transform: none; box-shadow: none; filter: brightness(1.08); } +.create-split .create-main { flex: 1; justify-content: center; border-radius: 999px 0 0 999px; } +.create-split .create-caret { + flex: 0 0 auto; padding: 12px 14px; border-radius: 0 999px 999px 0; + border-left: 1px solid color-mix(in srgb, #fff 25%, transparent); +} .create-split .create-caret svg { width: 18px; height: 18px; } .create-menu { left: 0; right: auto; min-width: 200px; } +/* Birthday modal: day / month / year row */ +.birthday-date-row { display: flex; gap: 8px; } +.birthday-date-row select { flex: 1; } +.birthday-date-row #birthday-year { width: 90px; flex: 0 0 auto; } + /* Circular icon buttons (topbar nav, modal close, etc.) */ .icon-btn { display: inline-flex; align-items: center; justify-content: center; @@ -1487,6 +1500,10 @@ a { color: var(--primary); text-decoration: none; } .ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; } .app-pw-create { display: flex; gap: 8px; align-items: center; } .app-pw-create input { flex: 1; } + +/* Consistent placement for a section's action button: right-aligned, matching + the sticky header save and the app-password create row. */ +.settings-actions { display: flex; justify-content: flex-end; margin-top: 10px; } .app-pw-new { margin: 8px 0; } .app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); } .app-pw-name { font-weight: 500; } diff --git a/frontend/index.html b/frontend/index.html index 8628794..10c388d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -590,17 +590,6 @@
-
- -
Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.
-
- diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 11b0220..e6d43a4 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -2538,22 +2538,12 @@ function openLocalCalModal() { document.getElementById('local-cal-name').value = ''; document.getElementById('local-cal-color-hex').value = '#34a853'; document.getElementById('local-cal-color-preview').style.background = '#34a853'; - const bday = document.getElementById('local-cal-birthday'); - bday.checked = false; - document.getElementById('local-cal-notify').value = '-1'; - document.getElementById('local-cal-notify-group').classList.add('hidden'); openModal('modal-local-cal'); } function bindLocalCalModal() { const preview = document.getElementById('local-cal-color-preview'); const hex = document.getElementById('local-cal-color-hex'); - const bday = document.getElementById('local-cal-birthday'); - const notifyGroup = document.getElementById('local-cal-notify-group'); - fillBirthdayNotifySelect(document.getElementById('local-cal-notify')); - bday.addEventListener('change', () => { - notifyGroup.classList.toggle('hidden', !bday.checked); - }); preview.addEventListener('click', async () => { const picked = await openColorPicker(preview, hex.value || '#34a853'); if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } @@ -2568,15 +2558,8 @@ function bindLocalCalModal() { const name = document.getElementById('local-cal-name').value.trim(); if (!name) { showToast(t('error_enter_name'), true); return; } const color = hex.value; - const isBirthday = bday.checked; - const notify = parseInt(document.getElementById('local-cal-notify').value, 10); - const body = { name, color }; - if (isBirthday) { - body.is_birthday = true; - if (notify >= 0) body.birthday_notify_days_before = notify; - } try { - const cal = await api.post('/local/calendars', body); + const cal = await api.post('/local/calendars', { name, color }); state.localCalendars.push(cal); renderCalendarList(); closeModal('modal-local-cal'); @@ -2585,53 +2568,90 @@ function bindLocalCalModal() { }; } +// ── Birthdays (single dedicated calendar per user) ───────── +const BIRTHDAY_DEFAULT_COLOR = '#e0407f'; + +/** The user's single birthday calendar (owned, is_birthday), or null. */ +function birthdayCalendar() { + return (state.localCalendars || []).find(c => c.is_birthday && c.owned !== false) || null; +} + +/** Create the single "Geburtstage" birthday calendar if none exists; returns it. */ +async function ensureBirthdayCalendar() { + const existing = birthdayCalendar(); + if (existing) return existing; + const cal = await api.post('/local/calendars', { + name: t('birthday_calendar_name'), color: BIRTHDAY_DEFAULT_COLOR, is_birthday: true, + }); + state.localCalendars.push(cal); + renderCalendarList(); + return cal; +} + // ── Birthday Modal (manual entry) ───────────────────────── -function birthdayCalendars() { - return (state.localCalendars || []).filter( - c => c.is_birthday && (c.owned !== false || c.permission === 'read_write') - ); +function fillBirthdayDateSelects() { + const daySel = document.getElementById('birthday-day'); + const monthSel = document.getElementById('birthday-month'); + if (daySel.options.length === 0) { + daySel.innerHTML = Array.from({ length: 31 }, (_, i) => + ``).join(''); + } + if (monthSel.options.length === 0) { + const months = t('months'); + monthSel.innerHTML = months.map((m, i) => ``).join(''); + } } function openBirthdayModal() { - const cals = birthdayCalendars(); - const sel = document.getElementById('birthday-cal'); - sel.innerHTML = cals.map(c => ``).join(''); - const none = cals.length === 0; - document.getElementById('birthday-no-cals').classList.toggle('hidden', !none); - document.getElementById('birthday-cal-group').classList.toggle('hidden', none || cals.length === 1); - document.getElementById('birthday-name').value = ''; - document.getElementById('birthday-year-unknown').checked = false; - const dateEl = document.getElementById('birthday-date'); - const today = new Date(); - dateEl.value = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`; - document.getElementById('birthday-save').disabled = none; + const cal = birthdayCalendar(); + document.getElementById('birthday-no-cal').classList.toggle('hidden', !!cal); + document.getElementById('birthday-form').classList.toggle('hidden', !cal); + document.getElementById('birthday-save').classList.toggle('hidden', !cal); + if (cal) { + fillBirthdayDateSelects(); + const today = new Date(); + document.getElementById('birthday-name').value = ''; + document.getElementById('birthday-day').value = String(today.getDate()); + document.getElementById('birthday-month').value = String(today.getMonth() + 1); + document.getElementById('birthday-year').value = String(today.getFullYear()); + document.getElementById('birthday-year-unknown').checked = false; + document.getElementById('birthday-year').classList.remove('hidden'); + } openModal('modal-birthday'); } function bindBirthdayModal() { + const yearUnknown = document.getElementById('birthday-year-unknown'); + const yearInput = document.getElementById('birthday-year'); + yearUnknown.addEventListener('change', () => { + yearInput.classList.toggle('hidden', yearUnknown.checked); + }); + + document.getElementById('birthday-activate').onclick = async () => { + try { + await ensureBirthdayCalendar(); + showToast(t('calendar_created', { name: t('birthday_calendar_name') })); + openBirthdayModal(); // re-render the modal in form mode + } catch (e) { showToast(e.message, true); } + }; + document.getElementById('birthday-save').onclick = async () => { - const cals = birthdayCalendars(); - if (!cals.length) return; + const cal = birthdayCalendar(); + if (!cal) return; const name = document.getElementById('birthday-name').value.trim(); if (!name) { showToast(t('error_enter_name'), true); return; } - const dateStr = document.getElementById('birthday-date').value; // YYYY-MM-DD - if (!dateStr) return; - const [y, m, d] = dateStr.split('-').map(n => parseInt(n, 10)); - const yearUnknown = document.getElementById('birthday-year-unknown').checked; - const calSel = document.getElementById('birthday-cal'); - const calId = parseInt(calSel.value || cals[0].id, 10); + const d = parseInt(document.getElementById('birthday-day').value, 10); + const m = parseInt(document.getElementById('birthday-month').value, 10); + const unknown = yearUnknown.checked; + const y = unknown ? 1970 : parseInt(yearInput.value, 10) || new Date().getFullYear(); // All-day anchor: birth year if known, else 1970 so FREQ=YEARLY expands // across any queried range. end = next day. - const anchorYear = yearUnknown ? 1970 : y; const pad = n => String(n).padStart(2, '0'); - const start = `${anchorYear}-${pad(m)}-${pad(d)}`; - const endDate = new Date(Date.UTC(anchorYear, m - 1, d + 1)); - const end = `${endDate.getUTCFullYear()}-${pad(endDate.getUTCMonth()+1)}-${pad(endDate.getUTCDate())}`; - const body = { - calendar_id: calId, title: name, start, end, allDay: true, - rrule: 'FREQ=YEARLY', - }; - if (!yearUnknown) body.birth_year = y; + const start = `${y}-${pad(m)}-${pad(d)}`; + const endDate = new Date(Date.UTC(y, m - 1, d + 1)); + const end = `${endDate.getUTCFullYear()}-${pad(endDate.getUTCMonth() + 1)}-${pad(endDate.getUTCDate())}`; + const body = { calendar_id: cal.id, title: name, start, end, allDay: true, rrule: 'FREQ=YEARLY' }; + if (!unknown) body.birth_year = y; try { await api.post('/local/events', body); closeModal('modal-birthday'); @@ -3561,6 +3581,57 @@ function renderAllAccounts() { } renderCalendarTable(); + renderBirthdaySettings(); +} + +/** Settings › Kalender › Geburtstage: activate the single birthday calendar, + * or (when active) tweak its "notify N days before". Colour/visibility live in + * the sidebar like any other calendar. */ +function renderBirthdaySettings() { + const container = document.getElementById('birthday-settings'); + if (!container) return; + container.innerHTML = ''; + const cal = birthdayCalendar(); + if (!cal) { + const hint = document.createElement('div'); + hint.className = 'form-hint'; + hint.textContent = t('birthday_activate_hint'); + const btn = document.createElement('button'); + btn.className = 'btn btn-primary btn-sm'; + btn.textContent = t('birthday_activate'); + btn.onclick = async () => { + try { + await ensureBirthdayCalendar(); + renderBirthdaySettings(); + renderCalendarTable(); + showToast(t('calendar_created', { name: t('birthday_calendar_name') })); + } catch (e) { showToast(e.message, true); } + }; + container.append(hint, btn); + return; + } + const status = document.createElement('div'); + status.className = 'form-hint'; + status.textContent = t('birthday_active'); + const notifyWrap = document.createElement('div'); + notifyWrap.className = 'form-group'; + const label = document.createElement('label'); + label.textContent = t('birthday_notify'); + const sel = document.createElement('select'); + fillBirthdayNotifySelect(sel); + sel.value = String(cal.birthday_notify_days_before ?? -1); + sel.onchange = async () => { + const v = parseInt(sel.value, 10); + try { + await api.put(`/local/calendars/${cal.id}`, { birthday_notify_days_before: v }); + cal.birthday_notify_days_before = v < 0 ? null : v; + } catch (e) { showToast(e.message, true); } + }; + notifyWrap.append(label, sel); + const colorHint = document.createElement('div'); + colorHint.className = 'form-hint'; + colorHint.textContent = t('birthday_color_hint'); + container.append(status, notifyWrap, colorHint); } function renderHiddenCalendars() { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 573efab..90d835b 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -290,6 +290,13 @@ const translations = { birthday_target: 'Geburtstagskalender', birthday_no_calendars: 'Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender".', birthday_created: 'Geburtstag „{name}" hinzugefügt', + birthday_calendar_name: 'Geburtstage', + birthday_activate: 'Geburtstagskalender aktivieren', + birthday_activate_hint: 'Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.', + birthday_active: 'Geburtstagskalender aktiv', + birthday_year_ph: 'Jahr', + birthday_settings_title: 'Geburtstage', + birthday_color_hint: 'Farbe und Sichtbarkeit änderst du in der Seitenleiste.', error_name_url: 'Bitte Name und URL eingeben', ical_subscribed: '"{name}" abonniert', google_synced: 'Kalender synchronisiert', @@ -625,6 +632,13 @@ const translations = { birthday_target: 'Birthday calendar', birthday_no_calendars: 'No birthday calendar yet. Create a calendar and enable “Birthday calendar” first.', birthday_created: 'Birthday “{name}” added', + birthday_calendar_name: 'Birthdays', + birthday_activate: 'Enable birthday calendar', + birthday_activate_hint: 'Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.', + birthday_active: 'Birthday calendar active', + birthday_year_ph: 'Year', + birthday_settings_title: 'Birthdays', + birthday_color_hint: 'Change colour and visibility in the sidebar.', error_name_url: 'Please enter a name and URL', ical_subscribed: '"{name}" subscribed', google_synced: 'Calendar synced', diff --git a/frontend/sw.js b/frontend/sw.js index 21e7185..9df0597 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-v24'; +const CACHE_VERSION = 'calendarr-v25'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => {