From 94cbe4e7fb3ac005ba1d27fb969bc6e331266fa9 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 21:20:42 +0200 Subject: [PATCH 001/114] feat: Spanning event bars, wheel nav, dark datetime picker, segmented settings UI - Month view: Multi-day events render as continuous Google Calendar-style spanning bars across days/weeks using a greedy lane-packing algorithm. Timed multi-day events no longer repeat per day. - Mouse wheel / trackpad scrolls week-by-week in month view, day/week in other views (debounced, prevents default page scroll). - datetime-local/date inputs now use color-scheme:dark so the native browser picker opens in dark mode; calendar icon styled to match. - Contrast/hour-height selectors redesigned as connected segmented pill controls instead of individual tiles. - Hidden calendars list gains proper padding and separator lines. - "Google Konten" settings panel renamed "Konten" and expanded to show CalDAV, local calendars, iCal subscriptions, and Google accounts in one unified panel with sync/disconnect actions. - New i18n keys added for accounts panel in both de and en. --- frontend/css/app.css | 150 +++++++++++++++++++------ frontend/index.html | 29 ++++- frontend/js/calendar.js | 118 +++++++++++++++++++- frontend/js/i18n.js | 22 ++++ frontend/js/views/month.js | 223 ++++++++++++++++++++++++------------- 5 files changed, 418 insertions(+), 124 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index e664e34..4f9797f 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -151,6 +151,19 @@ a { color: var(--primary); text-decoration: none; } border-color: var(--primary); } .form-group textarea { resize: vertical; } + +/* ── Date/time input dark mode ──────────────────────────── */ +.form-group input[type="datetime-local"], +.form-group input[type="date"] { + color-scheme: dark; +} +.form-group input[type="datetime-local"]::-webkit-calendar-picker-indicator, +.form-group input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.8); + opacity: 0.7; + cursor: pointer; +} + .form-row { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; } @@ -455,31 +468,25 @@ a { color: var(--primary); text-decoration: none; } font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); } -.month-grid { - display: grid; grid-template-columns: 38px repeat(7, 1fr); - grid-template-rows: repeat(6, 1fr); - flex: 1; overflow: hidden; -} .month-kw-cell { - border-right: 1px solid var(--border-light); - border-bottom: 1px solid var(--border); + position: absolute; left: 0; top: 0; bottom: 0; + width: 38px; display: flex; align-items: flex-start; justify-content: center; padding-top: 6px; font-size: 13px; color: var(--text-3); font-weight: 700; - cursor: default; user-select: none; - min-height: 0; + border-right: 1px solid var(--border-light); + cursor: default; user-select: none; z-index: 1; + background: var(--bg-app); } .month-cell { + flex: 1; border-right: 1px solid var(--border); - border-bottom: 1px solid var(--border); - padding: 4px; - overflow: hidden; + padding: 4px 4px 0; cursor: pointer; transition: background var(--transition); - min-height: 0; + min-width: 0; } -/* every 8th child is the last day column (after KW cell) */ -.month-cell:nth-child(8n) { border-right: none; } +.month-cell:last-child { border-right: none; } .month-cell:hover { background: var(--bg-hover); } .month-cell.today { background: rgba(66,133,244,.08); } .month-cell.other-month .cell-day { color: var(--text-3); } @@ -487,23 +494,15 @@ a { color: var(--primary); text-decoration: none; } font-size: 12px; font-weight: 500; color: var(--text-2); width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; - border-radius: 50%; margin-bottom: 2px; flex-shrink: 0; + border-radius: 50%; flex-shrink: 0; } .cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } -.month-event { - font-size: 11px; font-weight: 500; - padding: 1px 6px; border-radius: 3px; - margin-bottom: 1px; cursor: pointer; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - transition: filter var(--transition); -} -.month-event:hover { filter: brightness(1.15); } -.month-event.past { opacity: .45; } .month-more { - font-size: 11px; color: var(--text-2); padding: 1px 6px; + position: absolute; + font-size: 11px; color: var(--text-2); padding: 0 4px; cursor: pointer; font-weight: 500; } .month-more:hover { color: var(--primary); } @@ -747,29 +746,38 @@ a { color: var(--primary); text-decoration: none; } font-size: 12px; color: var(--text-3); margin: 0 0 12px; } -/* Contrast / option selectors */ +/* Contrast / option selectors — segmented pill */ .contrast-selector { - display: flex; gap: 8px; flex-wrap: wrap; + display: inline-flex; + border: 1px solid var(--border); + border-radius: 20px; + overflow: hidden; + background: var(--bg-surface); } .contrast-btn { - display: flex; flex-direction: column; align-items: center; gap: 6px; - padding: 10px 16px; border-radius: var(--radius); - border: 1px solid var(--border); background: var(--bg-surface); - cursor: pointer; transition: border-color .15s, background .15s; - min-width: 70px; + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 8px 14px; min-width: 64px; + background: transparent; border: none; + border-right: 1px solid var(--border); + cursor: pointer; color: var(--text-2); + transition: background var(--transition), color var(--transition); } -.contrast-btn:hover { border-color: var(--primary); } -.contrast-btn.active { border-color: var(--primary); background: var(--primary-dim); } +.contrast-btn:last-child { border-right: none; } +.contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); } +.contrast-btn.active { background: var(--primary); color: #fff; } .contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; } -.contrast-lbl { font-size: 11px; color: var(--text-2); white-space: nowrap; } +.contrast-btn.active span { color: #fff !important; } +.contrast-lbl { font-size: 11px; white-space: nowrap; } +.contrast-btn.active .contrast-lbl { color: #fff; } .line-preview { display: block; width: 36px; height: 0; border-top: 2px solid; border-radius: 1px; - margin: 6px 0; + margin: 4px 0; } .hour-preview { font-size: 14px; line-height: 1; color: var(--text-2); } +.contrast-btn.active .hour-preview { color: #fff; } /* ── Settings (legacy) ──────────────────────────────────── */ .settings-section { margin-bottom: 28px; } @@ -861,6 +869,74 @@ a { color: var(--primary); text-decoration: none; } .loading-view { display: flex; justify-content: center; align-items: center; height: 200px; } +/* ── Accounts Panel ──────────────────────────────────────── */ +.accounts-section { margin-bottom: 24px; } +.accounts-section-heading { + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-3); + padding: 0 0 8px; + border-bottom: 1px solid var(--border-light); + margin-bottom: 10px; +} +.accounts-section-empty { + font-size: 13px; color: var(--text-3); padding: 4px 0; display: block; +} +.accounts-row { + display: flex; align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border-light); +} +.accounts-row:last-child { border-bottom: none; } +.accounts-row-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.accounts-row-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.accounts-row-sub { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.accounts-row-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: 8px; } +.accounts-local-dot { + width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block; +} + +/* ── Month View (spanning bars) ─────────────────────────── */ +.month-body { + display: flex; flex-direction: column; flex: 1; overflow: hidden; +} +.month-row { + display: flex; flex: 1; position: relative; min-height: 0; + border-bottom: 1px solid var(--border); +} +.month-row:last-child { border-bottom: none; } +.month-row-right { + margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0; +} +.month-day-strip { + display: flex; flex-shrink: 0; +} +.month-events-area { + position: relative; flex: 1; + min-height: 72px; /* 3 lanes × 22px + 6px padding */ + overflow: hidden; +} +.month-span-event { + position: absolute; + height: 18px; line-height: 18px; + border-radius: 3px; + padding: 0 6px; + font-size: 11px; font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; color: #fff; + transition: filter var(--transition); + box-sizing: border-box; + z-index: 2; +} +.month-span-event:hover { filter: brightness(1.15); } +.month-span-event.past { opacity: .45; } +.month-span-event.continues-left { + border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px; +} +.month-span-event.continues-right { + border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px; +} + /* ── Responsive ─────────────────────────────────────────── */ @media (max-width: 768px) { :root { --sidebar-w: 0px; } diff --git a/frontend/index.html b/frontend/index.html index d62a7c5..a489f43 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -391,7 +391,7 @@
@@ -486,10 +486,29 @@
Keine ausgeblendeten Kalender
- -
-

Google Konten

-
Keine Google-Konten verbunden
+ +
+

Konten

+ +
+
CalDAV-Konten
+
Keine CalDAV-Konten
+
+ +
+
Lokale Kalender
+
Keine lokalen Kalender
+
+ +
+
iCal-Abonnements
+
Keine Abonnements
+
+ +
+
Google-Konten
+
Keine Google-Konten
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 0c1fe33..27f6fb4 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -539,6 +539,23 @@ function bindTopbar() { document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); + + // Mouse wheel / trackpad scroll navigation + let _wheelTimer = null; + document.getElementById('view-container').addEventListener('wheel', e => { + e.preventDefault(); + if (_wheelTimer) return; + _wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80); + const dir = e.deltaY > 0 ? 1 : -1; + if (state.currentView === 'agenda') return; + if (state.currentView === 'month') { + state.currentDate = new Date(state.currentDate); + state.currentDate.setDate(state.currentDate.getDate() + dir * 7); + fetchAndRender(); + } else { + navigate(dir); + } + }, { passive: false }); } // ── Sidebar toggle ──────────────────────────────────────── @@ -1071,8 +1088,8 @@ function openSettingsModal() { const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)'); if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel); - // Render Google accounts and hidden calendars - renderGoogleAccounts(); + // Render all accounts and hidden calendars + renderAllAccounts(); renderHiddenCalendars(); openModal('modal-settings'); @@ -1129,6 +1146,101 @@ function renderGoogleAccounts() { }); } +function renderAllAccounts() { + // CalDAV section + const caldavList = document.getElementById('accounts-caldav-list'); + if (caldavList) { + if (!state.accounts.length) { + caldavList.innerHTML = `${t('settings_no_caldav_accounts')}`; + } else { + caldavList.innerHTML = state.accounts.map(acc => + `
+
+ ${escHtml(acc.name)} + ${escHtml(acc.url || '')} +
+
+ + +
+
` + ).join(''); + caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; btn.textContent = '…'; + try { + await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`); + renderCalendarList(); fetchAndRender(); + showToast(t('google_synced')); + } catch (e) { showToast(e.message, true); } + finally { btn.disabled = false; btn.textContent = t('sync'); } + }); + }); + caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm(t('confirm_caldav_disconnect'))) return; + try { + await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`); + state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect)); + renderAllAccounts(); renderCalendarList(); fetchAndRender(); + showToast(t('caldav_disconnected')); + } catch (e) { showToast(e.message, true); } + }); + }); + } + } + + // Local calendars section + const localList = document.getElementById('accounts-local-list'); + if (localList) { + if (!state.localCalendars.length) { + localList.innerHTML = `${t('settings_no_local_cals')}`; + } else { + localList.innerHTML = state.localCalendars.map(cal => + `
+
+ + ${escHtml(cal.name)} +
+
` + ).join(''); + } + } + + // iCal subscriptions section + const icalList = document.getElementById('accounts-ical-list'); + if (icalList) { + if (!state.icalSubscriptions.length) { + icalList.innerHTML = `${t('settings_no_ical_subs')}`; + } else { + icalList.innerHTML = state.icalSubscriptions.map(sub => + `
+
+ ${escHtml(sub.name)} + ${escHtml(sub.url || '')} +
+
+ +
+
` + ).join(''); + icalList.querySelectorAll('[data-ical-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm(t('confirm_remove_ical'))) return; + try { + await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`); + state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete)); + renderAllAccounts(); renderCalendarList(); fetchAndRender(); + } catch (e) { showToast(e.message, true); } + }); + }); + } + } + + // Google accounts section — delegate to existing function + renderGoogleAccounts(); +} + function renderHiddenCalendars() { const list = document.getElementById('hidden-cals-list'); const hidden = []; @@ -1147,7 +1259,7 @@ function renderHiddenCalendars() { return; } list.innerHTML = hidden.map(c => - `
+ `
${escHtml(c.acc)} / ${escHtml(c.name)}
` diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 4a51fa2..283b3d3 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -86,6 +86,17 @@ const translations = { settings_hidden_cals: 'Ausgeblendete Kalender', settings_no_hidden_cals: 'Keine ausgeblendeten Kalender', settings_no_google: 'Keine Google-Konten verbunden', + settings_nav_accounts: 'Konten', + settings_accounts_caldav: 'CalDAV-Konten', + settings_accounts_local: 'Lokale Kalender', + settings_accounts_ical: 'iCal-Abonnements', + settings_accounts_google: 'Google-Konten', + settings_no_caldav_accounts: 'Keine CalDAV-Konten', + settings_no_local_cals: 'Keine lokalen Kalender', + settings_no_ical_subs: 'Keine Abonnements', + settings_no_google_accounts: 'Keine Google-Konten', + confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?', + caldav_disconnected: 'CalDAV-Konto getrennt', // User management users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator', @@ -269,6 +280,17 @@ const translations = { settings_hidden_cals: 'Hidden calendars', settings_no_hidden_cals: 'No hidden calendars', settings_no_google: 'No Google accounts connected', + settings_nav_accounts: 'Accounts', + settings_accounts_caldav: 'CalDAV Accounts', + settings_accounts_local: 'Local Calendars', + settings_accounts_ical: 'iCal Subscriptions', + settings_accounts_google: 'Google Accounts', + settings_no_caldav_accounts: 'No CalDAV accounts', + settings_no_local_cals: 'No local calendars', + settings_no_ical_subs: 'No subscriptions', + settings_no_google_accounts: 'No Google accounts', + confirm_caldav_disconnect: 'Really disconnect CalDAV account?', + caldav_disconnected: 'CalDAV account disconnected', // User management users_add: 'Add user', users_is_admin: 'Administrator', diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 1f87125..9775537 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,121 +1,185 @@ -import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; +import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; +const LANE_H = 20; // px per lane (height 18px + 2px gap) +const MAX_LANES = 3; // max visible event lanes per row + export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { - const year = currentDate.getFullYear(); + const year = currentDate.getFullYear(); const month = currentDate.getMonth(); - const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); + const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); const firstDay = new Date(year, month, 1); - const lastDay = new Date(year, month + 1, 0); - // Start grid on the correct weekday + // Build 42-cell grid + const cells = []; const gridStart = new Date(firstDay); const offset = dayOfWeek(firstDay, weekStartDay); gridStart.setDate(gridStart.getDate() - offset); - - const cells = []; const d = new Date(gridStart); for (let i = 0; i < 42; i++) { cells.push(new Date(d)); d.setDate(d.getDate() + 1); } - // Build event map keyed by date string - const evMap = {}; - events.forEach(ev => { + // Normalize each event's date range once + const normed = events.map(ev => { const s = new Date(ev.start); - const e = ev.allDay ? new Date(ev.end) : new Date(ev.end); - // Spread multi-day events across cells - const cur = new Date(s); - cur.setHours(0, 0, 0, 0); - const endNorm = new Date(e); - endNorm.setHours(0, 0, 0, 0); - if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1); - while (cur <= endNorm) { - const key = dateKey(cur); - if (!evMap[key]) evMap[key] = []; - evMap[key].push(ev); - cur.setDate(cur.getDate() + 1); - } + s.setHours(0, 0, 0, 0); + const e = new Date(ev.end); + e.setHours(0, 0, 0, 0); + if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive + return { ev, ns: s, ne: e }; }); - // Header: KW-Spalte + Wochentage - const headerHtml = `
KW
` + + // Header + const headerHtml = + `
KW
` + DOW.map(d => `
${d}
`).join(''); - // Build rows (6 weeks × 7 days) - let cellsHtml = ''; + // Build rows + let bodyHtml = ''; for (let row = 0; row < 6; row++) { - // KW cell for the first day of this row - const rowFirstDay = cells[row * 7]; - const kw = getISOWeekNumber(rowFirstDay); - cellsHtml += `
${kw}
`; + const rowCells = cells.slice(row * 7, row * 7 + 7); + const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0); + const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0); + const kw = getISOWeekNumber(rowCells[0]); - for (let col = 0; col < 7; col++) { - const cell = cells[row * 7 + col]; - const key = dateKey(cell); - const cellEvs = (evMap[key] || []).slice().sort((a, b) => { - if (a.allDay && !b.allDay) return -1; - if (!a.allDay && b.allDay) return 1; - return new Date(a.start) - new Date(b.start); + // Collect events overlapping this row + const rowItems = []; + normed.forEach(({ ev, ns, ne }) => { + if (ne < rowStart || ns > rowEnd) return; + const colStart = Math.max(0, daysBetween(rowStart, ns)); + const colEnd = Math.min(6, daysBetween(rowStart, ne)); + if (colEnd < colStart) return; + const span = colEnd - colStart + 1; + rowItems.push({ + ev, + colStart, + span, + continuesLeft: ns < rowStart, + continuesRight: ne > rowEnd, }); + }); - const isOther = cell.getMonth() !== month; - const todayClass = isToday(cell) ? 'today' : ''; - const otherClass = isOther ? 'other-month' : ''; - const numClass = isToday(cell) ? 'today' : ''; + // Sort: all-day first, then span desc, then start time + rowItems.sort((a, b) => { + if (a.ev.allDay && !b.ev.allDay) return -1; + if (!a.ev.allDay && b.ev.allDay) return 1; + if (b.span !== a.span) return b.span - a.span; + return new Date(a.ev.start) - new Date(b.ev.start); + }); - const MAX_VISIBLE = 3; - const visible = cellEvs.slice(0, MAX_VISIBLE); - const hiddenCount = cellEvs.length - MAX_VISIBLE; + // Assign lanes (greedy interval packing) + const lanes = []; // { colEnd } + rowItems.forEach(item => { + let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd); + if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); } + item.lane = laneIdx; + lanes[laneIdx].colEnd = item.colStart + item.span; + }); - const evHtml = visible.map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - const pastClass = isPast(ev) ? 'past' : ''; - const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`; - return `
${escHtml(title)}
`; - }).join(''); + // Track overflow per column + const overflowByCol = {}; + rowItems.forEach(item => { + if (item.lane >= MAX_LANES) { + for (let c = item.colStart; c < item.colStart + item.span; c++) { + overflowByCol[c] = (overflowByCol[c] || 0) + 1; + } + } + }); - const moreHtml = hiddenCount > 0 - ? `
${t('more_events', {n: hiddenCount})}
` - : ''; + // Render event spans + let eventsHtml = ''; + rowItems.forEach(item => { + if (item.lane >= MAX_LANES) return; + const { ev, colStart, span, continuesLeft, continuesRight } = item; + const leftPct = (colStart / 7) * 100; + const widthPct = (span / 7) * 100 - 0.4; + const topPx = item.lane * LANE_H + 2; + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + const cL = continuesLeft ? 'continues-left' : ''; + const cR = continuesRight ? 'continues-right' : ''; + const label = ev.allDay + ? ev.title + : `${fmtTime(new Date(ev.start))} ${ev.title}`; + eventsHtml += `
${escHtml(label)}
`; + }); - cellsHtml += `
-
${cell.getDate()}
- ${evHtml}${moreHtml} + // Render "+N more" per column + Object.entries(overflowByCol).forEach(([col, count]) => { + const c = parseInt(col); + const leftPct = (c / 7) * 100; + const widthPct = (1 / 7) * 100; + eventsHtml += `
${t('more_events', { n: count })}
`; + }); + + // Day cells (numbers only) + let dayCellsHtml = ''; + rowCells.forEach(cell => { + const key = dateKey(cell); + const isOther = cell.getMonth() !== month; + const todayCls = isToday(cell) ? 'today' : ''; + const otherCls = isOther ? 'other-month' : ''; + const numCls = isToday(cell) ? 'today' : ''; + dayCellsHtml += `
+
${cell.getDate()}
`; - } + }); + + bodyHtml += `
+
${kw}
+
+
${dayCellsHtml}
+
${eventsHtml}
+
+
`; } container.innerHTML = `
${headerHtml}
-
${cellsHtml}
+
${bodyHtml}
`; - // Events - container.querySelectorAll('.month-cell').forEach(cell => { - cell.addEventListener('click', e => { - const evEl = e.target.closest('.month-event'); - if (evEl) { - e.stopPropagation(); - const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url); - if (ev) onEventClick(ev, evEl); - return; - } - const moreEl = e.target.closest('.month-more'); - if (moreEl) { - e.stopPropagation(); - onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); - return; - } - onDayClick(new Date(cell.dataset.date + 'T00:00:00')); - }); + // Click handlers — event delegation + const body = container.querySelector('.month-body'); + body.addEventListener('click', e => { + // Span event click + const spanEl = e.target.closest('.month-span-event'); + if (spanEl) { + e.stopPropagation(); + const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url); + if (ev) onEventClick(ev, spanEl); + return; + } + // "+N more" click → day view + const moreEl = e.target.closest('.month-more'); + if (moreEl) { + e.stopPropagation(); + onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); + return; + } + // Day cell click → day view + const cellEl = e.target.closest('.month-cell'); + if (cellEl) { + onDayClick(new Date(cellEl.dataset.date + 'T00:00:00')); + } }); } +// ── Helpers ─────────────────────────────────────────────── + +function daysBetween(a, b) { + // Number of whole days from date a to date b (can be negative) + return Math.round((b - a) / 86400000); +} + function dateKey(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } @@ -127,6 +191,7 @@ function fmtTime(d) { function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } + function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); } -- 2.47.3 From e2f98520e2e3305b026c8e2873e0b50c5fb34a55 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 21:44:44 +0200 Subject: [PATCH 002/114] =?UTF-8?q?fix:=20Month=20grid=20lines,=20scroll?= =?UTF-8?q?=20throttle,=20custom=20dark=20date/time=20picker=20-=20Month?= =?UTF-8?q?=20view:=20Replaced=20day-strip+events-area=20with=20full-heigh?= =?UTF-8?q?t=20column=20=20=20divs=20(.month-col)=20so=20borders=20extend?= =?UTF-8?q?=20the=20full=20row=20height=20and=20clicking=20=20=20anywhere?= =?UTF-8?q?=20in=20a=20day=20column=20(including=20below=20events)=20navig?= =?UTF-8?q?ates=20to=20day=20view.=20=20=20Events=20overlay=20uses=20point?= =?UTF-8?q?er-events:none=20(pass-through)=20while=20span=20bars=20=20=20a?= =?UTF-8?q?nd=20+N-more=20labels=20stay=20pointer-events:all.=20-=20Scroll?= =?UTF-8?q?=20navigation:=20Changed=20wheel=20handler=20from=2080ms=20debo?= =?UTF-8?q?unce=20to=20500ms=20=20=20leading-edge=20throttle=20=E2=80=94?= =?UTF-8?q?=20one=20navigation=20per=20trackpad=20gesture.=20-=20Custom=20?= =?UTF-8?q?date/time=20picker=20(date-picker.js):=20Dark=20calendar=20grid?= =?UTF-8?q?=20with=20=20=20prev/next=20navigation,=20today/selected=20high?= =?UTF-8?q?lighting,=20and=20a=20CSS=20=20=20scroll-snap=20time=20scroller?= =?UTF-8?q?=20(hours=200-23,=20minutes=200-59)=20matching=20the=20=20=20ap?= =?UTF-8?q?p's=20primary=20color.=20Language-aware=20(month=20names,=20day?= =?UTF-8?q?=20headers=20via=20t()).=20-=20Event=20modal=20datetime=20input?= =?UTF-8?q?s=20replaced=20with=20hidden=20inputs=20+=20.dt-display=20=20?= =?UTF-8?q?=20click=20targets=20that=20open=20the=20custom=20picker.=20set?= =?UTF-8?q?DtValue()=20helper=20keeps=20=20=20hidden=20input=20and=20displ?= =?UTF-8?q?ay=20label=20in=20sync.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 166 +++++++++++++++++++---- frontend/index.html | 24 +++- frontend/js/calendar.js | 55 ++++++-- frontend/js/date-picker.js | 271 +++++++++++++++++++++++++++++++++++++ frontend/js/views/month.js | 46 +++---- 5 files changed, 491 insertions(+), 71 deletions(-) create mode 100644 frontend/js/date-picker.js diff --git a/frontend/css/app.css b/frontend/css/app.css index 4f9797f..89e567b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -469,8 +469,7 @@ a { color: var(--primary); text-decoration: none; } letter-spacing: .5px; color: var(--text-2); } .month-kw-cell { - position: absolute; left: 0; top: 0; bottom: 0; - width: 38px; + position: absolute; left: 0; top: 0; bottom: 0; width: 38px; display: flex; align-items: flex-start; justify-content: center; padding-top: 6px; font-size: 13px; color: var(--text-3); font-weight: 700; @@ -478,32 +477,34 @@ a { color: var(--primary); text-decoration: none; } cursor: default; user-select: none; z-index: 1; background: var(--bg-app); } -.month-cell { - flex: 1; - border-right: 1px solid var(--border); - padding: 4px 4px 0; - cursor: pointer; - transition: background var(--transition); - min-width: 0; +/* Full-height column divs — click target + border */ +.month-col { + flex: 1; border-right: 1px solid var(--border); + cursor: pointer; transition: background var(--transition); + padding: 4px 4px 0; min-width: 0; } -.month-cell:last-child { border-right: none; } -.month-cell:hover { background: var(--bg-hover); } -.month-cell.today { background: rgba(66,133,244,.08); } -.month-cell.other-month .cell-day { color: var(--text-3); } +.month-col:last-child { border-right: none; } +.month-col:hover { background: var(--bg-hover); } +.month-col.today { background: rgba(66,133,244,.08); } +.month-col.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 50%; flex-shrink: 0; } -.cell-day.today { - background: var(--today-color); - color: #fff; font-weight: 700; +.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } +/* Events overlay — pointer-events:none so clicks pass to columns */ +.month-events-overlay { + position: absolute; top: 30px; left: 0; right: 0; bottom: 0; + pointer-events: none; overflow: hidden; z-index: 2; } .month-more { position: absolute; font-size: 11px; color: var(--text-2); padding: 0 4px; cursor: pointer; font-weight: 500; + pointer-events: all; + white-space: nowrap; overflow: hidden; } .month-more:hover { color: var(--primary); } @@ -901,32 +902,24 @@ a { color: var(--primary); text-decoration: none; } display: flex; flex-direction: column; flex: 1; overflow: hidden; } .month-row { - display: flex; flex: 1; position: relative; min-height: 0; + display: flex; flex: 1; position: relative; min-height: 100px; border-bottom: 1px solid var(--border); } .month-row:last-child { border-bottom: none; } +/* row-right: flex row containing 7 full-height column divs + events overlay */ .month-row-right { - margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0; -} -.month-day-strip { - display: flex; flex-shrink: 0; -} -.month-events-area { - position: relative; flex: 1; - min-height: 72px; /* 3 lanes × 22px + 6px padding */ - overflow: hidden; + margin-left: 38px; display: flex; flex: 1; position: relative; min-width: 0; } .month-span-event { position: absolute; height: 18px; line-height: 18px; - border-radius: 3px; - padding: 0 6px; + border-radius: 3px; padding: 0 6px; font-size: 11px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: #fff; transition: filter var(--transition); box-sizing: border-box; - z-index: 2; + pointer-events: all; } .month-span-event:hover { filter: brightness(1.15); } .month-span-event.past { opacity: .45; } @@ -947,3 +940,118 @@ a { color: var(--primary); text-decoration: none; } .logo-text { display: none; } .view-title { font-size: 16px; } } + +/* ── Custom Date/Time Display Field ─────────────────────── */ +.dt-display { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + color: var(--text-1); + cursor: pointer; + transition: border-color var(--transition); + user-select: none; + min-height: 42px; +} +.dt-display:hover, .dt-display:focus { border-color: var(--primary); outline: none; } +.dt-display-text { font-size: 14px; } +.dt-display-icon { color: var(--text-3); flex-shrink: 0; margin-left: 6px; } + +/* ── Date/Time Picker Card ───────────────────────────────── */ +.dtp-overlay { + position: fixed; inset: 0; z-index: 700; +} +.dtp-card { + position: fixed; z-index: 701; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + padding: 16px; + width: 272px; + user-select: none; +} +/* Calendar header */ +.dtp-cal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; +} +.dtp-month-label { + font-size: 14px; font-weight: 600; color: var(--text-1); +} +.dtp-nav-btn { + background: none; border: none; cursor: pointer; + color: var(--text-2); font-size: 22px; line-height: 1; + padding: 2px 6px; border-radius: var(--radius-sm); + transition: background var(--transition), color var(--transition); +} +.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); } +/* Day-of-week headers */ +.dtp-grid { + display: grid; grid-template-columns: repeat(7, 1fr); + gap: 2px; margin-bottom: 4px; +} +.dtp-dow { + text-align: center; font-size: 11px; font-weight: 600; + color: var(--text-3); padding: 4px 0; text-transform: uppercase; +} +/* Day cells */ +.dtp-day { + display: flex; align-items: center; justify-content: center; + height: 34px; border-radius: 6px; + font-size: 13px; font-weight: 500; color: var(--text-1); + cursor: pointer; transition: background var(--transition); +} +.dtp-day:hover { background: var(--bg-hover); } +.dtp-day.other { color: var(--text-3); } +.dtp-day.other:hover { background: var(--bg-hover); } +.dtp-day.today { color: var(--primary); font-weight: 700; } +.dtp-day.selected { + background: var(--primary) !important; + color: #fff !important; font-weight: 700; +} +/* Time picker */ +.dtp-time-row { + display: flex; align-items: center; justify-content: center; + gap: 4px; margin: 12px 0 8px; + border-top: 1px solid var(--border-light); + padding-top: 12px; +} +.dtp-colon { + font-size: 22px; font-weight: 600; color: var(--text-2); + padding: 0 2px; line-height: 1; + align-self: center; +} +.dtp-tc-wrap { + position: relative; width: 64px; +} +.dtp-tc { + height: calc(3 * 40px); /* 120px = 3 visible items */ + overflow-y: scroll; + scroll-snap-type: y mandatory; + scrollbar-width: none; + padding: 40px 0; /* top/bottom padding so first/last can center */ + box-sizing: content-box; +} +.dtp-tc::-webkit-scrollbar { display: none; } +.dtp-ti { + height: 40px; line-height: 40px; + text-align: center; font-size: 20px; font-weight: 500; + scroll-snap-align: center; + border-radius: 8px; + cursor: pointer; + color: var(--text-2); + transition: background .1s, color .1s; +} +.dtp-ti:hover { color: var(--text-1); } +.dtp-ti.selected { + background: var(--primary); + color: #fff; +} +/* Actions */ +.dtp-actions { + display: flex; justify-content: flex-end; gap: 8px; + margin-top: 12px; padding-top: 10px; + border-top: 1px solid var(--border-light); +} diff --git a/frontend/index.html b/frontend/index.html index a489f43..480ebe7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -202,21 +202,37 @@
- + +
+ + +
- + +
+ + +
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 27f6fb4..4ddb9da 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; import { openColorPicker } from './color-picker.js'; +import { openDatePicker, formatDtDisplay } from './date-picker.js'; import { t, setLang, getLang } from './i18n.js'; // Fetch avatar image as blob URL (with auth header) @@ -540,12 +541,13 @@ function bindTopbar() { document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); - // Mouse wheel / trackpad scroll navigation - let _wheelTimer = null; + // Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture) + let _wheelLast = 0; document.getElementById('view-container').addEventListener('wheel', e => { e.preventDefault(); - if (_wheelTimer) return; - _wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80); + const now = Date.now(); + if (now - _wheelLast < 500) return; + _wheelLast = now; const dir = e.deltaY > 0 ? 1 : -1; if (state.currentView === 'agenda') return; if (state.currentView === 'month') { @@ -710,6 +712,17 @@ function populateCalendarSelect(selectedId) { }); } +// ── Date field helpers ──────────────────────────────────── +function setDtValue(id, isoStr, mode) { + const input = document.getElementById(id); + if (input) input.value = isoStr || ''; + const display = document.getElementById(id + '-display'); + if (display) { + display.querySelector('.dt-display-text').textContent = + formatDtDisplay(isoStr, mode, getLang()); + } +} + function openNewEventModal(date) { state.editingEvent = null; state.selectedEventColor = ''; @@ -723,10 +736,10 @@ function openNewEventModal(date) { const start = new Date(date); const end = new Date(date); end.setHours(end.getHours() + 1); - document.getElementById('ev-start').value = toLocalDatetimeInput(start); - document.getElementById('ev-end').value = toLocalDatetimeInput(end); - document.getElementById('ev-start-date').value = toDateInput(start); - document.getElementById('ev-end-date').value = toDateInput(start); + setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime'); + setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime'); + setDtValue('ev-start-date', toDateInput(start), 'date'); + setDtValue('ev-end-date', toDateInput(start), 'date'); toggleAlldayFields(false); populateCalendarSelect(null); @@ -746,14 +759,14 @@ function openEditEventModal(ev) { document.getElementById('ev-allday').checked = ev.allDay; if (ev.allDay) { - document.getElementById('ev-start-date').value = ev.start.slice(0, 10); - document.getElementById('ev-end-date').value = ev.end.slice(0, 10); + setDtValue('ev-start-date', ev.start.slice(0, 10), 'date'); + setDtValue('ev-end-date', ev.end.slice(0, 10), 'date'); toggleAlldayFields(true); } else { const s = new Date(ev.start); const e = new Date(ev.end); - document.getElementById('ev-start').value = toLocalDatetimeInput(s); - document.getElementById('ev-end').value = toLocalDatetimeInput(e); + setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime'); + setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime'); toggleAlldayFields(false); } @@ -781,6 +794,24 @@ function bindEventModal() { toggleAlldayFields(e.target.checked); }); + // Date/time pickers + [ + { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' }, + { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' }, + { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' }, + { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' }, + ].forEach(({ displayId, inputId, mode }) => { + const disp = document.getElementById(displayId); + if (!disp) return; + const open = async () => { + const current = document.getElementById(inputId)?.value || ''; + const result = await openDatePicker(disp, current, mode); + if (result !== null) setDtValue(inputId, result, mode); + }; + disp.addEventListener('click', open); + disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); }); + }); + // Color picker: click preview to open gradient picker const evColorPreview = document.getElementById('ev-color-preview'); const evColorHex = document.getElementById('ev-color-hex'); diff --git a/frontend/js/date-picker.js b/frontend/js/date-picker.js new file mode 100644 index 0000000..80678dd --- /dev/null +++ b/frontend/js/date-picker.js @@ -0,0 +1,271 @@ +/** + * Custom dark date/time picker + * openDatePicker(anchor, value, mode) → Promise + * anchor : DOM element to position near + * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or "" + * mode : 'datetime' | 'date' + */ +import { t } from './i18n.js'; + +const ITEM_H = 40; // px per scroll item +const VISIBLE = 3; // visible items in time scroller + +export function openDatePicker(anchor, value, mode = 'datetime') { + return new Promise(resolve => { + // ── Parse initial value ─────────────────────────────── + let selDate = new Date(); + selDate.setHours(0, 0, 0, 0); + let selHour = selDate.getHours(); + let selMin = 0; + + if (value) { + try { + const raw = mode === 'datetime' + ? value.replace(' ', 'T') + : value + 'T00:00:00'; + const d = new Date(raw); + if (!isNaN(d)) { + selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); } + } + } catch (_) {} + } + + let viewYear = selDate.getFullYear(); + let viewMonth = selDate.getMonth(); + + // ── Build DOM ───────────────────────────────────────── + const overlay = document.createElement('div'); + overlay.className = 'dtp-overlay'; + document.body.appendChild(overlay); + + const card = document.createElement('div'); + card.className = 'dtp-card'; + overlay.appendChild(card); + + function done(result) { + overlay.remove(); + resolve(result); + } + + // Click outside → cancel + overlay.addEventListener('mousedown', e => { + if (e.target === overlay) done(null); + }); + + // ── Calendar builder ────────────────────────────────── + function buildCalendar() { + const months = t('months'); + const dowKeys = t('dow_monday'); // always Monday-first in calendar + + const firstDay = new Date(viewYear, viewMonth, 1); + const gridStart = new Date(firstDay); + let dow = firstDay.getDay(); + dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun + gridStart.setDate(gridStart.getDate() - dow); + + const cells = []; + const iter = new Date(gridStart); + for (let i = 0; i < 42; i++) { + cells.push(new Date(iter)); + iter.setDate(iter.getDate() + 1); + } + + const today = new Date(); today.setHours(0, 0, 0, 0); + + const dowHtml = dowKeys.map(d => `
${d}
`).join(''); + const daysHtml = cells.map(cell => { + const isOther = cell.getMonth() !== viewMonth; + const isToday = cell.getTime() === today.getTime(); + const isSelected = cell.getTime() === selDate.getTime(); + let cls = 'dtp-day'; + if (isOther) cls += ' other'; + if (isToday) cls += ' today'; + if (isSelected) cls += ' selected'; + return `
${cell.getDate()}
`; + }).join(''); + + return `
+ + ${months[viewMonth]} ${viewYear} + +
+
+ ${dowHtml} + ${daysHtml} +
`; + } + + // ── Time scroll builder ─────────────────────────────── + function buildTime() { + if (mode !== 'datetime') return ''; + const hItems = Array.from({ length: 24 }, (_, i) => + `
${String(i).padStart(2,'0')}
` + ).join(''); + const mItems = Array.from({ length: 60 }, (_, i) => + `
${String(i).padStart(2,'0')}
` + ).join(''); + return `
+
+
${hItems}
+
+
:
+
+
${mItems}
+
+
`; + } + + // ── Render ──────────────────────────────────────────── + function render() { + card.innerHTML = + buildCalendar() + + buildTime() + + `
+ + +
`; + + bindEvents(); + if (mode === 'datetime') initScrollers(); + positionCard(); + } + + // ── Event bindings ──────────────────────────────────── + function bindEvents() { + card.querySelector('#dtp-prev').onclick = () => { + viewMonth--; + if (viewMonth < 0) { viewMonth = 11; viewYear--; } + render(); + }; + card.querySelector('#dtp-next').onclick = () => { + viewMonth++; + if (viewMonth > 11) { viewMonth = 0; viewYear++; } + render(); + }; + + // Day click + card.querySelectorAll('.dtp-day').forEach(el => { + el.addEventListener('click', () => { + selDate = new Date(parseInt(el.dataset.ts)); + if (el.classList.contains('other')) { + viewYear = selDate.getFullYear(); + viewMonth = selDate.getMonth(); + } + render(); + }); + }); + + card.querySelector('#dtp-cancel').onclick = () => done(null); + card.querySelector('#dtp-ok').onclick = () => done(buildResult()); + } + + // ── Time scroll initialisation ──────────────────────── + function initScrollers() { + const hCol = card.querySelector('#dtp-h'); + const mCol = card.querySelector('#dtp-m'); + if (!hCol || !mCol) return; + + // Scroll to selected value (padding-top = ITEM_H, so scrollTop = val * ITEM_H) + hCol.scrollTop = selHour * ITEM_H; + mCol.scrollTop = selMin * ITEM_H; + highlightItems(hCol, selHour); + highlightItems(mCol, selMin); + + let hTimer, mTimer; + + hCol.addEventListener('scroll', () => { + clearTimeout(hTimer); + hTimer = setTimeout(() => { + selHour = Math.max(0, Math.min(23, Math.round(hCol.scrollTop / ITEM_H))); + hCol.scrollTop = selHour * ITEM_H; + highlightItems(hCol, selHour); + }, 80); + }); + + mCol.addEventListener('scroll', () => { + clearTimeout(mTimer); + mTimer = setTimeout(() => { + selMin = Math.max(0, Math.min(59, Math.round(mCol.scrollTop / ITEM_H))); + mCol.scrollTop = selMin * ITEM_H; + highlightItems(mCol, selMin); + }, 80); + }); + + // Click item to select + hCol.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.addEventListener('click', () => { + selHour = i; + hCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' }); + highlightItems(hCol, i); + }); + }); + mCol.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.addEventListener('click', () => { + selMin = i; + mCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' }); + highlightItems(mCol, i); + }); + }); + } + + function highlightItems(col, val) { + col.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.classList.toggle('selected', i === val); + }); + } + + // ── Result builder ──────────────────────────────────── + function buildResult() { + const y = selDate.getFullYear(); + const mo = String(selDate.getMonth() + 1).padStart(2, '0'); + const dd = String(selDate.getDate()).padStart(2, '0'); + if (mode === 'date') return `${y}-${mo}-${dd}`; + const h = String(selHour).padStart(2, '0'); + const mi = String(selMin).padStart(2, '0'); + return `${y}-${mo}-${dd}T${h}:${mi}`; + } + + // ── Positioning ─────────────────────────────────────── + function positionCard() { + const r = anchor.getBoundingClientRect(); + const cw = card.offsetWidth || 280; + const ch = card.offsetHeight || 420; + let left = r.left; + let top = r.bottom + 6; + if (left + cw > window.innerWidth - 8) left = window.innerWidth - cw - 8; + if (top + ch > window.innerHeight - 8) top = r.top - ch - 6; + if (left < 8) left = 8; + if (top < 8) top = 8; + card.style.left = left + 'px'; + card.style.top = top + 'px'; + } + + render(); + }); +} + +/** + * Format an ISO value for display in the UI + * mode: 'datetime' | 'date' + * lang: 'de' | 'en' + */ +export function formatDtDisplay(isoStr, mode, lang = 'de') { + 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'; + if (mode === 'datetime') { + return d.toLocaleString(locale, { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: false, + }); + } + return d.toLocaleDateString(locale, { + day: '2-digit', month: '2-digit', year: 'numeric', + }); + } catch (_) { return isoStr; } +} diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 9775537..1d9de77 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,8 +1,8 @@ import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; -const LANE_H = 20; // px per lane (height 18px + 2px gap) -const MAX_LANES = 3; // max visible event lanes per row +const LANE_H = 20; // px per lane (event height 18px + 2px gap) +const MAX_LANES = 3; // max visible lanes per row export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { const year = currentDate.getFullYear(); @@ -24,10 +24,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // Normalize each event's date range once const normed = events.map(ev => { - const s = new Date(ev.start); - s.setHours(0, 0, 0, 0); - const e = new Date(ev.end); - e.setHours(0, 0, 0, 0); + const s = new Date(ev.start); s.setHours(0, 0, 0, 0); + const e = new Date(ev.end); e.setHours(0, 0, 0, 0); if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive return { ev, ns: s, ne: e }; }); @@ -52,11 +50,10 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC const colStart = Math.max(0, daysBetween(rowStart, ns)); const colEnd = Math.min(6, daysBetween(rowStart, ne)); if (colEnd < colStart) return; - const span = colEnd - colStart + 1; rowItems.push({ ev, colStart, - span, + span: colEnd - colStart + 1, continuesLeft: ns < rowStart, continuesRight: ne > rowEnd, }); @@ -71,7 +68,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC }); // Assign lanes (greedy interval packing) - const lanes = []; // { colEnd } + const lanes = []; rowItems.forEach(item => { let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd); if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); } @@ -89,7 +86,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC } }); - // Render event spans + // Render event spans HTML (placed in overlay) let eventsHtml = ''; rowItems.forEach(item => { if (item.lane >= MAX_LANES) return; @@ -110,25 +107,23 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC title="${escAttr(ev.title)}">${escHtml(label)}
`; }); - // Render "+N more" per column + // "+N more" per column Object.entries(overflowByCol).forEach(([col, count]) => { const c = parseInt(col); - const leftPct = (c / 7) * 100; - const widthPct = (1 / 7) * 100; eventsHtml += `
${t('more_events', { n: count })}
`; + style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}
`; }); - // Day cells (numbers only) - let dayCellsHtml = ''; + // Full-height column divs (click targets + borders) + let colsHtml = ''; rowCells.forEach(cell => { const key = dateKey(cell); const isOther = cell.getMonth() !== month; const todayCls = isToday(cell) ? 'today' : ''; const otherCls = isOther ? 'other-month' : ''; const numCls = isToday(cell) ? 'today' : ''; - dayCellsHtml += `
+ colsHtml += `
${cell.getDate()}
`; }); @@ -136,8 +131,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC bodyHtml += `
${kw}
-
${dayCellsHtml}
-
${eventsHtml}
+ ${colsHtml} +
${eventsHtml}
`; } @@ -147,7 +142,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
${bodyHtml}
`; - // Click handlers — event delegation + // Click handlers via event delegation on the body const body = container.querySelector('.month-body'); body.addEventListener('click', e => { // Span event click @@ -158,17 +153,17 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC if (ev) onEventClick(ev, spanEl); return; } - // "+N more" click → day view + // "+N more" → navigate to day view const moreEl = e.target.closest('.month-more'); if (moreEl) { e.stopPropagation(); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); return; } - // Day cell click → day view - const cellEl = e.target.closest('.month-cell'); - if (cellEl) { - onDayClick(new Date(cellEl.dataset.date + 'T00:00:00')); + // Column click → navigate to day view + const colEl = e.target.closest('.month-col'); + if (colEl) { + onDayClick(new Date(colEl.dataset.date + 'T00:00:00')); } }); } @@ -176,7 +171,6 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // ── Helpers ─────────────────────────────────────────────── function daysBetween(a, b) { - // Number of whole days from date a to date b (can be negative) return Math.round((b - a) / 86400000); } -- 2.47.3 From 46f6765087e080d10959146fdf9b3f1bea1e3205 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 21:51:15 +0200 Subject: [PATCH 003/114] =?UTF-8?q?fix:=20Month=20scroll=20navigates=20by?= =?UTF-8?q?=20full=20month,=20not=20by=20week=20Scrolling=20in=20month=20v?= =?UTF-8?q?iew=20was=20moving=20currentDate=20by=207=20days,=20but=20the?= =?UTF-8?q?=20grid=20always=20renders=20the=20complete=20month=20=E2=80=94?= =?UTF-8?q?=20so=204=20scrolls=20were=20needed=20before=20any=20visual=20c?= =?UTF-8?q?hange.=20Now=20each=20scroll=20step=20advances/retreats=20by=20?= =?UTF-8?q?exactly=20one=20month=20(same=20as=20the=20prev/next=20buttons)?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 4ddb9da..a51930a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -551,8 +551,8 @@ function bindTopbar() { const dir = e.deltaY > 0 ? 1 : -1; if (state.currentView === 'agenda') return; if (state.currentView === 'month') { - state.currentDate = new Date(state.currentDate); - state.currentDate.setDate(state.currentDate.getDate() + dir * 7); + const d = state.currentDate; + state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1); fetchAndRender(); } else { navigate(dir); -- 2.47.3 From 62ac0162eb33b83c43bc2811fb3e0314fbc35851 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 21:58:51 +0200 Subject: [PATCH 004/114] feat: Rolling 5-week month view with week-by-week scroll Month view now shows 5 weeks starting from the week containing currentDate (not fixed to month boundaries), enabling views like "mid-April to mid-May". Prev/Next buttons jump 4 weeks; mouse wheel scrolls 1 week at a time with 500ms debounce. --- frontend/js/calendar.js | 29 +++++++++++++++++++++-------- frontend/js/views/month.js | 24 ++++++++++++------------ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index a51930a..6658980 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -93,10 +93,11 @@ function getViewRange() { let start, end; if (state.currentView === 'month') { - start = new Date(d.getFullYear(), d.getMonth(), 1); - start.setDate(start.getDate() - dayOfWeek(start, weekStartDay) - 1); - end = new Date(d.getFullYear(), d.getMonth() + 1, 0); - end.setDate(end.getDate() + (6 - dayOfWeek(end, weekStartDay)) + 1); + // Rolling view: 5 weeks from the start of currentDate's week + start = weekStart(d, weekStartDay); + start.setDate(start.getDate() - 1); // 1-day buffer + end = new Date(start); + end.setDate(start.getDate() + 37); // 5 weeks + buffer } else if (state.currentView === 'week') { start = weekStart(d, weekStartDay); end = new Date(start); @@ -165,7 +166,17 @@ function updateTitle() { let title = ''; const M = t('months'); if (state.currentView === 'month') { - title = `${M[d.getMonth()]} ${d.getFullYear()}`; + // Show date range of the rolling 5-week window + const ws = weekStart(d, weekStartDay); + const we = new Date(ws); we.setDate(we.getDate() + 34); // last day of 5th week + const Ms = t('months_short'); + if (ws.getFullYear() !== we.getFullYear()) { + title = `${Ms[ws.getMonth()]} ${ws.getFullYear()} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; + } else if (ws.getMonth() !== we.getMonth()) { + title = `${Ms[ws.getMonth()]} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; + } else { + title = `${M[ws.getMonth()]} ${ws.getFullYear()}`; + } } else if (state.currentView === 'week') { const mon = weekStart(d, weekStartDay); const sun = new Date(mon); @@ -507,7 +518,9 @@ function renderCalendarList() { function navigate(dir) { const d = state.currentDate; if (state.currentView === 'month') { - state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1); + // Buttons jump 4 weeks (one screenful) + state.currentDate = new Date(d); + state.currentDate.setDate(d.getDate() + dir * 28); } else if (state.currentView === 'week') { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir * 7); @@ -551,8 +564,8 @@ function bindTopbar() { const dir = e.deltaY > 0 ? 1 : -1; if (state.currentView === 'agenda') return; if (state.currentView === 'month') { - const d = state.currentDate; - state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1); + state.currentDate = new Date(state.currentDate); + state.currentDate.setDate(state.currentDate.getDate() + dir * 7); fetchAndRender(); } else { navigate(dir); diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 1d9de77..0d4db29 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,23 +1,23 @@ -import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; +import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; const LANE_H = 20; // px per lane (event height 18px + 2px gap) const MAX_LANES = 3; // max visible lanes per row +const NUM_ROWS = 5; // rolling view: always 5 weeks + export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { - const year = currentDate.getFullYear(); - const month = currentDate.getMonth(); - const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); + // "Primary month" = currentDate's month (used for muting other-month days) + const primaryMonth = currentDate.getMonth(); + const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); - const firstDay = new Date(year, month, 1); + // Rolling grid: start at the week that contains currentDate + const gridStart = weekStart(currentDate, weekStartDay); - // Build 42-cell grid + // Build NUM_ROWS × 7 cells const cells = []; - const gridStart = new Date(firstDay); - const offset = dayOfWeek(firstDay, weekStartDay); - gridStart.setDate(gridStart.getDate() - offset); const d = new Date(gridStart); - for (let i = 0; i < 42; i++) { + for (let i = 0; i < NUM_ROWS * 7; i++) { cells.push(new Date(d)); d.setDate(d.getDate() + 1); } @@ -37,7 +37,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // Build rows let bodyHtml = ''; - for (let row = 0; row < 6; row++) { + for (let row = 0; row < NUM_ROWS; row++) { const rowCells = cells.slice(row * 7, row * 7 + 7); const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0); const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0); @@ -119,7 +119,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC let colsHtml = ''; rowCells.forEach(cell => { const key = dateKey(cell); - const isOther = cell.getMonth() !== month; + const isOther = cell.getMonth() !== primaryMonth; const todayCls = isToday(cell) ? 'today' : ''; const otherCls = isOther ? 'other-month' : ''; const numCls = isToday(cell) ? 'today' : ''; -- 2.47.3 From bc93474f49ece28837403a19969139b46e504c03 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 22:05:03 +0200 Subject: [PATCH 005/114] =?UTF-8?q?perf:=20Event=20cache=20mit=20=C2=B18-W?= =?UTF-8?q?ochen-Puffer=20f=C3=BCr=20schnelle=20Navigation=20Beim=20ersten?= =?UTF-8?q?=20Laden=20wird=20ein=20Fenster=20von=20=C2=B18=20Wochen=20um?= =?UTF-8?q?=20die=20aktuelle=20Ansicht=20geholt.=20Wochenweise=20Navigatio?= =?UTF-8?q?n=20trifft=20danach=20den=20Cache=20sofort=20(kein=20Spinner,?= =?UTF-8?q?=20kein=20Netzwerk).=20Nach=20echten=20Daten=C3=A4nderungen=20(?= =?UTF-8?q?Event=20speichern/l=C3=B6schen,=20Sync,=20Konto-=C3=84nderungen?= =?UTF-8?q?)=20wird=20der=20Cache=20invalidiert=20und=20neu=20geladen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 52 +++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 6658980..af88aaa 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -72,12 +72,40 @@ export async function initCalendar() { bindProfileModal(); } +// ── Event cache ─────────────────────────────────────────── +const eventCache = { start: null, end: null, events: [] }; + +function invalidateCache() { + eventCache.start = null; + eventCache.end = null; + eventCache.events = []; +} + // ── Data fetching ───────────────────────────────────────── -async function fetchAndRender() { +async function fetchAndRender(force = false) { const { start, end } = getViewRange(); + + // Cache hit: requested range is fully within what we already have + if (!force && eventCache.start && eventCache.end && + start >= eventCache.start && end <= eventCache.end) { + state.events = eventCache.events; + renderView(); + updateTitle(); + renderMiniCal(); + return; + } + + // Cache miss: fetch a wider window (±8 weeks) so subsequent navigation is instant + const BUF = 56 * 86400000; // 8 weeks in ms + const fetchStart = new Date(start.getTime() - BUF); + const fetchEnd = new Date(end.getTime() + BUF); + showLoading(); try { - const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`); + const events = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); + eventCache.start = fetchStart; + eventCache.end = fetchEnd; + eventCache.events = events; state.events = events; } catch (e) { showToast(t('error_load_events') + ': ' + e.message, true); @@ -676,7 +704,7 @@ function showEventPopup(ev, anchor) { await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); } showToast(t('event_deleted')); - fetchAndRender(); + fetchAndRender(true); } catch (e) { showToast(e.message, true); } }; document.getElementById('popup-close').onclick = () => popup.classList.add('hidden'); @@ -916,7 +944,7 @@ function bindEventModal() { showToast(t('event_created')); } closeModal('modal-event'); - fetchAndRender(); + fetchAndRender(true); } catch (e) { showToast(e.message, true); } @@ -940,7 +968,7 @@ function bindEventModal() { } showToast(t('event_deleted')); closeModal('modal-event'); - fetchAndRender(); + fetchAndRender(true); } catch (e) { showToast(e.message, true); } }; } @@ -993,7 +1021,7 @@ function bindAccountModal() { renderCalendarList(); closeModal('modal-account'); showToast(t("account_added", {name})); - fetchAndRender(); + fetchAndRender(true); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); @@ -1081,7 +1109,7 @@ function bindICalSubModal() { renderCalendarList(); closeModal('modal-ical-sub'); showToast(t("ical_subscribed", {name})); - fetchAndRender(); + fetchAndRender(true); } catch (e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); @@ -1170,7 +1198,7 @@ function renderGoogleAccounts() { if (idx !== -1) state.googleAccounts[idx] = updated; renderGoogleAccounts(); renderCalendarList(); - fetchAndRender(); + fetchAndRender(true); showToast(t('google_synced')); } catch (e) { showToast(e.message, true); } }); @@ -1183,7 +1211,7 @@ function renderGoogleAccounts() { state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc)); renderGoogleAccounts(); renderCalendarList(); - fetchAndRender(); + fetchAndRender(true); showToast(t('google_disconnected')); } catch (e) { showToast(e.message, true); } }); @@ -1214,7 +1242,7 @@ function renderAllAccounts() { btn.disabled = true; btn.textContent = '…'; try { await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`); - renderCalendarList(); fetchAndRender(); + renderCalendarList(); fetchAndRender(true); showToast(t('google_synced')); } catch (e) { showToast(e.message, true); } finally { btn.disabled = false; btn.textContent = t('sync'); } @@ -1226,7 +1254,7 @@ function renderAllAccounts() { try { await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`); state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect)); - renderAllAccounts(); renderCalendarList(); fetchAndRender(); + renderAllAccounts(); renderCalendarList(); fetchAndRender(true); showToast(t('caldav_disconnected')); } catch (e) { showToast(e.message, true); } }); @@ -1274,7 +1302,7 @@ function renderAllAccounts() { try { await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete)); - renderAllAccounts(); renderCalendarList(); fetchAndRender(); + renderAllAccounts(); renderCalendarList(); fetchAndRender(true); } catch (e) { showToast(e.message, true); } }); }); -- 2.47.3 From 59751349b751db05ac89b94d0f8098fb63714958 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 22:09:11 +0200 Subject: [PATCH 006/114] =?UTF-8?q?perf:=20Sliding-window=20Cache=20?= =?UTF-8?q?=E2=80=94=20Hintergrund-Prefetch=20bei=20Cache-Randn=C3=A4he=20?= =?UTF-8?q?Wenn=20die=20aktuelle=20Ansicht=20weniger=20als=204=20Wochen=20?= =?UTF-8?q?vom=20Cache-Rand=20entfernt=20ist,=20werden=20im=20Hintergrund?= =?UTF-8?q?=208=20weitere=20Wochen=20in=20diese=20Richtung=20geladen=20und?= =?UTF-8?q?=20in=20den=20Cache=20gemergt.=20Der=20Cache=20w=C3=A4chst=20da?= =?UTF-8?q?mit=20automatisch=20mit=20der=20Navigation=20mit=20=E2=80=94=20?= =?UTF-8?q?kein=20sichtbarer=20Ladevorgang=20auch=20bei=20langen=20Spr?= =?UTF-8?q?=C3=BCngen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 56 +++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index af88aaa..3dca0ac 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -73,12 +73,54 @@ export async function initCalendar() { } // ── Event cache ─────────────────────────────────────────── -const eventCache = { start: null, end: null, events: [] }; +const CACHE_BUF = 56 * 86400000; // initial ±8 weeks +const PREFETCH_EXT = 56 * 86400000; // extend by 8 weeks when triggered +const PREFETCH_EDGE = 28 * 86400000; // trigger when within 4 weeks of cache edge + +const eventCache = { + start: null, end: null, events: [], + _fwdPending: false, _bwdPending: false, +}; function invalidateCache() { - eventCache.start = null; - eventCache.end = null; - eventCache.events = []; + eventCache.start = null; + eventCache.end = null; + eventCache.events = []; + eventCache._fwdPending = false; + eventCache._bwdPending = false; +} + +function _mergeEvents(newEvents) { + const seen = new Set(eventCache.events.map(e => e.id + '@@' + (e.url || ''))); + for (const e of newEvents) { + const k = e.id + '@@' + (e.url || ''); + if (!seen.has(k)) { seen.add(k); eventCache.events.push(e); } + } +} + +// Fire-and-forget: extend the cache toward whichever edge the view is approaching +function prefetchIfNeeded(viewStart, viewEnd) { + if (!eventCache.start || !eventCache.end) return; + + if (!eventCache._fwdPending && (eventCache.end - viewEnd) < PREFETCH_EDGE) { + eventCache._fwdPending = true; + const from = new Date(eventCache.end); + const to = new Date(eventCache.end.getTime() + PREFETCH_EXT); + api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) + .then(evs => { _mergeEvents(evs); eventCache.end = to; }) + .catch(() => {}) + .finally(() => { eventCache._fwdPending = false; }); + } + + if (!eventCache._bwdPending && (viewStart - eventCache.start) < PREFETCH_EDGE) { + eventCache._bwdPending = true; + const from = new Date(eventCache.start.getTime() - PREFETCH_EXT); + const to = new Date(eventCache.start); + api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) + .then(evs => { _mergeEvents(evs); eventCache.start = from; }) + .catch(() => {}) + .finally(() => { eventCache._bwdPending = false; }); + } } // ── Data fetching ───────────────────────────────────────── @@ -92,13 +134,13 @@ async function fetchAndRender(force = false) { renderView(); updateTitle(); renderMiniCal(); + prefetchIfNeeded(start, end); // extend cache in background if approaching an edge return; } // Cache miss: fetch a wider window (±8 weeks) so subsequent navigation is instant - const BUF = 56 * 86400000; // 8 weeks in ms - const fetchStart = new Date(start.getTime() - BUF); - const fetchEnd = new Date(end.getTime() + BUF); + const fetchStart = new Date(start.getTime() - CACHE_BUF); + const fetchEnd = new Date(end.getTime() + CACHE_BUF); showLoading(); try { -- 2.47.3 From 3846af527a2617ffc2542f913f323e061cbb55c1 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 22:18:10 +0200 Subject: [PATCH 007/114] =?UTF-8?q?fix:=20Kalenderfarbe=20wird=20sofort=20?= =?UTF-8?q?ohne=20Reload=20aktualisiert=20Statt=20nach=20Farb=C3=A4nderung?= =?UTF-8?q?=20den=20Cache=20zu=20invalidieren=20und=20neu=20zu=20laden,=20?= =?UTF-8?q?wird=20calendarColor=20direkt=20in-place=20auf=20allen=20gecach?= =?UTF-8?q?ten=20Events=20gepatcht=20und=20dann=20nur=20renderView()=20auf?= =?UTF-8?q?gerufen.=20Kein=20Netzwerk-Request,=20sofortige=20Darstellung?= =?UTF-8?q?=20der=20neuen=20Farbe.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 3dca0ac..e177cb7 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -98,6 +98,22 @@ function _mergeEvents(newEvents) { } } +// Patch calendarColor in-place for all cached events belonging to a calendar, +// then re-render immediately without a network round-trip. +function applyCalendarColor(source, calId, color) { + const id = String(calId); + eventCache.events.forEach(ev => { + if (ev.source !== source) return; + const cid = String(ev.calendar_id); + if (cid === id || cid === source + '-' + id || cid.replace(source + '-', '') === id) { + ev.calendarColor = color; + } + }); + state.events = eventCache.events; + renderCalendarList(); + renderView(); +} + // Fire-and-forget: extend the cache toward whichever edge the view is approaching function prefetchIfNeeded(viewStart, viewEnd) { if (!eventCache.start || !eventCache.end) return; @@ -468,8 +484,7 @@ function renderCalendarList() { if (picked) { await api.put(`/local/calendars/${calId}`, { color: picked }); if (cal) cal.color = picked; - renderCalendarList(); - fetchAndRender(); + applyCalendarColor('local', calId, picked); } } else if (source === 'ical') { const subId = parseInt(dot.dataset.subId); @@ -478,8 +493,7 @@ function renderCalendarList() { if (picked) { await api.put(`/ical/subscriptions/${subId}`, { color: picked }); if (sub) sub.color = picked; - renderCalendarList(); - fetchAndRender(); + applyCalendarColor('ical', subId, picked); } } else if (source === 'google') { const calId = parseInt(dot.dataset.calId); @@ -492,8 +506,7 @@ function renderCalendarList() { if (picked) { await api.put(`/google/calendars/${calId}`, { color: picked }); if (gcal) gcal.color = picked; - renderCalendarList(); - fetchAndRender(); + applyCalendarColor('google', calId, picked); } } }); @@ -1732,8 +1745,7 @@ async function openCalColorPicker(anchor, calId) { if (cal.id === calId) cal.color = picked; } } - renderCalendarList(); - fetchAndRender(); + applyCalendarColor('caldav', calId, picked); } // ── Avatar Crop ────────────────────────────────────────── -- 2.47.3 From e8b5bb3a40bb89c31cb853db610f355374cf698b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:10:50 +0200 Subject: [PATCH 008/114] =?UTF-8?q?Feature:=20Quartalsansicht=20hinzugef?= =?UTF-8?q?=C3=BCgt=20Neue=20Ansicht=20zeigt=203=20Monate=20eines=20Quarta?= =?UTF-8?q?ls=20nebeneinander=20mit=20farbigen=20Event-Dots,=20Quartal-Nav?= =?UTF-8?q?igation=20und=20Titelanzeige=20(z.B.=20Q2=202026).=20Klick=20au?= =?UTF-8?q?f=20Tag=20wechselt=20in=20Tagesansicht.=20Zweisprachig=20(DE/EN?= =?UTF-8?q?).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 98 ++++++++++++++++++++++++++++++ frontend/index.html | 2 + frontend/js/calendar.js | 16 +++++ frontend/js/i18n.js | 4 +- frontend/js/views/quarter.js | 113 +++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 frontend/js/views/quarter.js diff --git a/frontend/css/app.css b/frontend/css/app.css index 89e567b..cd1214b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -616,6 +616,104 @@ a { color: var(--primary); text-decoration: none; } /* Day view specifics */ .day-view .week-day-col { flex: 1; } +/* ── Quarter View ───────────────────────────────────────── */ +.quarter-view { + display: flex; + gap: 16px; + padding: 16px; + height: 100%; + box-sizing: border-box; + overflow-y: auto; +} +.qtr-month { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.qtr-month-name { + font-size: 13px; + font-weight: 600; + color: var(--text-1); + padding: 10px 12px 8px; + border-bottom: 1px solid var(--border); + letter-spacing: .3px; +} +.qtr-month-grid { padding: 6px 8px 8px; } +.qtr-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 2px; +} +.qtr-dow { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-3); + text-align: center; + padding: 3px 0; +} +.qtr-cells { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(6, auto); +} +.qtr-cell { + padding: 3px 2px; + text-align: center; + cursor: pointer; + border-radius: 4px; + min-height: 36px; +} +.qtr-cell:hover { background: var(--bg-hover); } +.qtr-cell.today .qtr-day-num { + background: var(--today-color, var(--primary)); + color: #fff; + border-radius: 50%; +} +.qtr-cell.selected .qtr-day-num { + background: var(--primary); + color: #fff; + border-radius: 50%; + opacity: .55; +} +.qtr-day-num { + font-size: 12px; + font-weight: 500; + color: var(--text-2); + width: 22px; + height: 22px; + line-height: 22px; + margin: 0 auto 2px; +} +.qtr-cell.other-month .qtr-day-num { color: var(--text-3); opacity: .45; } +.qtr-cell.today .qtr-day-num { color: #fff; } +.qtr-dots { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 2px; + min-height: 6px; +} +.qtr-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + cursor: pointer; +} +.qtr-dot.past { opacity: .45; } +.qtr-dot-more { + font-size: 9px; + color: var(--text-3); + line-height: 6px; +} + /* ── Agenda View ────────────────────────────────────────── */ .agenda-view { padding: 16px; } .agenda-day { margin-bottom: 16px; } diff --git a/frontend/index.html b/frontend/index.html index 480ebe7..291fad8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -102,6 +102,7 @@ +
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index e177cb7..081145c 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -3,6 +3,7 @@ import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, date import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; 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'; @@ -193,6 +194,10 @@ function getViewRange() { start.setHours(0, 0, 0, 0); end = new Date(start); end.setDate(end.getDate() + 1); + } else if (state.currentView === 'quarter') { + const q = Math.floor(d.getMonth() / 3); + start = new Date(d.getFullYear(), q * 3, 1); + end = new Date(d.getFullYear(), q * 3 + 3, 1); } else { // agenda start = new Date(d); start.setHours(0, 0, 0, 0); @@ -232,6 +237,12 @@ function renderView() { weekStartDay, state.settings.hour_height || 60 ); + } else if (state.currentView === 'quarter') { + renderQuarter(container, state.currentDate, evs, + date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, + showEventPopup, + weekStartDay + ); } else { renderAgenda(container, state.currentDate, evs, showEventPopup); } @@ -273,6 +284,9 @@ function updateTitle() { : `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`; } else if (state.currentView === 'day') { title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`; + } else if (state.currentView === 'quarter') { + const q = Math.floor(d.getMonth() / 3) + 1; + title = `Q${q} ${d.getFullYear()}`; } else { title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`; } @@ -610,6 +624,8 @@ function navigate(dir) { } else if (state.currentView === 'day') { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir); + } else if (state.currentView === 'quarter') { + state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir * 3, 1); } else { state.currentDate = new Date(d); state.currentDate.setDate(d.getDate() + dir * 30); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 283b3d3..b57c5dc 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -24,7 +24,7 @@ const translations = { // Topbar btn_today: 'Heute', - view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine', + view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_quarter: 'Quartal', view_agenda: 'Termine', btn_profile: 'Profil', btn_logout: 'Abmelden', // Sidebar @@ -218,7 +218,7 @@ const translations = { // Topbar btn_today: 'Today', - view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events', + view_month: 'Month', view_week: 'Week', view_day: 'Day', view_quarter: 'Quarter', view_agenda: 'Events', btn_profile: 'Profile', btn_logout: 'Log out', // Sidebar diff --git a/frontend/js/views/quarter.js b/frontend/js/views/quarter.js new file mode 100644 index 0000000..ffb0bcb --- /dev/null +++ b/frontend/js/views/quarter.js @@ -0,0 +1,113 @@ +import { isToday, isSameDay, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; +import { t } from '../i18n.js'; + +export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { + const year = currentDate.getFullYear(); + // Quarter: Q1=0, Q2=1, Q3=2, Q4=3 + const quarter = Math.floor(currentDate.getMonth() / 3); + const firstMonthOfQ = quarter * 3; + + // Build event map keyed by date string + const evMap = {}; + events.forEach(ev => { + const s = new Date(ev.start); + const e = new Date(ev.end); + const cur = new Date(s); + cur.setHours(0, 0, 0, 0); + const endNorm = new Date(e); + endNorm.setHours(0, 0, 0, 0); + if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1); + while (cur <= endNorm) { + const key = dateKey(cur); + if (!evMap[key]) evMap[key] = []; + evMap[key].push(ev); + cur.setDate(cur.getDate() + 1); + } + }); + + const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); + const MONTHS = t('months'); + + const monthsHtml = [0, 1, 2].map(offset => { + const month = firstMonthOfQ + offset; + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + // Start grid on correct weekday + const gridStart = new Date(firstDay); + const startOffset = dayOfWeek(firstDay, weekStartDay); + gridStart.setDate(gridStart.getDate() - startOffset); + + const cells = []; + const d = new Date(gridStart); + for (let i = 0; i < 42; i++) { + cells.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + + // DOW header + const dowHeader = DOW.map(d => `
${d}
`).join(''); + + // Rows + let rowsHtml = ''; + for (let row = 0; row < 6; row++) { + for (let col = 0; col < 7; col++) { + const cell = cells[row * 7 + col]; + const key = dateKey(cell); + const cellEvs = evMap[key] || []; + + const isOther = cell.getMonth() !== month; + const todayCls = isToday(cell) ? 'today' : ''; + const otherCls = isOther ? 'other-month' : ''; + const selCls = isSameDay(cell, currentDate) && !isToday(cell) ? 'selected' : ''; + + // Up to 3 event dots + const dots = cellEvs.slice(0, 3).map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + return ``; + }).join(''); + const moreDot = cellEvs.length > 3 + ? `+${cellEvs.length - 3}` + : ''; + + rowsHtml += `
+
${cell.getDate()}
+
${dots}${moreDot}
+
`; + } + } + + return `
+
${MONTHS[month]}
+
+
${dowHeader}
+
${rowsHtml}
+
+
`; + }).join(''); + + container.innerHTML = `
${monthsHtml}
`; + + // Click handlers + container.querySelectorAll('.qtr-cell').forEach(cell => { + cell.addEventListener('click', e => { + // Check if a dot was clicked + const dot = e.target.closest('.qtr-dot'); + if (dot) { + e.stopPropagation(); + const ev = events.find(ev => ev.id === dot.dataset.id && ev.url === dot.dataset.url); + if (ev) { onEventClick(ev, dot); return; } + } + onDayClick(new Date(cell.dataset.date + 'T00:00:00')); + }); + }); +} + +function dateKey(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +function escAttr(s) { + return String(s).replace(/"/g,'"').replace(/'/g,'''); +} -- 2.47.3 From e9a307a20ded3d30568cc88a5c8cfd98eb8b5332 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:15:48 +0200 Subject: [PATCH 009/114] =?UTF-8?q?Fix:=20Quartalsansicht=20=E2=80=93=20zu?= =?UTF-8?q?f=C3=A4llige=20Today-Markierungen=20behoben,=20Button=20nach=20?= =?UTF-8?q?links=20verschoben=20Selected-Klasse=20aus=20der=20Quartalsansi?= =?UTF-8?q?cht=20entfernt=20(war=20visuell=20identisch=20mit=20Today).=20B?= =?UTF-8?q?utton-Reihenfolge:=20Quartal=20>=20Monat=20>=20Woche=20>=20Tag?= =?UTF-8?q?=20>=20Termine.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 6 ------ frontend/index.html | 2 +- frontend/js/views/quarter.js | 11 +++++------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index cd1214b..01dfafa 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -675,12 +675,6 @@ a { color: var(--primary); text-decoration: none; } color: #fff; border-radius: 50%; } -.qtr-cell.selected .qtr-day-num { - background: var(--primary); - color: #fff; - border-radius: 50%; - opacity: .55; -} .qtr-day-num { font-size: 12px; font-weight: 500; diff --git a/frontend/index.html b/frontend/index.html index 291fad8..7bc3514 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -99,10 +99,10 @@
+ -
- - - + + + +

Ausgeblendete Kalender

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 081145c..cb8566b 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -227,7 +227,7 @@ function renderView() { showEventPopup, false, weekStartDay, - state.settings.hour_height || 60 + state.settings.hour_height || 54 ); } else if (state.currentView === 'day') { renderWeek(container, state.currentDate, evs, @@ -235,7 +235,7 @@ function renderView() { showEventPopup, true, weekStartDay, - state.settings.hour_height || 60 + state.settings.hour_height || 54 ); } else if (state.currentView === 'quarter') { renderQuarter(container, state.currentDate, evs, @@ -653,15 +653,15 @@ function bindTopbar() { document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); - // Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture) + // Mouse wheel / trackpad scroll navigation – only for month & quarter let _wheelLast = 0; document.getElementById('view-container').addEventListener('wheel', e => { + if (state.currentView !== 'month' && state.currentView !== 'quarter') return; e.preventDefault(); const now = Date.now(); if (now - _wheelLast < 500) return; _wheelLast = now; const dir = e.deltaY > 0 ? 1 : -1; - if (state.currentView === 'agenda') return; if (state.currentView === 'month') { state.currentDate = new Date(state.currentDate); state.currentDate.setDate(state.currentDate.getDate() + dir * 7); @@ -1527,7 +1527,7 @@ function bindSettingsModal() { dim_past_events: document.getElementById('cfg-dim-past').checked, text_contrast: getActive('cfg-text-contrast') || 3, line_contrast: getActive('cfg-line-contrast') || 3, - hour_height: getActive('cfg-hour-height') || 60, + hour_height: getActive('cfg-hour-height') || 54, language: document.getElementById('cfg-language').value, }; try { diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 8219e07..7f5caa7 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -92,7 +92,7 @@ export function applyTheme(settings) { root.style.setProperty('--border', lc.border); root.style.setProperty('--border-light', lc.light); - const hh = settings.hour_height || 60; + const hh = settings.hour_height || 54; root.style.setProperty('--hour-h', hh + 'px'); } -- 2.47.3 From d29cbb84500f366313e1bc46b06a2edff12f2b4b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:27:24 +0200 Subject: [PATCH 011/114] =?UTF-8?q?Fix:=20Mehrt=C3=A4gige=20Events=20auf?= =?UTF-8?q?=20Tagesende=20begrenzt,=20Stundenh=C3=B6hen=20weiter=20reduzie?= =?UTF-8?q?rt=20Timed-Events=20in=20Wochen-/Tagesansicht=20werden=20jetzt?= =?UTF-8?q?=20auf=20Mitternacht=20(24:00)=20des=20Starttages=20gek=C3=BCrz?= =?UTF-8?q?t=20=E2=80=93=20keine=20kilometerhohen=20Balken=20mehr=20bei=20?= =?UTF-8?q?tages=C3=BCbergreifenden=20Terminen.=20Stundenh=C3=B6hen:=2036/?= =?UTF-8?q?54/72/90=20=E2=86=92=2028/44/60/80px;=20Kompakt=20(28px)=20zeig?= =?UTF-8?q?t=2024h=20=3D=20672px.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 8 ++++---- frontend/js/calendar.js | 6 +++--- frontend/js/utils.js | 2 +- frontend/js/views/week.js | 4 +++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 3d1ef86..2c04d89 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -494,10 +494,10 @@

Stundenhöhe (Wochen- & Tagesansicht)

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

- - - - + + + +

Ausgeblendete Kalender

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index cb8566b..38e0488 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -227,7 +227,7 @@ function renderView() { showEventPopup, false, weekStartDay, - state.settings.hour_height || 54 + state.settings.hour_height || 44 ); } else if (state.currentView === 'day') { renderWeek(container, state.currentDate, evs, @@ -235,7 +235,7 @@ function renderView() { showEventPopup, true, weekStartDay, - state.settings.hour_height || 54 + state.settings.hour_height || 44 ); } else if (state.currentView === 'quarter') { renderQuarter(container, state.currentDate, evs, @@ -1527,7 +1527,7 @@ function bindSettingsModal() { dim_past_events: document.getElementById('cfg-dim-past').checked, text_contrast: getActive('cfg-text-contrast') || 3, line_contrast: getActive('cfg-line-contrast') || 3, - hour_height: getActive('cfg-hour-height') || 54, + hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, }; try { diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 7f5caa7..58a151a 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -92,7 +92,7 @@ export function applyTheme(settings) { root.style.setProperty('--border', lc.border); root.style.setProperty('--border-light', lc.light); - const hh = settings.hour_height || 54; + const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); } diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index d0d7203..eed4c3f 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -74,7 +74,9 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const s = new Date(ev.start); const e = new Date(ev.end); const top = s.getHours() * hourH + s.getMinutes() * hourH / 60; - const height = Math.max(20, (e - s) / 60000 * hourH / 60); + const dayEnd = new Date(s); dayEnd.setHours(24, 0, 0, 0); + const clampedEnd = e > dayEnd ? dayEnd : e; + const height = Math.max(20, (clampedEnd - s) / 60000 * hourH / 60); const left = (col / cols) * 100; const width = (1 / cols) * 100 - 0.5; const color = ev.color || ev.calendarColor || '#4285f4'; -- 2.47.3 From 5dcde0a3ef935e4a7e7038f57907e0ef4f9dacbc Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:34:01 +0200 Subject: [PATCH 012/114] =?UTF-8?q?Feature:=20Enddatum=20im=20Popup=20+=20?= =?UTF-8?q?Kopieren-nach-Kalender-Button=20Enddatum=20wird=20im=20Event-Po?= =?UTF-8?q?pup=20angezeigt=20wenn=20Termin=20=C3=BCber=20Mitternacht=20geh?= =?UTF-8?q?t.=20Neuer=20Kopieren-Button=20(=F0=9F=93=8B)=20im=20Popup=20?= =?UTF-8?q?=C3=B6ffnet=20Kalender-Auswahl=20und=20dupliziert=20den=20Termi?= =?UTF-8?q?n=20in=20den=20gew=C3=A4hlten=20Kalender=20(CalDAV=20/=20Lokal?= =?UTF-8?q?=20/=20Google).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 15 ++++++++ frontend/index.html | 4 ++ frontend/js/calendar.js | 82 +++++++++++++++++++++++++++++++++++++++-- frontend/js/i18n.js | 2 + 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 01dfafa..939f50b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -791,6 +791,21 @@ a { color: var(--primary); text-decoration: none; } .popup-body { padding: 12px 16px; } .popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } .popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } +.popup-copy-menu { + border-top: 1px solid var(--border); + padding: 4px 0; +} +.popup-copy-label { + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--text-3); + padding: 4px 14px 6px; +} +.popup-copy-item { + display: flex; align-items: center; gap: 9px; + padding: 7px 14px; cursor: pointer; font-size: 13px; color: var(--text-1); +} +.popup-copy-item:hover { background: var(--bg-hover); } +.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } /* ── Settings Page ──────────────────────────────────────── */ #modal-settings.modal-overlay { diff --git a/frontend/index.html b/frontend/index.html index 2c04d89..8b759f3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -273,6 +273,9 @@ + @@ -284,6 +287,7 @@ + diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 38e0488..8e2ae6a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -731,12 +731,16 @@ function showEventPopup(ev, anchor) { // Time if (ev.allDay) { - document.getElementById('popup-time').textContent = 'Ganztägig'; + document.getElementById('popup-time').textContent = t('allday_cap'); } else { const s = new Date(ev.start); const e = new Date(ev.end); - document.getElementById('popup-time').textContent = - `${fmtDatetime(s)} – ${fmtTime(e)}`; + const sameDay = s.getFullYear() === e.getFullYear() && + s.getMonth() === e.getMonth() && + s.getDate() === e.getDate(); + document.getElementById('popup-time').textContent = sameDay + ? `${fmtDatetime(s)} – ${fmtTime(e)}` + : `${fmtDatetime(s)} – ${fmtDatetime(e)}`; } document.getElementById('popup-location').textContent = ev.location || ''; @@ -759,6 +763,33 @@ function showEventPopup(ev, anchor) { popup.classList.add('hidden'); openEditEventModal(ev); }; + + // Copy to calendar + document.getElementById('popup-copy').onclick = e => { + e.stopPropagation(); + const menu = document.getElementById('popup-copy-menu'); + if (!menu.classList.contains('hidden')) { menu.classList.add('hidden'); return; } + const targets = buildWritableCalendars(ev); + if (!targets.length) { showToast('Keine Zielkalender verfügbar', true); return; } + menu.innerHTML = `` + + targets.map(c => + `` + ).join(''); + menu.classList.remove('hidden'); + menu.querySelectorAll('.popup-copy-item').forEach(el => { + el.addEventListener('click', async ev2 => { + ev2.stopPropagation(); + menu.classList.add('hidden'); + popup.classList.add('hidden'); + const cal = targets[parseInt(el.dataset.calIdx)]; + await copyEventToCalendar(ev, cal); + }); + }); + }; + document.getElementById('popup-delete').onclick = async () => { if (!confirm(t("confirm_delete_event", {title: ev.title}))) return; popup.classList.add('hidden'); @@ -1881,3 +1912,48 @@ function fmtDatetime(d) { function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } + +function buildWritableCalendars(_excludeEv) { + const list = []; + let idx = 0; + for (const acc of state.accounts) { + for (const cal of acc.calendars) { + if (cal.sidebar_hidden) continue; + list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#4285f4', type: 'caldav' }); + } + } + for (const cal of state.localCalendars) { + list.push({ _idx: idx++, id: cal.id, name: cal.name, color: cal.color || '#34a853', type: 'local' }); + } + for (const acc of state.googleAccounts) { + for (const cal of acc.calendars) { + if (cal.sidebar_hidden) continue; + list.push({ _idx: idx++, id: cal.id, name: `${acc.email} / ${cal.name}`, color: cal.color || '#4285f4', type: 'google' }); + } + } + return list; +} + +async function copyEventToCalendar(ev, cal) { + const { title, start, end, allDay, location, description, color } = ev; + try { + if (cal.type === 'google') { + await api.post('/google/events', { + calendar_db_id: cal.id, title, start, end, allDay, + location: location || '', description: description || '', + }); + } else if (cal.type === 'local') { + await api.post('/local/events', { + calendar_id: cal.id, title, start, end, allDay, + location: location || '', description: description || '', color: color || null, + }); + } else { + await api.post('/caldav/events', { + calendar_id: cal.id, title, start, end, allDay, + location: location || '', description: description || '', color: color || null, + }); + } + showToast(t('event_copied')); + fetchAndRender(true); + } catch (e) { showToast(e.message, true); } +} diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index b57c5dc..48de8ae 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -142,6 +142,7 @@ const translations = { error_enter_title: 'Bitte Titel eingeben', error_enter_date: 'Bitte Datum eingeben', error_enter_start: 'Bitte Start-Zeit eingeben', + copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', confirm_delete_event: '"{title}" wirklich löschen?', event_deleted: 'Termin gelöscht', @@ -336,6 +337,7 @@ const translations = { error_enter_title: 'Please enter a title', error_enter_date: 'Please enter a date', error_enter_start: 'Please enter a start time', + copy_to_calendar: 'Copy to…', event_copied: 'Event copied', event_updated: 'Event updated', event_created: 'Event created', confirm_delete_event: 'Really delete "{title}"?', event_deleted: 'Event deleted', -- 2.47.3 From eea150373edb990b2fb59b2fc2477ad19b194717 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:43:34 +0200 Subject: [PATCH 013/114] =?UTF-8?q?Fix:=20CalDAV=20delete/update,=20Copy-M?= =?UTF-8?q?en=C3=BC-Reset,=20Timezone=20beim=20Kopieren=20-=20caldav=5Fcli?= =?UTF-8?q?ent:=20client.event()=20=E2=86=92=20caldav.Event()=20mit=20reso?= =?UTF-8?q?urce.load()=20f=C3=BCr=20update/delete=20(DAVClient=20hat=20kei?= =?UTF-8?q?ne=20event()-Methode)=20-=20Popup:=20Copy-Men=C3=BC=20wird=20be?= =?UTF-8?q?im=20=C3=96ffnen=20eines=20neuen=20Events=20immer=20zur=C3=BCck?= =?UTF-8?q?gesetzt=20-=20copyEventToCalendar:=20start/end=20via=20new=20Da?= =?UTF-8?q?te().toISOString()=20normalisiert=20=E2=86=92=20verhindert=202h?= =?UTF-8?q?-Verschiebung=20bei=20Terminen=20ohne=20Timezone-Info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 5 +++-- frontend/js/calendar.js | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 23c9bdd..49f9ca6 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -211,7 +211,8 @@ def update_event( url: str, username: str, password: str, event_url: str, data: Dict ): client = _client(url, username, password) - resource = client.event(url=event_url) + resource = caldav.Event(client=client, url=event_url) + resource.load() raw = resource.data cal = Calendar.from_ical(raw) @@ -255,7 +256,7 @@ def update_event( def delete_event(url: str, username: str, password: str, event_url: str): client = _client(url, username, password) - resource = client.event(url=event_url) + resource = caldav.Event(client=client, url=event_url) resource.delete() diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 8e2ae6a..bfa22a3 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -723,6 +723,7 @@ function bindSidebar() { // ── Event Popup ─────────────────────────────────────────── function showEventPopup(ev, anchor) { const popup = document.getElementById('popup-event'); + document.getElementById('popup-copy-menu').classList.add('hidden'); popup.classList.remove('hidden'); const color = ev.color || ev.calendarColor || '#4285f4'; @@ -1935,7 +1936,11 @@ function buildWritableCalendars(_excludeEv) { } async function copyEventToCalendar(ev, cal) { - const { title, start, end, allDay, location, description, color } = ev; + const { title, allDay, location, description, color } = ev; + // Normalize to UTC ISO string so the backend doesn't misinterpret bare local times + const toISO = s => (s && s.length > 10) ? new Date(s).toISOString() : s; + const start = allDay ? ev.start : toISO(ev.start); + const end = allDay ? ev.end : toISO(ev.end); try { if (cal.type === 'google') { await api.post('/google/events', { -- 2.47.3 From f98ff69a9bf5e291ef874c50d19794b20e3b0be3 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:47:11 +0200 Subject: [PATCH 014/114] =?UTF-8?q?Feature:=20Mehrt=C3=A4gige=20Termine=20?= =?UTF-8?q?in=20Wochen-/Tagesansicht=20vollst=C3=A4ndig=20anzeigen=20Timed?= =?UTF-8?q?-Events=20die=20mehrere=20Tage=20=C3=BCberspannen=20werden=20ne?= =?UTF-8?q?u=20in=20der=20Ganztags-Zeile=20f=C3=BCr=20jeden=20betroffenen?= =?UTF-8?q?=20Tag=20als=20Bar=20angezeigt=20(am=20Starttag=20mit=20Uhrzeit?= =?UTF-8?q?).=20Die=20Tagesspalten=20erhalten=20einen=2015%-Farbhintergrun?= =?UTF-8?q?d=20(col-span-tint)=20um=20die=20Abdeckung=20zu=20visualisieren?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 7 +++++++ frontend/js/views/week.js | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 939f50b..d1e7920 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -554,11 +554,18 @@ a { color: var(--primary); text-decoration: none; } .allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); } .allday-cols { display: flex; flex: 1; } .allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; } +.col-span-tint { + position: absolute; inset: 0; pointer-events: none; z-index: 0; +} .allday-event { font-size: 11px; font-weight: 500; padding: 2px 6px; border-radius: 3px; margin-bottom: 2px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.allday-event.multiday-timed { + opacity: .88; + border-left: 3px solid rgba(255,255,255,.45); +} /* Time grid */ .week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index eed4c3f..b62a68e 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -16,8 +16,19 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC } // Separate all-day and timed events - const allDayEvs = events.filter(ev => ev.allDay); - const timedEvs = events.filter(ev => !ev.allDay); + const allDayEvs = events.filter(ev => ev.allDay); + const timedEvs = events.filter(ev => !ev.allDay); + // Multi-day timed events: timed but spanning more than one calendar day + const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end))); + + // Returns true if event overlaps any part of the given day + function spansDay(ev, day) { + const evStart = new Date(ev.start); + const evEnd = new Date(ev.end); + const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0); + return evStart < dayEnd && evEnd > dayStart; + } // ── KW Badge ────────────────────────────────────────── const kwNum = getISOWeekNumber(days[0]); @@ -43,12 +54,22 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const d = new Date(day); d.setHours(0,0,0,0); return d >= s && d < e || isSameDay(d, s); }); - const inner = dayEvs.map(ev => { + const allDayHtml = dayEvs.map(ev => { const color = ev.color || ev.calendarColor || '#4285f4'; return `
${escHtml(ev.title)}
`; }).join(''); - return `
${inner}
`; + + // Multi-day timed events: show in all-day row for every day they span + const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + const isStart = isSameDay(new Date(ev.start), day); + const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; + return `
${escHtml(label)}
`; + }).join(''); + + return `
${allDayHtml}${spanHtml}
`; }).join(''); // ── Time column labels ──────────────────────────────── @@ -92,7 +113,14 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC `; }).join(''); + // Background tint for days covered by multi-day timed events + const tintHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { + const color = ev.color || ev.calendarColor || '#4285f4'; + return `
`; + }).join(''); + return `
+ ${tintHtml} ${hourLines} ${evHtml}
`; -- 2.47.3 From 4156bc4413cc9ea9df19bf9ee43f20ef1a067137 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 14:57:57 +0200 Subject: [PATCH 015/114] =?UTF-8?q?Feature:=20Dynamische=20Monatsansicht-L?= =?UTF-8?q?anes=20+=20spanning=20All-Day-Balken=20in=20Wochenansicht=20mon?= =?UTF-8?q?th.js:=20MAX=5FLANES=20wird=20jetzt=20aus=20der=20tats=C3=A4chl?= =?UTF-8?q?ichen=20Container-H=C3=B6he=20berechnet=20(kein=20hartes=20Limi?= =?UTF-8?q?t=20von=203=20mehr).=20week.js:=20All-Day-Zeile=20verwendet=20j?= =?UTF-8?q?etzt=20dieselbe=20Overlay-Logik=20wie=20die=20Monatsansicht=20?= =?UTF-8?q?=E2=80=93=20Termine=20spannen=20als=20einzelner=20Balken=20?= =?UTF-8?q?=C3=BCber=20mehrere=20Tage.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 31 +++++++------ frontend/js/views/month.js | 12 +++-- frontend/js/views/week.js | 93 +++++++++++++++++++++++++++----------- 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d1e7920..d5e12aa 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -550,22 +550,25 @@ a { color: var(--primary); text-decoration: none; } .week-day-header.today .day-name { color: var(--today-color); } /* All-day row */ -.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; } -.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); } -.allday-cols { display: flex; flex: 1; } -.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; } -.col-span-tint { - position: absolute; inset: 0; pointer-events: none; z-index: 0; -} -.allday-event { - font-size: 11px; font-weight: 500; padding: 2px 6px; - border-radius: 3px; margin-bottom: 2px; cursor: pointer; +.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px 6px 0; font-size: 10px; color: var(--text-3); } +.allday-cols-wrap { flex: 1; position: relative; display: flex; } +.allday-col-bg { flex: 1; border-left: 1px solid var(--border); } +.allday-spans-layer { position: absolute; inset: 0; pointer-events: none; } +.allday-span { + position: absolute; + height: 18px; line-height: 18px; + font-size: 11px; font-weight: 500; + padding: 0 6px; border-radius: 3px; + cursor: pointer; pointer-events: all; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.allday-event.multiday-timed { - opacity: .88; - border-left: 3px solid rgba(255,255,255,.45); -} +.allday-span:hover { filter: brightness(1.12); } +.allday-span.past { opacity: .45; } +.allday-span.continues-left { border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px; } +.allday-span.continues-right { border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px; } +.allday-span.multiday-timed { opacity: .88; border-left: 3px solid rgba(255,255,255,.4); } +.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ .week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 0d4db29..201636e 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,12 +1,16 @@ import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; -const LANE_H = 20; // px per lane (event height 18px + 2px gap) -const MAX_LANES = 3; // max visible lanes per row - -const NUM_ROWS = 5; // rolling view: always 5 weeks +const LANE_H = 20; // px per lane (event height 18px + 2px gap) +const DAY_H = 30; // day-number row height +const NUM_ROWS = 5; // rolling view: always 5 weeks export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { + // Dynamic lane limit: how many events fit in the actual row height + const containerH = container.clientHeight || 600; + const headerH = 34; // month-header DOW row + const rowH = (containerH - headerH) / NUM_ROWS; + const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1); // "Primary month" = currentDate's month (used for muting other-month days) const primaryMonth = currentDate.getMonth(); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index b62a68e..8923671 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -45,33 +45,41 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC `; }).join(''); - // ── All-day row ─────────────────────────────────────── - const alldayCols = days.map(day => { - const key = dayKey(day); - const dayEvs = allDayEvs.filter(ev => { - const s = new Date(ev.start); s.setHours(0,0,0,0); - const e = new Date(ev.end); e.setHours(0,0,0,0); - const d = new Date(day); d.setHours(0,0,0,0); - return d >= s && d < e || isSameDay(d, s); - }); - const allDayHtml = dayEvs.map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - return `
${escHtml(ev.title)}
`; - }).join(''); + // ── All-day row (spanning bars, same logic as month view) ── + const ALLDAY_LANE_H = 22; + const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs]; + const alldayLayout = layoutWeekAllDay(allDayAndMulti, days); + const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1; + const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6; - // Multi-day timed events: show in all-day row for every day they span - const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - const isStart = isSameDay(new Date(ev.start), day); - const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; - return `
${escHtml(label)}
`; - }).join(''); - - return `
${allDayHtml}${spanHtml}
`; + const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => { + const isMultiTimed = multiDayTimedEvs.includes(ev); + const n = days.length; + const left = (colStart / n) * 100; + const width = ((colEnd - colStart + 1) / n) * 100; + const top = lane * ALLDAY_LANE_H + 2; + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + const multiCls = isMultiTimed ? 'multiday-timed' : ''; + const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : ''; + const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : ''; + const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) + ? `${fmtTime(new Date(ev.start))} ${ev.title}` + : ev.title; + return `
${escHtml(label)}
`; }).join(''); + const alldayBgCols = days.map(day => + `
` + ).join(''); + + const alldayCols = `
+ ${alldayBgCols} +
${alldaySpanHtml}
+
`; + // ── Time column labels ──────────────────────────────── const timeLabels = Array.from({length: 24}, (_, h) => `
${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}
` @@ -180,8 +188,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC }); }); - // Click: all-day event - container.querySelectorAll('.allday-event').forEach(el => { + // Click: all-day span + container.querySelectorAll('.allday-span').forEach(el => { el.addEventListener('click', e => { e.stopPropagation(); const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); @@ -205,6 +213,39 @@ function renderNowLine(container, days, hourH = 60) { setTimeout(() => renderNowLine(container, days, hourH), 60000); } +function layoutWeekAllDay(evs, days) { + const items = []; + evs.forEach(ev => { + let colStart = -1, colEnd = -1; + days.forEach((day, i) => { + const ds = new Date(day); ds.setHours(0, 0, 0, 0); + const de = new Date(day); de.setHours(24, 0, 0, 0); + if (new Date(ev.start) < de && new Date(ev.end) > ds) { + if (colStart === -1) colStart = i; + colEnd = i; + } + }); + if (colStart === -1) return; + items.push({ ev, colStart, colEnd }); + }); + + // Sort: longer spans first, then by start column + items.sort((a, b) => + (b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart + ); + + // Greedy lane assignment + const laneEnds = []; + items.forEach(item => { + let lane = laneEnds.findIndex(end => item.colStart > end); + if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); } + item.lane = lane; + laneEnds[lane] = item.colEnd; + }); + + return items; +} + function layoutEvents(events) { if (!events.length) return []; -- 2.47.3 From 4a2f094a4046c52cc8785773028083e4928b6990 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 15:08:45 +0200 Subject: [PATCH 016/114] =?UTF-8?q?Fix:=20Ganzt=C3=A4gig-Zeile=20sticky=20?= =?UTF-8?q?+=20korrekte=20Ausrichtung=20in=20Wochen-/Tagesansicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 21 ++++++++++++--------- frontend/js/views/week.js | 16 +++++++++------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d5e12aa..ee53534 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -373,8 +373,8 @@ a { color: var(--primary); text-decoration: none; } background: var(--bg-sidebar); } .sidebar-copyright:hover { color: var(--text-1); } -.main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; } -#view-container { flex: 1; display: flex; flex-direction: column; } +.main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; } +#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; } /* ── Mini Calendar ──────────────────────────────────────── */ .mini-cal { padding: 12px 16px; } @@ -452,7 +452,7 @@ a { color: var(--primary); text-decoration: none; } .cal-item:hover .cal-item-remove { opacity: 1; } /* ── Month View ─────────────────────────────────────────── */ -.month-view { display: flex; flex-direction: column; height: 100%; } +.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .month-header { display: grid; grid-template-columns: 38px repeat(7, 1fr); border-bottom: 1px solid var(--border); flex-shrink: 0; @@ -509,11 +509,13 @@ a { color: var(--primary); text-decoration: none; } .month-more:hover { color: var(--primary); } /* ── Week / Day Views ───────────────────────────────────── */ -.week-view, .day-view { display: flex; flex-direction: column; height: 100%; } +.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.week-head-sticky { + flex-shrink: 0; position: sticky; top: 0; z-index: 10; + background: var(--bg-app); +} .week-header-row { display: flex; border-bottom: 1px solid var(--border); - flex-shrink: 0; background: var(--bg-app); - position: sticky; top: 0; z-index: 10; } /* KW badge in week view header gutter */ .week-kw-badge { @@ -571,7 +573,7 @@ a { color: var(--primary); text-decoration: none; } .col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ -.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } +.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; } .week-time-col { width: 56px; flex-shrink: 0; position: relative; } .time-label { height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end; @@ -631,7 +633,8 @@ a { color: var(--primary); text-decoration: none; } display: flex; gap: 16px; padding: 16px; - height: 100%; + flex: 1; + min-height: 0; box-sizing: border-box; overflow-y: auto; } @@ -719,7 +722,7 @@ a { color: var(--primary); text-decoration: none; } } /* ── Agenda View ────────────────────────────────────────── */ -.agenda-view { padding: 16px; } +.agenda-view { padding: 16px; flex: 1; overflow-y: auto; min-height: 0; } .agenda-day { margin-bottom: 16px; } .agenda-date { font-size: 13px; font-weight: 600; color: var(--text-2); diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 8923671..212574a 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -137,13 +137,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const viewClass = isSingleDay ? 'day-view' : 'week-view'; container.innerHTML = `
-
-
${kwBadge}
- ${headerCols} -
-
-
${t('allday')}
-
${alldayCols}
+
+
+
${kwBadge}
+ ${headerCols} +
+
+
${t('allday')}
+ ${alldayCols} +
${timeLabels}
-- 2.47.3 From 77462263e104c4f99f53f04167fd66f50f8672fd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 15:11:13 +0200 Subject: [PATCH 017/114] Fix: Spaltenbreite Zeitraster und Kopfzeile durch scrollbar-gutter angleichen --- frontend/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index ee53534..ccff3e9 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; } .col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ -.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; } +.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; scrollbar-gutter: stable; } .week-time-col { width: 56px; flex-shrink: 0; position: relative; } .time-label { height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end; -- 2.47.3 From ecdf8917d6e4c90deb73f47f4a3f348272508a9c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 15:13:00 +0200 Subject: [PATCH 018/114] =?UTF-8?q?Fix:=20overflow-y:scroll=20statt=20auto?= =?UTF-8?q?=20f=C3=BCr=20konsistente=20Spaltenbreite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index ccff3e9..9cac5c3 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; } .col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ -.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; scrollbar-gutter: stable; } +.week-body { display: flex; flex: 1; overflow-y: scroll; position: relative; min-height: 0; } .week-time-col { width: 56px; flex-shrink: 0; position: relative; } .time-label { height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end; -- 2.47.3 From 0cce4fc721442ca74d954c3e43b88aed2644160a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 15:15:37 +0200 Subject: [PATCH 019/114] Fix: Kopfzeilen-Breite per JS an Scrollbar-Breite anpassen --- frontend/js/views/week.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 212574a..3861d06 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -157,6 +157,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const body = container.querySelector('.week-body'); if (body) body.scrollTop = 8 * hourH - 20; + // Align sticky header width to body content width (account for scrollbar) + const stickyTop = container.querySelector('.week-head-sticky'); + if (body && stickyTop) { + stickyTop.style.paddingRight = (body.offsetWidth - body.clientWidth) + 'px'; + } + // Render current-time line renderNowLine(container, days, hourH); -- 2.47.3 From 307ee3c6a9c7e9960a4fb77264796571be95bea9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Apr 2026 15:19:16 +0200 Subject: [PATCH 020/114] =?UTF-8?q?Fix:=20Scroll=20auf=20week-view=20verle?= =?UTF-8?q?gen=20=E2=80=93=20Header=20und=20Zeitraster=20immer=20gleich=20?= =?UTF-8?q?breit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 4 ++-- frontend/js/views/week.js | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 9cac5c3..ee6ec83 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -509,7 +509,7 @@ a { color: var(--primary); text-decoration: none; } .month-more:hover { color: var(--primary); } /* ── Week / Day Views ───────────────────────────────────── */ -.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: scroll; } .week-head-sticky { flex-shrink: 0; position: sticky; top: 0; z-index: 10; background: var(--bg-app); @@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; } .col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ -.week-body { display: flex; flex: 1; overflow-y: scroll; position: relative; min-height: 0; } +.week-time-area { display: flex; flex-shrink: 0; } .week-time-col { width: 56px; flex-shrink: 0; position: relative; } .time-label { height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end; diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 3861d06..98de4c4 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -147,21 +147,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC ${alldayCols}
-
+
${timeLabels}
${dayCols}
`; // Scroll to ~8:00 - const body = container.querySelector('.week-body'); - if (body) body.scrollTop = 8 * hourH - 20; - - // Align sticky header width to body content width (account for scrollbar) - const stickyTop = container.querySelector('.week-head-sticky'); - if (body && stickyTop) { - stickyTop.style.paddingRight = (body.offsetWidth - body.clientWidth) + 'px'; - } + const scrollEl = container.querySelector(`.${viewClass}`); + if (scrollEl) scrollEl.scrollTop = 8 * hourH - 20; // Render current-time line renderNowLine(container, days, hourH); @@ -171,7 +165,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC col.addEventListener('click', e => { if (e.target.closest('.timed-event')) return; const rect = col.getBoundingClientRect(); - const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0); + const y = e.clientY - rect.top + (scrollEl?.scrollTop || 0); const h = Math.floor(y / hourH); const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15; const date = new Date(col.dataset.date + 'T00:00:00'); -- 2.47.3 From 62e7fa8be1ec1bbba05c2520a17a4461b4eae3f1 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 8 Apr 2026 21:40:01 +0200 Subject: [PATCH 021/114] =?UTF-8?q?fix:=20Google-Token-Fehler=20wird=20sic?= =?UTF-8?q?htbar=20gemacht=20und=20dem=20User=20gemeldet=20Wenn=20der=20Ac?= =?UTF-8?q?cess-Token=20eines=20Google-Accounts=20abl=C3=A4uft=20und=20der?= =?UTF-8?q?=20Refresh=20fehlschl=C3=A4gt,=20wurde=20die=20leere=20Terminli?= =?UTF-8?q?ste=20bisher=20still=20zur=C3=BCckgegeben=20(kein=20Log,=20kein?= =?UTF-8?q?e=20UI-Meldung).=20Jetzt=20wird=20der=20Fehler=20geloggt,=20an?= =?UTF-8?q?=20den=20Aufrufer=20weitergegeben=20und=20als=20Toast-Meldung?= =?UTF-8?q?=20im=20Frontend=20angezeigt=20("Token=20abgelaufen=20=E2=80=93?= =?UTF-8?q?=20bitte=20Konto=20trennen=20und=20neu=20verbinden").=20Das=20E?= =?UTF-8?q?vents-Endpoint=20gibt=20nun=20{events,=20errors}=20statt=20ein?= =?UTF-8?q?=20reines=20Array=20zur=C3=BCck;=20das=20Frontend=20extrahiert?= =?UTF-8?q?=20die=20Events=20entsprechend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/caldav_router.py | 4 +++- backend/routers/google_router.py | 5 +++-- frontend/js/calendar.js | 12 +++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index a0d61aa..1aa38e4 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -347,13 +347,15 @@ def get_events( .filter(models.GoogleAccount.user_id == current_user.id) .all() ) + google_errors = [] for g_acc in google_accounts: try: all_events.extend(get_google_events(g_acc, start_dt, end_dt, db)) except Exception as exc: logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) + google_errors.append({"email": g_acc.email}) - return all_events + return {"events": all_events, "errors": google_errors} @router.post("/events") diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py index d771d61..0a1c443 100644 --- a/backend/routers/google_router.py +++ b/backend/routers/google_router.py @@ -373,8 +373,9 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: """Fetch events from all enabled Google calendars for an account.""" try: token = _refresh_access_token(account, db) - except Exception: - return [] + except Exception as exc: + logger.error("Token refresh failed for Google account %s: %s", account.email, exc) + raise all_events = [] try: diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index bfa22a3..631d012 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -124,7 +124,7 @@ function prefetchIfNeeded(viewStart, viewEnd) { const from = new Date(eventCache.end); const to = new Date(eventCache.end.getTime() + PREFETCH_EXT); api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) - .then(evs => { _mergeEvents(evs); eventCache.end = to; }) + .then(r => { _mergeEvents(r.events || r); eventCache.end = to; }) .catch(() => {}) .finally(() => { eventCache._fwdPending = false; }); } @@ -134,7 +134,7 @@ function prefetchIfNeeded(viewStart, viewEnd) { const from = new Date(eventCache.start.getTime() - PREFETCH_EXT); const to = new Date(eventCache.start); api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`) - .then(evs => { _mergeEvents(evs); eventCache.start = from; }) + .then(r => { _mergeEvents(r.events || r); eventCache.start = from; }) .catch(() => {}) .finally(() => { eventCache._bwdPending = false; }); } @@ -161,7 +161,13 @@ async function fetchAndRender(force = false) { showLoading(); try { - const events = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); + const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); + const events = resp.events || resp; + if (resp.errors && resp.errors.length) { + for (const err of resp.errors) { + showToast(`Google (${err.email}): Token abgelaufen – bitte Konto trennen und neu verbinden`, true); + } + } eventCache.start = fetchStart; eventCache.end = fetchEnd; eventCache.events = events; -- 2.47.3 From f2da15784bc93afd718af2fdb1c6d46eb9dca9d9 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 8 Apr 2026 21:49:24 +0200 Subject: [PATCH 022/114] =?UTF-8?q?fix:=20Wochenkalender-Filter=20und=20pe?= =?UTF-8?q?r-Kalender=20Fehlerbehandlung=20Der=20Wochenkalender=20von=20Go?= =?UTF-8?q?ogle=20hat=20locale-spezifische=20IDs=20(z.B.=20de.german#weekn?= =?UTF-8?q?um@...)=20die=20nicht=20im=20alten=20exakten=20Set-Filter=20gef?= =?UTF-8?q?angen=20wurden.=20Dadurch=20wurde=20er=20in=20die=20DB=20gespei?= =?UTF-8?q?chert=20und=20verursachte=20beim=20Event-Abruf=20einen=20API-Fe?= =?UTF-8?q?hler.=20Da=20der=20try/except=20die=20gesamte=20Kalender-Schlei?= =?UTF-8?q?fe=20umschloss,=20wurden=20bei=20einem=20einzigen=20fehlerhafte?= =?UTF-8?q?n=20Kalender=20alle=20anderen=20Events=20ebenfalls=20verloren?= =?UTF-8?q?=20=E2=80=94=20Ursache=20f=C3=BCr=20keine=20Termine=20trotz=20k?= =?UTF-8?q?orrektem=20Token.=20-=20=5Fis=5Fsystem=5Fcalendar():=20pr=C3=BC?= =?UTF-8?q?ft=20jetzt=20auch=20'weeknum'=20als=20Substring=20-=20=5Fsync?= =?UTF-8?q?=5Fgoogle=5Fcalendars():=20bereinigt=20bereits=20gespeicherte?= =?UTF-8?q?=20System-Kalender=20-=20get=5Fgoogle=5Fevents():=20try/except?= =?UTF-8?q?=20ist=20jetzt=20pro=20Kalender,=20nicht=20global?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/google_router.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py index 0a1c443..dbcf9c8 100644 --- a/backend/routers/google_router.py +++ b/backend/routers/google_router.py @@ -32,6 +32,11 @@ SKIP_GOOGLE_CALENDAR_IDS = { } +def _is_system_calendar(cal_id: str) -> bool: + """Return True for virtual/system calendars that should be hidden.""" + return cal_id in SKIP_GOOGLE_CALENDAR_IDS or "weeknum" in cal_id.lower() + + def _google_configured() -> bool: return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) @@ -139,7 +144,7 @@ def _account_dict(a: models.GoogleAccount) -> dict: "sidebar_hidden": bool(c.sidebar_hidden), } for c in a.calendars - if c.cal_id not in SKIP_GOOGLE_CALENDAR_IDS + if not _is_system_calendar(c.cal_id) ], } @@ -149,12 +154,16 @@ def _sync_google_calendars(account: models.GoogleAccount, db: Session): try: token = _refresh_access_token(account, db) cal_list = _google_api(token, "/users/me/calendarList") - existing = {c.cal_id: c for c in account.calendars} + # Remove any previously stored system calendars (e.g. locale-specific weeknum variants) + for c in list(account.calendars): + if _is_system_calendar(c.cal_id): + db.delete(c) + existing = {c.cal_id: c for c in account.calendars if not _is_system_calendar(c.cal_id)} for cal in cal_list.get("items", []): if cal.get("deleted"): continue cal_id = cal["id"] - if cal_id in SKIP_GOOGLE_CALENDAR_IDS: + if _is_system_calendar(cal_id): continue if cal_id not in existing: db.add(models.GoogleCalendar( @@ -378,12 +387,12 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: raise all_events = [] - try: - for gcal in account.calendars: - if not gcal.enabled: - continue - if gcal.cal_id in SKIP_GOOGLE_CALENDAR_IDS: - continue + for gcal in account.calendars: + if not gcal.enabled: + continue + if _is_system_calendar(gcal.cal_id): + continue + try: events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={ "timeMin": start_dt.isoformat(), "timeMax": end_dt.isoformat(), @@ -394,8 +403,8 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: if ev.get("status") == "cancelled": continue all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4")) - except Exception as exc: - logger.error("Error fetching Google Calendar for %s: %s", account.email, exc) + except Exception as exc: + logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc) return all_events -- 2.47.3 From 15c540bd25d020d45fde20cab66e15275ec3af70 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 8 Apr 2026 21:59:41 +0200 Subject: [PATCH 023/114] =?UTF-8?q?fix:=20Kalender-Toggle=20sofort=20wirks?= =?UTF-8?q?am=20+=20Tint=20f=C3=BCr=20mehrt=C3=A4gige=20Ganztags-Events=20?= =?UTF-8?q?-=20fetchAndRender(true)=20beim=20Ein-/Ausblenden=20eines=20Kal?= =?UTF-8?q?enders=20erzwingt=20=20=20einen=20Neu-Abruf=20statt=20Cache-Tre?= =?UTF-8?q?ffer,=20damit=20die=20=C3=84nderung=20sofort=20sichtbar=20ist?= =?UTF-8?q?=20-=20Tint-Berechnung=20in=20der=20Wochenansicht=20ber=C3=BCck?= =?UTF-8?q?sichtigt=20jetzt=20auch=20=20=20mehrt=C3=A4gige=20Ganztags-Even?= =?UTF-8?q?ts=20(z.B.=20Urlaub),=20nicht=20nur=20mehrt=C3=A4gige=20=20=20T?= =?UTF-8?q?ermin-Events=20=E2=80=94=20exclusive=20Enddaten=20werden=20dabe?= =?UTF-8?q?i=20korrekt=20normalisiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 2 +- frontend/js/views/week.js | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 631d012..6a8c6fb 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -486,7 +486,7 @@ function renderCalendarList() { if (cal) cal.enabled = cb.checked; } } - fetchAndRender(); + fetchAndRender(true); }); }); diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 98de4c4..087ed6d 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -20,6 +20,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const timedEvs = events.filter(ev => !ev.allDay); // Multi-day timed events: timed but spanning more than one calendar day const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end))); + // Multi-day all-day events (exclusive end → subtract 1 day before comparing) + const multiDayAllDayEvs = allDayEvs.filter(ev => { + const s = new Date(ev.start); + const e = new Date(ev.end); + if (e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive + return !isSameDay(s, e); + }); + // All events that should generate a column background tint + const tintEvs = [...multiDayTimedEvs, ...multiDayAllDayEvs]; // Returns true if event overlaps any part of the given day function spansDay(ev, day) { @@ -121,8 +130,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`; }).join(''); - // Background tint for days covered by multi-day timed events - const tintHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { + // Background tint for days covered by multi-day events (timed or all-day) + const tintHtml = tintEvs.filter(ev => spansDay(ev, day)).map(ev => { const color = ev.color || ev.calendarColor || '#4285f4'; return `
`; }).join(''); -- 2.47.3 From a362ab21ae3959a08a91b48fe9f7ec463c32cea3 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 8 Apr 2026 22:14:08 +0200 Subject: [PATCH 024/114] =?UTF-8?q?perf/fix:=20Kalender-Toggle=20ohne=20La?= =?UTF-8?q?descreen=20+=20Mehrfach-Tint=20als=20Verlauf=20Ausblenden:=20Ev?= =?UTF-8?q?ents=20werden=20sofort=20client-seitig=20aus=20dem=20Cache=20ge?= =?UTF-8?q?filtert=20(calendar=5Fid-Match),=20kein=20Netzwerkaufruf=20f?= =?UTF-8?q?=C3=BCr=20die=20Ansicht=20n=C3=B6tig.=20Einblenden:=20fetchAndR?= =?UTF-8?q?ender(force,=20silent=3Dtrue)=20=C3=BCberspringt=20showLoading(?= =?UTF-8?q?),=20die=20aktuelle=20Ansicht=20bleibt=20sichtbar=20und=20wird?= =?UTF-8?q?=20nach=20dem=20Fetch=20aktualisiert.=20Mehrere=20mehrt=C3=A4gi?= =?UTF-8?q?ge=20Events=20am=20selben=20Tag=20erzeugen=20jetzt=20einen=20ve?= =?UTF-8?q?rtikalen=20Farbverlauf=20(linear-gradient)=20statt=20gestapelte?= =?UTF-8?q?r=20Ebenen,=20bei=20denen=20nur=20die=20letzte=20Farbe=20sichtb?= =?UTF-8?q?ar=20war.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/calendar.js | 23 ++++++++++++++++++++--- frontend/js/views/week.js | 22 ++++++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 6a8c6fb..5e924d1 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -141,7 +141,7 @@ function prefetchIfNeeded(viewStart, viewEnd) { } // ── Data fetching ───────────────────────────────────────── -async function fetchAndRender(force = false) { +async function fetchAndRender(force = false, silent = false) { const { start, end } = getViewRange(); // Cache hit: requested range is fully within what we already have @@ -159,7 +159,7 @@ async function fetchAndRender(force = false) { const fetchStart = new Date(start.getTime() - CACHE_BUF); const fetchEnd = new Date(end.getTime() + CACHE_BUF); - showLoading(); + if (!silent) showLoading(); try { const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`); const events = resp.events || resp; @@ -460,6 +460,8 @@ function renderCalendarList() { container.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', async () => { const source = cb.dataset.source; + let cacheCalId = null; // calendar_id value used in cached events + if (source === 'caldav') { const calId = parseInt(cb.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked }); @@ -468,16 +470,19 @@ function renderCalendarList() { if (cal.id === calId) cal.enabled = cb.checked; } } + cacheCalId = calId; // numeric integer in cached events } else if (source === 'local') { const calId = parseInt(cb.dataset.calId); await api.put(`/local/calendars/${calId}`, { enabled: cb.checked }); const cal = state.localCalendars.find(c => c.id === calId); if (cal) cal.enabled = cb.checked; + cacheCalId = `local-${calId}`; } else if (source === 'ical') { const subId = parseInt(cb.dataset.subId); await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked }); const sub = state.icalSubscriptions.find(s => s.id === subId); if (sub) sub.enabled = cb.checked; + cacheCalId = `ical-${subId}`; } else if (source === 'google') { const calId = parseInt(cb.dataset.calId); await api.put(`/google/calendars/${calId}`, { enabled: cb.checked }); @@ -485,8 +490,20 @@ function renderCalendarList() { const cal = acc.calendars.find(c => c.id === calId); if (cal) cal.enabled = cb.checked; } + cacheCalId = `google-${calId}`; + } + + if (!cb.checked && cacheCalId !== null) { + // Hiding: filter from cache instantly, no network call needed + eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId); + state.events = eventCache.events; + renderView(); + updateTitle(); + renderMiniCal(); + } else { + // Showing: refetch silently — view stays visible, updates when done + fetchAndRender(true, true); } - fetchAndRender(true); }); }); diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 087ed6d..d16efe4 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -131,10 +131,24 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC }).join(''); // Background tint for days covered by multi-day events (timed or all-day) - const tintHtml = tintEvs.filter(ev => spansDay(ev, day)).map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - return `
`; - }).join(''); + const dayTintEvs = tintEvs.filter(ev => spansDay(ev, day)); + const tintHtml = (() => { + if (!dayTintEvs.length) return ''; + const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4'); + let bg; + if (colors.length === 1) { + bg = colors[0] + '26'; + } else { + // Vertical gradient bands for multiple overlapping multi-day events + const stops = colors.flatMap((c, i) => { + const p1 = ((i / colors.length) * 100).toFixed(1); + const p2 = (((i + 1) / colors.length) * 100).toFixed(1); + return [`${c}26 ${p1}%`, `${c}26 ${p2}%`]; + }).join(','); + bg = `linear-gradient(to bottom,${stops})`; + } + return `
`; + })(); return `
${tintHtml} -- 2.47.3 From 7f123de1485ca48ca8f8a7dc84b13e12f7d040eb Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 8 Apr 2026 22:24:05 +0200 Subject: [PATCH 025/114] =?UTF-8?q?fix:=20Tint=20f=C3=BCr=20mehrt=C3=A4gig?= =?UTF-8?q?e=20Ganztags-Events=20korrekt=20via=20alldayLayout=20Der=20bish?= =?UTF-8?q?erige=20multiDayAllDayEvs-Filter=20hatte=20einen=20Timezone-Feh?= =?UTF-8?q?ler=20bei=20der=20Datumsberechnung=20(UTC-Parsing=20vs.=20lokal?= =?UTF-8?q?e=20Zeit=20in=20UTC+2).=20Neue=20L=C3=B6sung:=20das=20bereits?= =?UTF-8?q?=20korrekt=20arbeitende=20alldayLayout=20wird=20direkt=20als=20?= =?UTF-8?q?Quelle=20verwendet.=20Items=20mit=20colEnd=20>=20colStart=20sin?= =?UTF-8?q?d=20mehrt=C3=A4gig=20=E2=80=94=20die=20Spaltenindizes=20aus=20d?= =?UTF-8?q?em=20Layout=20ergeben=20den=20Tint-Bereich=20exakt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/views/week.js | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index d16efe4..bc440bc 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -20,15 +20,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const timedEvs = events.filter(ev => !ev.allDay); // Multi-day timed events: timed but spanning more than one calendar day const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end))); - // Multi-day all-day events (exclusive end → subtract 1 day before comparing) - const multiDayAllDayEvs = allDayEvs.filter(ev => { - const s = new Date(ev.start); - const e = new Date(ev.end); - if (e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive - return !isSameDay(s, e); - }); - // All events that should generate a column background tint - const tintEvs = [...multiDayTimedEvs, ...multiDayAllDayEvs]; // Returns true if event overlaps any part of the given day function spansDay(ev, day) { @@ -58,6 +49,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const ALLDAY_LANE_H = 22; const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs]; const alldayLayout = layoutWeekAllDay(allDayAndMulti, days); + // Items that span more than one column → used for column background tint + const multiDayLayoutItems = alldayLayout.filter(item => item.colEnd > item.colStart); const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1; const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6; @@ -95,7 +88,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC ).join(''); // ── Day columns ─────────────────────────────────────── - const dayCols = days.map(day => { + const dayCols = days.map((day, dayIdx) => { const key = dayKey(day); const dayEvs = timedEvs.filter(ev => { const s = new Date(ev.start); @@ -130,8 +123,10 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`; }).join(''); - // Background tint for days covered by multi-day events (timed or all-day) - const dayTintEvs = tintEvs.filter(ev => spansDay(ev, day)); + // Background tint: reuse alldayLayout (proven correct) — colEnd > colStart = multi-day + const dayTintEvs = multiDayLayoutItems + .filter(item => dayIdx >= item.colStart && dayIdx <= item.colEnd) + .map(item => item.ev); const tintHtml = (() => { if (!dayTintEvs.length) return ''; const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4'); -- 2.47.3 From f9f305b213f47a5f84e42d0bce16e676fdd0185b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Apr 2026 08:46:43 +0200 Subject: [PATCH 026/114] =?UTF-8?q?feat:=20Home=20Assistant=20Kalender-Int?= =?UTF-8?q?egration=20+=20Bugfix=20ausgeblendete=20Kalender=20-=20Neue=20I?= =?UTF-8?q?ntegration:=20Home=20Assistant=20als=20Kalenderquelle=20via=20R?= =?UTF-8?q?EST-API=20=20=20(GET=20/api/calendars=20+=20GET=20/api/calendar?= =?UTF-8?q?s/{entity=5Fid})=20-=20Authentifizierung=20per=20Long-Lived=20A?= =?UTF-8?q?ccess=20Token=20-=20Neues=20Modal=20zum=20Verbinden=20(Name,=20?= =?UTF-8?q?URL,=20Token)=20mit=20Fehlerbehandlung=20-=20Kalender=20einzeln?= =?UTF-8?q?=20aktivierbar/deaktivierbar,=20Farbe=20=C3=A4nderbar=20-=20Aus?= =?UTF-8?q?geblendete=20HA-Kalender=20in=20Einstellungen=20wiederherstellb?= =?UTF-8?q?ar=20-=20Sync-=20und=20Trennen-Buttons=20in=20den=20Einstellung?= =?UTF-8?q?en=20-=20Bugfix:=20CalDAV-=20und=20Google-Kalender=20mit=20side?= =?UTF-8?q?bar=5Fhidden=3Dtrue=20=20=20liefern=20nun=20keine=20Events=20me?= =?UTF-8?q?hr=20im=20Kalender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 3 +- backend/models.py | 32 +++ backend/routers/caldav_router.py | 15 +- backend/routers/google_router.py | 2 +- backend/routers/homeassistant_router.py | 268 ++++++++++++++++++++++++ frontend/index.html | 35 ++++ frontend/js/calendar.js | 157 +++++++++++++- 7 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 backend/routers/homeassistant_router.py diff --git a/backend/main.py b/backend/main.py index 534665d..ef673d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from sqlalchemy import text sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -76,6 +76,7 @@ app.include_router(profile_router.router, prefix="/api/profile", tags=["profile" app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) +app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") diff --git a/backend/models.py b/backend/models.py index 38cf548..4f85b31 100644 --- a/backend/models.py +++ b/backend/models.py @@ -30,6 +30,9 @@ class User(Base): google_accounts = relationship( "GoogleAccount", back_populates="user", cascade="all, delete-orphan" ) + homeassistant_accounts = relationship( + "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" + ) class CalDAVAccount(Base): @@ -176,3 +179,32 @@ class GoogleCalendar(Base): sidebar_hidden = Column(Boolean, default=False) account = relationship("GoogleAccount", back_populates="calendars") + + +class HomeAssistantAccount(Base): + __tablename__ = "homeassistant_accounts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + token = Column(Text, nullable=False) + + user = relationship("User", back_populates="homeassistant_accounts") + calendars = relationship( + "HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan" + ) + + +class HomeAssistantCalendar(Base): + __tablename__ = "homeassistant_calendars" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False) + entity_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + color = Column(String(7), nullable=True) + enabled = Column(Boolean, default=True) + sidebar_hidden = Column(Boolean, default=False) + + account = relationship("HomeAssistantAccount", back_populates="calendars") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 1aa38e4..d555487 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -266,7 +266,7 @@ def get_events( for account in accounts: for calendar in account.calendars: - if not calendar.enabled: + if not calendar.enabled or calendar.sidebar_hidden: continue try: events = caldav_client.fetch_events( @@ -355,6 +355,19 @@ def get_events( logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc) google_errors.append({"email": g_acc.email}) + # ── Home Assistant events ───────────────────────────── + from routers.homeassistant_router import get_ha_events + ha_accounts = ( + db.query(models.HomeAssistantAccount) + .filter(models.HomeAssistantAccount.user_id == current_user.id) + .all() + ) + for ha_acc in ha_accounts: + try: + all_events.extend(get_ha_events(ha_acc, start_dt, end_dt)) + except Exception as exc: + logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) + return {"events": all_events, "errors": google_errors} diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py index dbcf9c8..510090b 100644 --- a/backend/routers/google_router.py +++ b/backend/routers/google_router.py @@ -388,7 +388,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: all_events = [] for gcal in account.calendars: - if not gcal.enabled: + if not gcal.enabled or gcal.sidebar_hidden: continue if _is_system_calendar(gcal.cal_id): continue diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py new file mode 100644 index 0000000..3bffdf2 --- /dev/null +++ b/backend/routers/homeassistant_router.py @@ -0,0 +1,268 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +import requests as http_requests +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from database import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + +HA_DEFAULT_COLOR = "#03a9f4" + + +# ── HA API helpers ──────────────────────────────────────── + +def _ha_get_calendars(url: str, token: str) -> list: + try: + resp = http_requests.get( + f"{url.rstrip('/')}/api/calendars", + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + verify=False, + ) + resp.raise_for_status() + return resp.json() + except http_requests.exceptions.ConnectionError: + raise HTTPException(503, "Home Assistant nicht erreichbar") + except http_requests.exceptions.Timeout: + raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)") + except http_requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 401: + raise HTTPException(401, "Ungültiger Access Token") + raise HTTPException(502, f"Home Assistant Fehler: {e}") + + +def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list: + try: + resp = http_requests.get( + f"{url.rstrip('/')}/api/calendars/{entity_id}", + headers={"Authorization": f"Bearer {token}"}, + params={"start": start_dt.isoformat(), "end": end_dt.isoformat()}, + timeout=15, + verify=False, + ) + resp.raise_for_status() + return resp.json() + except http_requests.exceptions.ConnectionError: + raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}") + except http_requests.exceptions.Timeout: + raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}") + + +def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict: + start = ev.get("start", {}) + end = ev.get("end", {}) + all_day = "date" in start and "dateTime" not in start + return { + "id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}", + "url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}", + "title": ev.get("summary", "(Kein Titel)"), + "start": start.get("dateTime") or start.get("date", ""), + "end": end.get("dateTime") or end.get("date", ""), + "allDay": all_day, + "location": ev.get("location", ""), + "description": ev.get("description", ""), + "color": None, + "calendar_id": f"homeassistant-{cal_db_id}", + "calendar_name": cal_name, + "calendarColor": cal_color, + "source": "homeassistant", + } + + +def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list: + all_events = [] + for cal in account.calendars: + if not cal.enabled or cal.sidebar_hidden: + continue + try: + raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt) + color = cal.color or HA_DEFAULT_COLOR + for ev in raw: + all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) + except Exception as exc: + logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc) + return all_events + + +# ── Serialization ───────────────────────────────────────── + +def _account_dict(a: models.HomeAssistantAccount) -> dict: + return { + "id": a.id, + "name": a.name, + "url": a.url, + "calendars": [ + { + "id": c.id, + "name": c.name, + "entity_id": c.entity_id, + "color": c.color or HA_DEFAULT_COLOR, + "enabled": c.enabled, + "sidebar_hidden": bool(c.sidebar_hidden), + } + for c in a.calendars + ], + } + + +# ── Pydantic models ─────────────────────────────────────── + +class HAAccountCreate(BaseModel): + name: str + url: str + token: str + + +class HACalendarUpdate(BaseModel): + enabled: Optional[bool] = None + color: Optional[str] = None + name: Optional[str] = None + sidebar_hidden: Optional[bool] = None + + +# ── Endpoints ───────────────────────────────────────────── + +@router.get("/accounts") +def list_accounts( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + accounts = ( + db.query(models.HomeAssistantAccount) + .filter(models.HomeAssistantAccount.user_id == current_user.id) + .all() + ) + return [_account_dict(a) for a in accounts] + + +@router.post("/accounts") +def add_account( + data: HAAccountCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + remote_cals = _ha_get_calendars(data.url, data.token) + + account = models.HomeAssistantAccount( + user_id=current_user.id, + name=data.name, + url=data.url, + token=data.token, + ) + db.add(account) + db.flush() + + for cal in remote_cals: + entity_id = cal.get("entity_id", "") + if not entity_id: + continue + db.add(models.HomeAssistantCalendar( + account_id=account.id, + entity_id=entity_id, + name=cal.get("name") or entity_id, + color=None, + enabled=True, + )) + + db.commit() + db.refresh(account) + return _account_dict(account) + + +@router.delete("/accounts/{account_id}") +def delete_account( + account_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + acc = ( + db.query(models.HomeAssistantAccount) + .filter( + models.HomeAssistantAccount.id == account_id, + models.HomeAssistantAccount.user_id == current_user.id, + ) + .first() + ) + if not acc: + raise HTTPException(404, "Account not found") + db.delete(acc) + db.commit() + return {"ok": True} + + +@router.post("/accounts/{account_id}/sync") +def sync_account( + account_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + acc = ( + db.query(models.HomeAssistantAccount) + .filter( + models.HomeAssistantAccount.id == account_id, + models.HomeAssistantAccount.user_id == current_user.id, + ) + .first() + ) + if not acc: + raise HTTPException(404, "Account not found") + + remote_cals = _ha_get_calendars(acc.url, acc.token) + existing = {c.entity_id: c for c in acc.calendars} + + for cal in remote_cals: + entity_id = cal.get("entity_id", "") + if not entity_id: + continue + if entity_id not in existing: + db.add(models.HomeAssistantCalendar( + account_id=acc.id, + entity_id=entity_id, + name=cal.get("name") or entity_id, + color=None, + enabled=True, + )) + else: + existing[entity_id].name = cal.get("name") or entity_id + + db.commit() + db.refresh(acc) + return _account_dict(acc) + + +@router.put("/calendars/{calendar_id}") +def update_calendar( + calendar_id: int, + data: HACalendarUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.HomeAssistantCalendar) + .join(models.HomeAssistantAccount) + .filter( + models.HomeAssistantCalendar.id == calendar_id, + models.HomeAssistantAccount.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + if data.enabled is not None: + cal.enabled = data.enabled + if data.color is not None: + cal.color = data.color + if data.name is not None: + cal.name = data.name + if data.sidebar_hidden is not None: + cal.sidebar_hidden = data.sidebar_hidden + db.commit() + return {"ok": True} diff --git a/frontend/index.html b/frontend/index.html index 8b759f3..8497872 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -165,6 +165,7 @@ + @@ -399,6 +400,35 @@ + + + diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 5e924d1..b1fa8d5 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -32,6 +32,7 @@ let state = { localCalendars: [], icalSubscriptions: [], googleAccounts: [], + haAccounts: [], settings: {}, dimPast: false, editingEvent: null, // null = new event @@ -40,12 +41,13 @@ let state = { // ── Public init ─────────────────────────────────────────── export async function initCalendar() { - const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([ + const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([ api.get('/settings/'), api.get('/caldav/accounts'), api.get('/local/calendars').catch(() => []), api.get('/ical/subscriptions').catch(() => []), api.get('/google/accounts').catch(() => []), + api.get('/homeassistant/accounts').catch(() => []), ]); state.settings = settings; @@ -53,6 +55,7 @@ export async function initCalendar() { 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'; @@ -69,6 +72,7 @@ export async function initCalendar() { bindAccountModal(); bindLocalCalModal(); bindICalSubModal(); + bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); } @@ -449,6 +453,25 @@ function renderCalendarList() { }).join(''); } + // ── Home Assistant accounts ─────────────────────────── + if (state.haAccounts.length) { + html += state.haAccounts.map(acc => { + const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden); + if (!visibleCals.length) return ``; + return `` + + visibleCals.map(cal => + `
+ +
+ ${escHtml(cal.name)} + +
` + ).join(''); + }).join(''); + } + if (!html) { container.innerHTML = `
${t('error_no_calendars')}
`; return; @@ -491,6 +514,14 @@ function renderCalendarList() { if (cal) cal.enabled = cb.checked; } cacheCalId = `google-${calId}`; + } else if (source === 'homeassistant') { + const calId = parseInt(cb.dataset.calId); + await api.put(`/homeassistant/calendars/${calId}`, { enabled: cb.checked }); + for (const acc of state.haAccounts) { + const cal = acc.calendars.find(c => c.id === calId); + if (cal) cal.enabled = cb.checked; + } + cacheCalId = `homeassistant-${calId}`; } if (!cb.checked && cacheCalId !== null) { @@ -545,6 +576,19 @@ function renderCalendarList() { if (gcal) gcal.color = picked; applyCalendarColor('google', calId, picked); } + } else if (source === 'homeassistant') { + const calId = parseInt(dot.dataset.calId); + let hacal = null; + for (const acc of state.haAccounts) { + hacal = acc.calendars.find(c => c.id === calId); + if (hacal) break; + } + const picked = await openColorPicker(dot, hacal?.color || '#03a9f4'); + if (picked) { + await api.put(`/homeassistant/calendars/${calId}`, { color: picked }); + if (hacal) hacal.color = picked; + applyCalendarColor('homeassistant', calId, picked); + } } }); }); @@ -627,6 +671,14 @@ function renderCalendarList() { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } + } else if (source === 'homeassistant') { + const calId = parseInt(btn.dataset.calId); + await api.put(`/homeassistant/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); + for (const acc of state.haAccounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } + } + } } renderCalendarList(); fetchAndRender(); @@ -727,6 +779,10 @@ function bindSidebar() { dropdown.classList.add('hidden'); openICalSubModal(); }; + dropdown.querySelector('[data-action="homeassistant"]').onclick = () => { + dropdown.classList.add('hidden'); + openHAAccountModal(); + }; dropdown.querySelector('[data-action="google"]').onclick = async () => { dropdown.classList.add('hidden'); try { @@ -1193,6 +1249,48 @@ function bindLocalCalModal() { }; } +// ── Home Assistant Account Modal ───────────────────────── +function openHAAccountModal() { + document.getElementById('ha-account-name').value = ''; + document.getElementById('ha-account-url').value = ''; + document.getElementById('ha-account-token').value = ''; + document.getElementById('ha-account-error').classList.add('hidden'); + openModal('modal-ha-account'); +} + +function bindHAAccountModal() { + document.getElementById('ha-account-save').onclick = async () => { + const name = document.getElementById('ha-account-name').value.trim(); + const url = document.getElementById('ha-account-url').value.trim(); + const token = document.getElementById('ha-account-token').value.trim(); + const errEl = document.getElementById('ha-account-error'); + if (!name || !url || !token) { + errEl.textContent = 'Bitte alle Felder ausfüllen'; + errEl.classList.remove('hidden'); + return; + } + errEl.classList.add('hidden'); + + const saveBtn = document.getElementById('ha-account-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Verbinde…'; + try { + const account = await api.post('/homeassistant/accounts', { name, url, token }); + state.haAccounts.push(account); + renderCalendarList(); + closeModal('modal-ha-account'); + showToast(`Home Assistant "${name}" verbunden`); + fetchAndRender(true); + } catch (e) { + errEl.textContent = e.message || 'Home Assistant nicht erreichbar'; + errEl.classList.remove('hidden'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Verbinden'; + } + }; +} + // ── iCal Subscription Modal ────────────────────────────── function openICalSubModal() { document.getElementById('ical-sub-name').value = ''; @@ -1437,6 +1535,51 @@ function renderAllAccounts() { // Google accounts section — delegate to existing function renderGoogleAccounts(); + + // Home Assistant accounts section + const haList = document.getElementById('accounts-ha-list'); + if (haList) { + if (!state.haAccounts.length) { + haList.innerHTML = 'Keine HA-Konten'; + } else { + haList.innerHTML = state.haAccounts.map(acc => + `
+
+ ${escHtml(acc.name)} + ${escHtml(acc.url || '')} +
+
+ + +
+
` + ).join(''); + haList.querySelectorAll('[data-ha-sync]').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; btn.textContent = '…'; + try { + const updated = await api.post(`/homeassistant/accounts/${btn.dataset.haSync}/sync`); + const idx = state.haAccounts.findIndex(a => a.id === parseInt(btn.dataset.haSync)); + if (idx !== -1) state.haAccounts[idx] = updated; + renderAllAccounts(); renderCalendarList(); fetchAndRender(true); + showToast('Home Assistant synchronisiert'); + } catch (e) { showToast(e.message, true); } + finally { btn.disabled = false; btn.textContent = t('sync'); } + }); + }); + haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Home Assistant Konto wirklich trennen?')) return; + try { + await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`); + state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect)); + renderAllAccounts(); renderCalendarList(); fetchAndRender(true); + showToast('Home Assistant getrennt'); + } catch (e) { showToast(e.message, true); } + }); + }); + } + } } function renderHiddenCalendars() { @@ -1452,6 +1595,11 @@ function renderHiddenCalendars() { if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' }); } } + for (const acc of state.haAccounts) { + for (const cal of acc.calendars) { + if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'homeassistant' }); + } + } if (!hidden.length) { list.innerHTML = `${t('settings_no_hidden_cals')}`; return; @@ -1473,6 +1621,13 @@ function renderHiddenCalendars() { if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; } } } + } else if (source === 'homeassistant') { + await api.put(`/homeassistant/calendars/${calId}`, { enabled: true, sidebar_hidden: false }); + for (const acc of state.haAccounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; } + } + } } else { await api.put(`/caldav/calendars/${calId}`, { enabled: true, sidebar_hidden: false }); for (const acc of state.accounts) { -- 2.47.3 From e70433a61c6a95f6ff7644770d0c7f9595a96681 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Apr 2026 09:03:40 +0200 Subject: [PATCH 027/114] fix: Ausgeblendete Kalender sofort aus Event-Cache entfernen Beim Ausblenden eines Kalenders (sidebar_hidden) wurde fetchAndRender() ohne force=true aufgerufen, wodurch der Cache nie invalidiert wurde und die Events weiterhin angezeigt wurden. Jetzt wird der Cache sofort gefiltert (wie beim Checkbox-Deaktivieren), ohne einen neuen Netzwerkaufruf. --- frontend/js/calendar.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index b1fa8d5..b33b54a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -645,6 +645,7 @@ function renderCalendarList() { btn.addEventListener('click', async e => { e.stopPropagation(); const source = btn.dataset.source; + let cacheCalId = null; if (source === 'caldav') { const calId = parseInt(btn.dataset.calId); await api.put(`/caldav/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); @@ -653,16 +654,19 @@ function renderCalendarList() { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } + cacheCalId = calId; } else if (source === 'local') { if (!confirm(t('confirm_delete_local_cal'))) return; const calId = parseInt(btn.dataset.calId); await api.delete(`/local/calendars/${calId}`); state.localCalendars = state.localCalendars.filter(c => c.id !== calId); + cacheCalId = `local-${calId}`; } else if (source === 'ical') { if (!confirm(t('confirm_remove_ical'))) return; const subId = parseInt(btn.dataset.subId); await api.delete(`/ical/subscriptions/${subId}`); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId); + cacheCalId = `ical-${subId}`; } else if (source === 'google') { const calId = parseInt(btn.dataset.calId); await api.put(`/google/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); @@ -671,6 +675,7 @@ function renderCalendarList() { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } + cacheCalId = `google-${calId}`; } else if (source === 'homeassistant') { const calId = parseInt(btn.dataset.calId); await api.put(`/homeassistant/calendars/${calId}`, { enabled: false, sidebar_hidden: true }); @@ -679,9 +684,16 @@ function renderCalendarList() { if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; } } } + cacheCalId = `homeassistant-${calId}`; + } + if (cacheCalId !== null) { + eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId); + state.events = eventCache.events; } renderCalendarList(); - fetchAndRender(); + renderView(); + updateTitle(); + renderMiniCal(); }); }); } -- 2.47.3 From e172386850815373552b7c7f80b9865ad39d7158 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Apr 2026 09:18:03 +0200 Subject: [PATCH 028/114] =?UTF-8?q?fix:=20Color-Picker-Cursor=20erreicht?= =?UTF-8?q?=20jetzt=20den=20rechten=20und=20unteren=20Rand=20updateUI=20ve?= =?UTF-8?q?rwendete=20svCanvas.width=20(HTML-Attribut,=20220px)=20statt=20?= =?UTF-8?q?der=20tats=C3=A4chlich=20gerenderten=20Breite.=20Wenn=20CSS=20d?= =?UTF-8?q?en=20Canvas=20gr=C3=B6=C3=9Fer=20rendert,=20stoppte=20der=20Cur?= =?UTF-8?q?sor=20vor=20dem=20rechten=20Rand.=20Jetzt=20wird=20getBoundingC?= =?UTF-8?q?lientRect()=20verwendet,=20konsistent=20mit=20handleSV.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/js/color-picker.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js index b41418c..e4ec37b 100644 --- a/frontend/js/color-picker.js +++ b/frontend/js/color-picker.js @@ -128,11 +128,14 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') { function updateUI() { const [r, g, b] = hsvToRgb(h, s, v); const hex = rgbToHex(r, g, b); + // Use rendered size so cursor matches the visible palette area + const svRect = svCanvas.getBoundingClientRect(); + const hueRect = hueCanvas.getBoundingClientRect(); // SV cursor position - svCursor.style.left = (s * svCanvas.width) + 'px'; - svCursor.style.top = ((1 - v) * svCanvas.height) + 'px'; + svCursor.style.left = (s * svRect.width) + 'px'; + svCursor.style.top = ((1 - v) * svRect.height) + 'px'; // Hue thumb position - hueThumb.style.left = (h / 360 * hueCanvas.width) + 'px'; + hueThumb.style.left = (h / 360 * hueRect.width) + 'px'; // Preview + hex preview.style.background = hex; hexInput.value = hex.toUpperCase(); -- 2.47.3 From 4ffcd2628e324f11584df6581008c86345e9df9b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Apr 2026 09:22:42 +0200 Subject: [PATCH 029/114] fix: Color-Picker-Cursor korrekt auf Palette ausgerichtet Der Cursor war relativ zum .gcp-Container positioniert, aber ohne den Offset des Canvas innerhalb des Containers (Padding). Jetzt wird die Canvas-Position via getBoundingClientRect() eingerechnet, sodass der Cursor exakt auf der Farbpalette bleibt. --- frontend/js/color-picker.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js index e4ec37b..ce7f88f 100644 --- a/frontend/js/color-picker.js +++ b/frontend/js/color-picker.js @@ -128,14 +128,16 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') { function updateUI() { const [r, g, b] = hsvToRgb(h, s, v); const hex = rgbToHex(r, g, b); - // Use rendered size so cursor matches the visible palette area + // Use rendered rects to position cursor relative to the picker container const svRect = svCanvas.getBoundingClientRect(); + const pickerRect = picker.getBoundingClientRect(); const hueRect = hueCanvas.getBoundingClientRect(); - // SV cursor position - svCursor.style.left = (s * svRect.width) + 'px'; - svCursor.style.top = ((1 - v) * svRect.height) + 'px'; - // Hue thumb position - hueThumb.style.left = (h / 360 * hueRect.width) + 'px'; + const hueTrackRect = hueTrack.getBoundingClientRect(); + // SV cursor: offset canvas position within picker + position within canvas + svCursor.style.left = (svRect.left - pickerRect.left + s * svRect.width) + 'px'; + svCursor.style.top = (svRect.top - pickerRect.top + (1 - v) * svRect.height) + 'px'; + // Hue thumb: offset canvas position within track + position within canvas + hueThumb.style.left = (hueRect.left - hueTrackRect.left + (h / 360) * hueRect.width) + 'px'; // Preview + hex preview.style.background = hex; hexInput.value = hex.toUpperCase(); -- 2.47.3 From 7c55a6043d59bdd4ea5de4f67e7167d8cbf5082a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 21 Apr 2026 11:02:32 +0200 Subject: [PATCH 030/114] =?UTF-8?q?feat:=20Home=20Assistant=20Benutzername?= =?UTF-8?q?/Passwort-Authentifizierung=20Erg=C3=A4nzt=20die=20HA-Integrati?= =?UTF-8?q?on=20um=20Password-Grant=20OAuth2:=20Nutzer=20k=C3=B6nnen=20sic?= =?UTF-8?q?h=20nun=20wahlweise=20mit=20einem=20Long-Lived=20Token=20oder?= =?UTF-8?q?=20mit=20Benutzername/Passwort=20anmelden.=20Access=20Tokens=20?= =?UTF-8?q?werden=20automatisch=20per=20Refresh-Token=20erneuert.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 15 ++++ backend/models.py | 3 + backend/routers/caldav_router.py | 2 +- backend/routers/homeassistant_router.py | 103 ++++++++++++++++++++++-- frontend/index.html | 21 +++++ frontend/js/calendar.js | 45 ++++++++++- 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/backend/main.py b/backend/main.py index ef673d0..ca24b29 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,6 +63,21 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'")) + conn.commit() + except Exception: + pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT")) + conn.commit() + except Exception: + pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME")) + conn.commit() + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 4f85b31..4772ef0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -189,6 +189,9 @@ class HomeAssistantAccount(Base): name = Column(String(100), nullable=False) url = Column(String(500), nullable=False) token = Column(Text, nullable=False) + auth_method = Column(String(20), default="token") + refresh_token = Column(Text, nullable=True) + token_expiry = Column(DateTime, nullable=True) user = relationship("User", back_populates="homeassistant_accounts") calendars = relationship( diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index d555487..c5f3167 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -364,7 +364,7 @@ def get_events( ) for ha_acc in ha_accounts: try: - all_events.extend(get_ha_events(ha_acc, start_dt, end_dt)) + all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db)) except Exception as exc: logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py index 3bffdf2..28a3fee 100644 --- a/backend/routers/homeassistant_router.py +++ b/backend/routers/homeassistant_router.py @@ -15,6 +15,70 @@ logger = logging.getLogger(__name__) router = APIRouter() HA_DEFAULT_COLOR = "#03a9f4" +HA_CLIENT_ID = "http://localhost/" + + +# ── Auth helpers ────────────────────────────────────────── + +def _ha_login(url: str, username: str, password: str) -> tuple: + """Password grant → (access_token, refresh_token, expires_in)""" + try: + resp = http_requests.post( + f"{url.rstrip('/')}/auth/token", + data={ + "grant_type": "password", + "username": username, + "password": password, + "client_id": HA_CLIENT_ID, + }, + timeout=10, + verify=False, + ) + except http_requests.exceptions.ConnectionError: + raise HTTPException(503, "Home Assistant nicht erreichbar") + except http_requests.exceptions.Timeout: + raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)") + if resp.status_code in (400, 401): + raise HTTPException(401, "Ungültige Anmeldedaten") + resp.raise_for_status() + data = resp.json() + return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800) + + +def _ha_refresh(url: str, refresh_token: str) -> tuple: + """Refresh grant → (access_token, expires_in)""" + resp = http_requests.post( + f"{url.rstrip('/')}/auth/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": HA_CLIENT_ID, + }, + timeout=10, + verify=False, + ) + resp.raise_for_status() + data = resp.json() + return data["access_token"], data.get("expires_in", 1800) + + +def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str: + """Return a valid access token, refreshing if necessary.""" + if account.auth_method != "password": + return account.token # Long-Lived Token läuft nicht ab + now = datetime.now(timezone.utc) + if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now: + return account.token + # Needs refresh + try: + access_token, expires_in = _ha_refresh(account.url, account.refresh_token) + except Exception as exc: + logger.error("HA token refresh failed for %s: %s", account.name, exc) + raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden") + account.token = access_token + account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) + db.commit() + return access_token # ── HA API helpers ──────────────────────────────────────── @@ -77,13 +141,18 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> } -def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list: +def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list: all_events = [] + try: + token = _get_valid_token(account, db) + except Exception as exc: + logger.error("HA token error for %s: %s", account.name, exc) + raise for cal in account.calendars: if not cal.enabled or cal.sidebar_hidden: continue try: - raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt) + raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt) color = cal.color or HA_DEFAULT_COLOR for ev in raw: all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) @@ -99,6 +168,7 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: "id": a.id, "name": a.name, "url": a.url, + "auth_method": a.auth_method or "token", "calendars": [ { "id": c.id, @@ -118,7 +188,9 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: class HAAccountCreate(BaseModel): name: str url: str - token: str + token: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None class HACalendarUpdate(BaseModel): @@ -149,13 +221,31 @@ def add_account( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - remote_cals = _ha_get_calendars(data.url, data.token) + now = datetime.now(timezone.utc) + + if data.username and data.password: + access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password) + auth_method = "password" + stored_refresh = refresh_tok + token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) + elif data.token: + access_token = data.token + auth_method = "token" + stored_refresh = None + token_expiry = None + else: + raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich") + + remote_cals = _ha_get_calendars(data.url, access_token) account = models.HomeAssistantAccount( user_id=current_user.id, name=data.name, url=data.url, - token=data.token, + token=access_token, + auth_method=auth_method, + refresh_token=stored_refresh, + token_expiry=token_expiry, ) db.add(account) db.flush() @@ -215,7 +305,8 @@ def sync_account( if not acc: raise HTTPException(404, "Account not found") - remote_cals = _ha_get_calendars(acc.url, acc.token) + token = _get_valid_token(acc, db) + remote_cals = _ha_get_calendars(acc.url, token) existing = {c.entity_id: c for c in acc.calendars} for cal in remote_cals: diff --git a/frontend/index.html b/frontend/index.html index 8497872..b9685bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -417,9 +417,30 @@
+ +
+ + +
+
+
+ -
- -
-
- diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 6920d14..bd7c403 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1,4 +1,4 @@ -import { api } from './api.js'; +import { api } from './api.js'; import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.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 { APP_VERSION } from './version.js'; // Fetch avatar image as blob URL (with auth header) function fetchAvatarBlob() { @@ -63,6 +64,11 @@ export async function initCalendar() { setLang(settings.language || 'de'); applyTheme(settings); updateViewButtons(); + document.querySelectorAll('.sidebar-copyright, .impressum-link').forEach(el => { + el.innerHTML = `© 2026 Scarriffleservices · ${APP_VERSION}`; + }); + const impVer = document.getElementById('impressum-version'); + if (impVer) impVer.textContent = `Calendarr ${APP_VERSION}`; renderCalendarList(); renderMiniCal(); await fetchAndRender(); diff --git a/frontend/js/version.js b/frontend/js/version.js new file mode 100644 index 0000000..c3d71fe --- /dev/null +++ b/frontend/js/version.js @@ -0,0 +1,2 @@ +// Increment APP_VERSION with every code change +export const APP_VERSION = 'v1'; -- 2.47.3 From e81bcfa26905c19095ba68a57e22254fc9bb383b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 24 Apr 2026 11:47:55 +0200 Subject: [PATCH 033/114] =?UTF-8?q?fix:=20Versionsanzeige=20direkt=20im=20?= =?UTF-8?q?HTML=20statt=20per=20JS=20Vorher=20wurde=20die=20Version=20erst?= =?UTF-8?q?=20in=20initCalendar()=20gesetzt=20=E2=80=93=20wenn=20JS=20vorh?= =?UTF-8?q?er=20fehlschlug,=20blieb=20der=20Text=20leer.=20Jetzt=20steht?= =?UTF-8?q?=20v1=20direkt=20im=20HTML=20(Titel,=20Login-Button,=20Sidebar-?= =?UTF-8?q?Button,=20Impressum-Modal).=20F=C3=BCr=20k=C3=BCnftige=20Releas?= =?UTF-8?q?es:=20v1=20=E2=86=92=20v2=20in=20index.html=20+=20version.js=20?= =?UTF-8?q?ersetzen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 9 +++++---- frontend/js/calendar.js | 6 ------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index fc89351..b327ee9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,8 @@ - Calendarr + + Calendarr v1 @@ -70,7 +71,7 @@
- + @@ -172,7 +173,7 @@
- + @@ -764,7 +765,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index bd7c403..925526b 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -7,7 +7,6 @@ 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 { APP_VERSION } from './version.js'; // Fetch avatar image as blob URL (with auth header) function fetchAvatarBlob() { @@ -64,11 +63,6 @@ export async function initCalendar() { setLang(settings.language || 'de'); applyTheme(settings); updateViewButtons(); - document.querySelectorAll('.sidebar-copyright, .impressum-link').forEach(el => { - el.innerHTML = `© 2026 Scarriffleservices · ${APP_VERSION}`; - }); - const impVer = document.getElementById('impressum-version'); - if (impVer) impVer.textContent = `Calendarr ${APP_VERSION}`; renderCalendarList(); renderMiniCal(); await fetchAndRender(); -- 2.47.3 From 9a59911156b78ed976a36bb6c2bab8e1859cff2a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 24 Apr 2026 12:57:38 +0200 Subject: [PATCH 034/114] =?UTF-8?q?feat(ha):=20OAuth=20Authorization-Code-?= =?UTF-8?q?Flow=20statt=20kaputtem=20Password-Grant=20Home=20Assistant=20u?= =?UTF-8?q?nterst=C3=BCtzt=20keinen=20Password-Grant=20=E2=80=94=20deshalb?= =?UTF-8?q?=20kam=20immer=20"Ung=C3=BCltige=20Anmeldedaten",=20egal=20was?= =?UTF-8?q?=20eingegeben=20wurde.=20Jetzt=20wird=20der=20Nutzer=20nach=20d?= =?UTF-8?q?emselben=20Muster=20wie=20bei=20Google=20zur=20HA-Login-Seite?= =?UTF-8?q?=20weitergeleitet,=20meldet=20sich=20dort=20an=20und=20kommt=20?= =?UTF-8?q?zur=C3=BCck=20zu=20Calendarr.=20=C3=84nderungen:=20-=20Neuer=20?= =?UTF-8?q?POST=20/api/homeassistant/auth-url=20und=20GET=20/callback=20En?= =?UTF-8?q?dpoint=20-=20Account=20speichert=20client=5Fid=20f=C3=BCr=20sp?= =?UTF-8?q?=C3=A4tere=20Token-Refreshes=20-=20Modal:=20"Benutzername/Passw?= =?UTF-8?q?ort"=20=E2=86=92=20"Mit=20Home=20Assistant=20anmelden"=20-=20Fr?= =?UTF-8?q?ontend=20behandelt=20=3Fha=5Fconnected=3D1=20/=20=3Fha=5Ferror?= =?UTF-8?q?=3D...=20nach=20R=C3=BCckkehr=20-=20Version=20v1=20=E2=86=92=20?= =?UTF-8?q?v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 5 + backend/models.py | 1 + backend/routers/homeassistant_router.py | 193 +++++++++++++++++------- frontend/index.html | 45 +++--- frontend/js/calendar.js | 103 +++++++++---- frontend/js/version.js | 2 +- 6 files changed, 235 insertions(+), 114 deletions(-) diff --git a/backend/main.py b/backend/main.py index ca24b29..bb1327a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -78,6 +78,11 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)")) + conn.commit() + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 4772ef0..36fc462 100644 --- a/backend/models.py +++ b/backend/models.py @@ -192,6 +192,7 @@ class HomeAssistantAccount(Base): auth_method = Column(String(20), default="token") refresh_token = Column(Text, nullable=True) token_expiry = Column(DateTime, nullable=True) + client_id = Column(String(500), nullable=True) user = relationship("User", back_populates="homeassistant_accounts") calendars = relationship( diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py index 77f4e05..e6ef38a 100644 --- a/backend/routers/homeassistant_router.py +++ b/backend/routers/homeassistant_router.py @@ -1,9 +1,13 @@ import logging +import secrets +import time from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlencode import requests as http_requests -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session @@ -15,44 +19,27 @@ logger = logging.getLogger(__name__) router = APIRouter() HA_DEFAULT_COLOR = "#03a9f4" -HA_CLIENT_ID = "http://localhost/" + +# In-memory store for pending OAuth states (short-lived, ~10 min TTL) +_pending_oauth: dict[str, dict] = {} + + +def _cleanup_pending(): + now = time.time() + for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]: + _pending_oauth.pop(k, None) # ── Auth helpers ────────────────────────────────────────── -def _ha_login(url: str, username: str, password: str) -> tuple: - """Password grant → (access_token, refresh_token, expires_in)""" - try: - resp = http_requests.post( - f"{url.rstrip('/')}/auth/token", - data={ - "grant_type": "password", - "username": username, - "password": password, - "client_id": HA_CLIENT_ID, - }, - timeout=10, - verify=False, - ) - except http_requests.exceptions.ConnectionError: - raise HTTPException(503, "Home Assistant nicht erreichbar") - except http_requests.exceptions.Timeout: - raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)") - if resp.status_code in (400, 401): - raise HTTPException(400, "Ungültige Anmeldedaten") - resp.raise_for_status() - data = resp.json() - return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800) - - -def _ha_refresh(url: str, refresh_token: str) -> tuple: +def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple: """Refresh grant → (access_token, expires_in)""" resp = http_requests.post( f"{url.rstrip('/')}/auth/token", data={ "grant_type": "refresh_token", "refresh_token": refresh_token, - "client_id": HA_CLIENT_ID, + "client_id": client_id, }, timeout=10, verify=False, @@ -64,14 +51,16 @@ def _ha_refresh(url: str, refresh_token: str) -> tuple: def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str: """Return a valid access token, refreshing if necessary.""" - if account.auth_method != "password": + if account.auth_method != "oauth": return account.token # Long-Lived Token läuft nicht ab now = datetime.now(timezone.utc) if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now: return account.token # Needs refresh try: - access_token, expires_in = _ha_refresh(account.url, account.refresh_token) + access_token, expires_in = _ha_refresh( + account.url, account.refresh_token, account.client_id or "" + ) except Exception as exc: logger.error("HA token refresh failed for %s: %s", account.name, exc) raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden") @@ -188,9 +177,14 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: class HAAccountCreate(BaseModel): name: str url: str - token: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None + token: str + + +class HAOAuthStart(BaseModel): + name: str + url: str + client_id: str + redirect_uri: str class HACalendarUpdate(BaseModel): @@ -221,31 +215,17 @@ def add_account( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - now = datetime.now(timezone.utc) - - if data.username and data.password: - access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password) - auth_method = "password" - stored_refresh = refresh_tok - token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) - elif data.token: - access_token = data.token - auth_method = "token" - stored_refresh = None - token_expiry = None - else: - raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich") - - remote_cals = _ha_get_calendars(data.url, access_token) + """Create a HA account from a Long-Lived Access Token.""" + remote_cals = _ha_get_calendars(data.url, data.token) account = models.HomeAssistantAccount( user_id=current_user.id, name=data.name, url=data.url, - token=access_token, - auth_method=auth_method, - refresh_token=stored_refresh, - token_expiry=token_expiry, + token=data.token, + auth_method="token", + refresh_token=None, + token_expiry=None, ) db.add(account) db.flush() @@ -267,6 +247,111 @@ def add_account( return _account_dict(account) +@router.post("/auth-url") +def oauth_start( + data: HAOAuthStart, + current_user: models.User = Depends(get_current_user), +): + """Start the OAuth flow: store pending state, return HA authorization URL.""" + _cleanup_pending() + state_token = secrets.token_urlsafe(32) + _pending_oauth[state_token] = { + "user_id": current_user.id, + "ha_url": data.url.rstrip('/'), + "name": data.name, + "client_id": data.client_id, + "redirect_uri": data.redirect_uri, + "expires": time.time() + 600, + } + params = { + "client_id": data.client_id, + "redirect_uri": data.redirect_uri, + "state": state_token, + "response_type": "code", + } + return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"} + + +@router.get("/callback") +def oauth_callback( + request: Request, + code: str = Query(""), + state: str = Query(""), + error: str = Query(""), + db: Session = Depends(get_db), +): + """Callback from Home Assistant after user authorization.""" + if error or not code: + return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302) + + pending = _pending_oauth.pop(state, None) + if not pending or pending["expires"] < time.time(): + return RedirectResponse(url="/?ha_error=state_expired", status_code=302) + + ha_url = pending["ha_url"] + client_id = pending["client_id"] + + # Exchange code for tokens + try: + resp = http_requests.post( + f"{ha_url}/auth/token", + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": client_id, + }, + timeout=15, + verify=False, + ) + except Exception as exc: + logger.error("HA token exchange connection error: %s", exc) + return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302) + + if resp.status_code != 200: + logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text) + return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302) + + tokens = resp.json() + access_token = tokens["access_token"] + refresh_token = tokens.get("refresh_token", "") + expires_in = tokens.get("expires_in", 1800) + now = datetime.now(timezone.utc) + + try: + remote_cals = _ha_get_calendars(ha_url, access_token) + except HTTPException as exc: + logger.error("HA calendar fetch failed after OAuth: %s", exc.detail) + return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302) + + account = models.HomeAssistantAccount( + user_id=pending["user_id"], + name=pending["name"], + url=ha_url, + token=access_token, + auth_method="oauth", + refresh_token=refresh_token, + token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc), + client_id=client_id, + ) + db.add(account) + db.flush() + + for cal in remote_cals: + entity_id = cal.get("entity_id", "") + if not entity_id: + continue + db.add(models.HomeAssistantCalendar( + account_id=account.id, + entity_id=entity_id, + name=cal.get("name") or entity_id, + color=None, + enabled=True, + )) + + db.commit() + return RedirectResponse(url="/?ha_connected=1", status_code=302) + + @router.delete("/accounts/{account_id}") def delete_account( account_id: int, diff --git a/frontend/index.html b/frontend/index.html index b327ee9..f202162 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v1 + Calendarr v2 @@ -71,7 +71,7 @@ - + @@ -118,7 +118,7 @@ Profil @@ -173,7 +173,7 @@
- + @@ -208,7 +208,7 @@
- +
@@ -216,7 +216,7 @@
- +
@@ -226,7 +226,7 @@
- +
@@ -234,7 +234,7 @@
- +
@@ -276,10 +276,10 @@ @@ -421,32 +421,25 @@
Anmeldemethode
-
+
+ Du wirst zur Login-Seite deiner Home Assistant Instanz weitergeleitet und nach erfolgreichem Login wieder zurück zu Calendarr. +
+ -
@@ -689,7 +682,7 @@
@@ -765,7 +758,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 925526b..5faf66a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -75,6 +75,32 @@ export async function initCalendar() { bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); + handleHAOAuthReturn(); +} + +function handleHAOAuthReturn() { + const params = new URLSearchParams(window.location.search); + const errMap = { + no_code: 'Home Assistant hat keinen Autorisierungscode zurückgegeben', + state_expired: 'Die Anmeldung ist abgelaufen, bitte erneut versuchen', + ha_unreachable: 'Home Assistant nicht erreichbar', + token_exchange_failed: 'Token-Austausch mit Home Assistant fehlgeschlagen', + calendars_failed: 'Kalender konnten nicht geladen werden', + }; + if (params.has('ha_connected')) { + showToast('Home Assistant verbunden'); + window.history.replaceState({}, '', window.location.pathname); + fetchAndRender(true); + api.get('/homeassistant/accounts').then(accs => { + state.haAccounts = accs || []; + renderCalendarList(); + renderAllAccounts?.(); + }).catch(() => {}); + } else if (params.has('ha_error')) { + const code = params.get('ha_error'); + showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); + window.history.replaceState({}, '', window.location.pathname); + } } // ── Event cache ─────────────────────────────────────────── @@ -1266,30 +1292,32 @@ function openHAAccountModal() { document.getElementById('ha-account-name').value = ''; document.getElementById('ha-account-url').value = ''; document.getElementById('ha-account-token').value = ''; - document.getElementById('ha-account-username').value = ''; - document.getElementById('ha-account-userpass').value = ''; document.getElementById('ha-account-error').classList.add('hidden'); - // Reset to token method - document.getElementById('ha-auth-token').checked = true; - document.getElementById('ha-token-group').classList.remove('hidden'); - document.getElementById('ha-credentials-group').classList.add('hidden'); + // Reset to OAuth method + document.getElementById('ha-auth-oauth').checked = true; + document.getElementById('ha-oauth-info').classList.remove('hidden'); + document.getElementById('ha-token-group').classList.add('hidden'); + document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden'; openModal('modal-ha-account'); } function bindHAAccountModal() { - // Toggle auth method fields + // Toggle auth method fields + save button label document.querySelectorAll('[name="ha-auth-method"]').forEach(r => { r.addEventListener('change', () => { - const isPw = document.getElementById('ha-auth-password').checked; - document.getElementById('ha-token-group').classList.toggle('hidden', isPw); - document.getElementById('ha-credentials-group').classList.toggle('hidden', !isPw); + const isOAuth = document.getElementById('ha-auth-oauth').checked; + document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth); + document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth); + document.getElementById('ha-account-save').textContent = isOAuth + ? 'Mit Home Assistant anmelden' + : 'Verbinden'; }); }); document.getElementById('ha-account-save').onclick = async () => { const name = document.getElementById('ha-account-name').value.trim(); const url = document.getElementById('ha-account-url').value.trim(); - const isPw = document.getElementById('ha-auth-password').checked; + const isOAuth = document.getElementById('ha-auth-oauth').checked; const errEl = document.getElementById('ha-account-error'); if (!name || !url) { @@ -1297,34 +1325,43 @@ function bindHAAccountModal() { errEl.classList.remove('hidden'); return; } - - const body = { name, url }; - if (isPw) { - const username = document.getElementById('ha-account-username').value.trim(); - const password = document.getElementById('ha-account-userpass').value.trim(); - if (!username || !password) { - errEl.textContent = 'Bitte Benutzername und Passwort ausfüllen'; - errEl.classList.remove('hidden'); - return; - } - body.username = username; - body.password = password; - } else { - const token = document.getElementById('ha-account-token').value.trim(); - if (!token) { - errEl.textContent = 'Bitte Access Token ausfüllen'; - errEl.classList.remove('hidden'); - return; - } - body.token = token; - } errEl.classList.add('hidden'); const saveBtn = document.getElementById('ha-account-save'); saveBtn.disabled = true; + + if (isOAuth) { + saveBtn.textContent = 'Weiterleiten…'; + try { + const base = window.location.origin; + const resp = await api.post('/homeassistant/auth-url', { + name, + url, + client_id: base + '/', + redirect_uri: base + '/api/homeassistant/callback', + }); + if (!resp) return; + window.location.href = resp.url; + } catch (e) { + errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung'; + errEl.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Mit Home Assistant anmelden'; + } + return; + } + + // Long-Lived Token flow + const token = document.getElementById('ha-account-token').value.trim(); + if (!token) { + errEl.textContent = 'Bitte Access Token ausfüllen'; + errEl.classList.remove('hidden'); + saveBtn.disabled = false; + return; + } saveBtn.textContent = 'Verbinde…'; try { - const account = await api.post('/homeassistant/accounts', body); + const account = await api.post('/homeassistant/accounts', { name, url, token }); if (!account) return; state.haAccounts.push(account); renderCalendarList(); diff --git a/frontend/js/version.js b/frontend/js/version.js index c3d71fe..9d3baa6 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 = 'v1'; +export const APP_VERSION = 'v2'; -- 2.47.3 From 013fb3dbc249645a786801337c475f1baa3e8553 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 29 Apr 2026 17:49:03 +0200 Subject: [PATCH 035/114] =?UTF-8?q?feat:=20Datum-Validierung,=20Monatsausw?= =?UTF-8?q?ahl,=20CalDAV-Fix,=20wiederkehrende=20Termine=20-=20End-Datum?= =?UTF-8?q?=20passt=20sich=20automatisch=20an=20wenn=20Start=20ge=C3=A4nde?= =?UTF-8?q?rt=20wird=20(Duration=20bleibt=20erhalten)=20-=20Erstellen-Butt?= =?UTF-8?q?on=20nutzt=20den=20aktuell=20angesehenen=20Tag=20statt=20immer?= =?UTF-8?q?=20heute=20-=20Monatsansicht:=20Einzelklick=20=3D=20Tag=20ausw?= =?UTF-8?q?=C3=A4hlen,=20Doppelklick=20=3D=20Tagesansicht,=20Rechtsklick?= =?UTF-8?q?=20=3D=20Kontextmen=C3=BC=20-=20CalDAV=20URL-Matching=20robuste?= =?UTF-8?q?r=20(Normalisierung,=20Path-Fallback,=20calendar=5Fid=20Paramet?= =?UTF-8?q?er)=20-=20iCal-Abo-Termine=20sind=20nicht=20mehr=20bearbeitbar?= =?UTF-8?q?=20(Read-Only-Schutz)=20-=20Wiederkehrende=20Termine=20mit=20RR?= =?UTF-8?q?ULE-Support=20(t=C3=A4glich/w=C3=B6chentlich/monatlich/j=C3=A4h?= =?UTF-8?q?rlich/benutzerdefiniert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 26 +++- backend/main.py | 6 + backend/models.py | 1 + backend/routers/caldav_router.py | 191 +++++++++++++++++++++--- backend/routers/local_router.py | 6 + frontend/css/app.css | 26 ++++ frontend/index.html | 50 +++++++ frontend/js/calendar.js | 249 +++++++++++++++++++++++++++++-- frontend/js/i18n.js | 18 +++ frontend/js/views/month.js | 36 ++++- requirements.txt | 1 + 11 files changed, 564 insertions(+), 46 deletions(-) diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 49f9ca6..0cacd8b 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone from typing import Dict, List, Optional import caldav -from icalendar import Calendar, Event +from icalendar import Calendar, Event, vRecur logger = logging.getLogger(__name__) @@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: location = str(component.get("LOCATION", "") or "") description = str(component.get("DESCRIPTION", "") or "") color = str(component.get("X-CALENDARR-COLOR", "") or "") + rrule_prop = component.get("RRULE") + rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None dtstart_prop = component.get("DTSTART") dtend_prop = component.get("DTEND") @@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: "location": location, "description": description, "color": color or None, + "rrule": rrule_str, } ) except Exception as exc: @@ -201,6 +204,8 @@ def create_event( event.add("description", data["description"]) if data.get("color"): event.add("x-calendarr-color", data["color"]) + if data.get("rrule"): + event.add("rrule", _parse_rrule_str(data["rrule"])) cal.add_component(event) cal_obj.save_event(cal.to_ical().decode("utf-8")) @@ -247,6 +252,11 @@ def update_event( component["DESCRIPTION"] = data["description"] if "color" in data: component["X-CALENDARR-COLOR"] = data["color"] + if "rrule" in data: + if data["rrule"]: + component["RRULE"] = _parse_rrule_str(data["rrule"]) + elif "RRULE" in component: + del component["RRULE"] new_cal.add_component(component) @@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str): resource.delete() +def _parse_rrule_str(rrule_str: str) -> vRecur: + """Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur.""" + params = {} + for part in rrule_str.split(";"): + if "=" not in part: + continue + key, val = part.split("=", 1) + if "," in val: + params[key] = val.split(",") + else: + params[key] = val + return vRecur(params) + + def _parse_dt(s: str) -> datetime: s = s.replace("Z", "+00:00") dt = datetime.fromisoformat(s) diff --git a/backend/main.py b/backend/main.py index bb1327a..c457c27 100644 --- a/backend/main.py +++ b/backend/main.py @@ -83,6 +83,12 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT")) + conn.commit() + logging.info("Migration: added rrule to local_events") + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 36fc462..98b65fb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -112,6 +112,7 @@ class LocalEvent(Base): location = Column(String(500), nullable=True) description = Column(Text, nullable=True) color = Column(String(7), nullable=True) + rrule = Column(Text, nullable=True) calendar = relationship("LocalCalendar", back_populates="events") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index c5f3167..6ef8806 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -1,9 +1,13 @@ import logging +from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone from typing import Optional +from urllib.parse import urlparse +from dateutil.rrule import rrulestr from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session +from sqlalchemy import or_ import caldav_client import models @@ -41,6 +45,7 @@ class EventCreate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None class EventUpdate(BaseModel): @@ -51,6 +56,7 @@ class EventUpdate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None def _account_dict(a: models.CalDAVAccount) -> dict: @@ -75,16 +81,124 @@ def _account_dict(a: models.CalDAVAccount) -> dict: } +def _expand_recurring_local(ev, local_cal, range_start, range_end): + """Expand a recurring LocalEvent into individual occurrences within the date range.""" + results = [] + try: + ev_start_str = ev.start.replace("Z", "+00:00") + ev_end_str = ev.end.replace("Z", "+00:00") + + if ev.all_day: + ev_start = dt_date.fromisoformat(ev_start_str[:10]) + ev_end = dt_date.fromisoformat(ev_end_str[:10]) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) + r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) + r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_start = occ.date() + occ_end = occ_start + duration + results.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": occ_start.isoformat(), + "end": occ_end.isoformat(), + "allDay": True, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) + else: + ev_start = dt_datetime.fromisoformat(ev_start_str) + ev_end = dt_datetime.fromisoformat(ev_end_str) + if ev_start.tzinfo is None: + ev_start = ev_start.replace(tzinfo=dt_timezone.utc) + if ev_end.tzinfo is None: + ev_end = ev_end.replace(tzinfo=dt_timezone.utc) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) + r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + if r_start.tzinfo is None: + r_start = r_start.replace(tzinfo=dt_timezone.utc) + if r_end.tzinfo is None: + r_end = r_end.replace(tzinfo=dt_timezone.utc) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_end = occ + duration + results.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": occ.isoformat(), + "end": occ_end.isoformat(), + "allDay": False, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) + except Exception as exc: + logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) + # Fall back to single event + results.append({ + "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title, + "start": ev.start, "end": ev.end, "allDay": ev.all_day, + "location": ev.location or "", "description": ev.description or "", + "color": ev.color, "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name, + "calendarColor": local_cal.color, "source": "local", + }) + return results + + +def _normalize_url(url: str) -> str: + """Normalize URL for comparison: lowercase scheme/host, strip trailing slash.""" + parsed = urlparse(url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or '').lower() + port = parsed.port + if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80): + port = None + netloc = f"{host}:{port}" if port else host + path = parsed.path.rstrip('/') + return f"{scheme}://{netloc}{path}" + + def _find_account_for_event_url( event_url: str, accounts: list[models.CalDAVAccount] ) -> Optional[models.CalDAVAccount]: + norm_event = _normalize_url(event_url) + # Primary: match against normalized account URL for acc in accounts: - if event_url.startswith(acc.url): + if norm_event.startswith(_normalize_url(acc.url)): return acc - # fallback: check calendar urls + # Fallback: match against normalized calendar URLs for acc in accounts: for cal in acc.calendars: - if event_url.startswith(cal.cal_id): + if norm_event.startswith(_normalize_url(cal.cal_id)): + return acc + # Second fallback: path-only matching + event_path = urlparse(event_url).path.rstrip('/') + for acc in accounts: + acc_path = urlparse(acc.url).path.rstrip('/') + if acc_path and event_path.startswith(acc_path): + return acc + for acc in accounts: + for cal in acc.calendars: + cal_path = urlparse(cal.cal_id).path.rstrip('/') + if cal_path and event_path.startswith(cal_path): return acc return None @@ -302,27 +416,35 @@ def get_events( db.query(models.LocalEvent) .filter( models.LocalEvent.calendar_id == local_cal.id, - models.LocalEvent.start < end, - models.LocalEvent.end > start, + or_( + # Non-recurring events in range + (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start), + # Recurring events: always include so we can expand + models.LocalEvent.rrule != None, + ), ) .all() ) for ev in local_events: - all_events.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) + if ev.rrule: + all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt)) + else: + all_events.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": ev.start, + "end": ev.end, + "allDay": ev.all_day, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": None, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) # ── iCal subscription events ────────────────────────── ical_subs = ( @@ -403,6 +525,7 @@ def create_event( "location": data.location, "description": data.description, "color": data.color, + "rrule": data.rrule, }, ) return {"uid": uid, "calendar_id": data.calendar_id} @@ -414,6 +537,7 @@ def create_event( def update_event( event_id: str, event_url: str = Query(...), + calendar_id: Optional[int] = Query(None), data: EventUpdate = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), @@ -423,7 +547,18 @@ def update_event( .filter(models.CalDAVAccount.user_id == current_user.id) .all() ) - account = _find_account_for_event_url(event_url, accounts) + account = None + if calendar_id is not None: + cal = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id) + .first() + ) + if cal: + account = next((a for a in accounts if a.id == cal.account_id), None) + if not account: + account = _find_account_for_event_url(event_url, accounts) if not account: raise HTTPException(404, "Event not found or not authorized") try: @@ -443,6 +578,7 @@ def update_event( def delete_event( event_id: str, event_url: str = Query(...), + calendar_id: Optional[int] = Query(None), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): @@ -451,7 +587,18 @@ def delete_event( .filter(models.CalDAVAccount.user_id == current_user.id) .all() ) - account = _find_account_for_event_url(event_url, accounts) + account = None + if calendar_id is not None: + cal = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id) + .first() + ) + if cal: + account = next((a for a in accounts if a.id == cal.account_id), None) + if not account: + account = _find_account_for_event_url(event_url, accounts) if not account: raise HTTPException(404, "Event not found or not authorized") try: diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 405dd52..d0b2900 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -32,6 +32,7 @@ class EventCreate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None class EventUpdate(BaseModel): @@ -42,6 +43,7 @@ class EventUpdate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None def _cal_dict(cal: models.LocalCalendar) -> dict: @@ -64,6 +66,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: "location": ev.location or "", "description": ev.description or "", "color": ev.color, + "rrule": ev.rrule, "calendar_id": f"local-{cal.id}", "calendar_name": cal.name, "calendarColor": cal.color, @@ -180,6 +183,7 @@ def create_event( location=data.location, description=data.description, color=data.color, + rrule=data.rrule, ) db.add(ev) db.commit() @@ -219,6 +223,8 @@ def update_event( ev.description = data.description if data.color is not None: ev.color = data.color + if data.rrule is not None: + ev.rrule = data.rrule if data.rrule else None db.commit() return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index ee6ec83..010297b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -486,6 +486,8 @@ a { color: var(--primary); text-decoration: none; } .month-col:last-child { border-right: none; } .month-col:hover { background: var(--bg-hover); } .month-col.today { background: rgba(66,133,244,.08); } +.month-col.month-selected { background: var(--primary-dim); } +.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; } .month-col.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); @@ -785,6 +787,30 @@ a { color: var(--primary); text-decoration: none; } padding: 12px 20px; border-top: 1px solid var(--border); } +/* ── Recurrence UI ─────────────────────────────────────── */ +.rec-weekdays { display: flex; gap: 4px; margin-top: 8px; } +.rec-day-btn { + width: 36px; height: 36px; border-radius: 50%; + border: 1px solid var(--border); background: var(--bg-card); + color: var(--text-2); cursor: pointer; font-size: 12px; + display: flex; align-items: center; justify-content: center; + transition: background var(--transition), color var(--transition); +} +.rec-day-btn:hover { background: var(--bg-hover); } +.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ── Day Context Menu ──────────────────────────────────── */ +.cal-context-menu { + position: fixed; z-index: 1000; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3); + min-width: 180px; padding: 4px 0; +} +.ctx-item { + padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer; +} +.ctx-item:hover { background: var(--bg-hover); } + /* ── Event Popup ────────────────────────────────────────── */ .event-popup { position: fixed; z-index: 600; diff --git a/frontend/index.html b/frontend/index.html index f202162..ae02952 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -238,6 +238,56 @@ +
+ + +
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 5faf66a..186c7f9 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -250,7 +250,23 @@ function renderView() { if (state.currentView === 'month') { renderMonth(container, state.currentDate, evs, - date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, + (date, action, mouseEvent) => { + if (action === 'navigate') { + state.currentDate = date; + state.currentView = 'day'; + updateViewButtons(); + fetchAndRender(); + } else if (action === 'context') { + state.currentDate = date; + showDayContextMenu(date, mouseEvent); + } else { + // 'select' — highlight day without navigating + state.currentDate = date; + renderMiniCal(); + renderView(); + updateTitle(); + } + }, showEventPopup, weekStartDay ); @@ -764,7 +780,7 @@ function bindTopbar() { }); document.getElementById('btn-settings').onclick = openSettingsModal; - document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); + document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate); // Mouse wheel / trackpad scroll navigation – only for month & quarter let _wheelLast = 0; @@ -837,6 +853,29 @@ function bindSidebar() { }; } +// ── Day Context Menu (month view) ──────────────────────── +function showDayContextMenu(date, mouseEvent) { + document.querySelectorAll('.cal-context-menu').forEach(m => m.remove()); + + const menu = document.createElement('div'); + menu.className = 'cal-context-menu'; + menu.innerHTML = `
${t('ctx_create_event')}
`; + + menu.style.left = mouseEvent.clientX + 'px'; + menu.style.top = mouseEvent.clientY + 'px'; + document.body.appendChild(menu); + + menu.querySelector('[data-action="create"]').onclick = () => { + menu.remove(); + openNewEventModal(date); + }; + + const close = (e) => { + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + setTimeout(() => document.addEventListener('click', close), 0); +} + // ── Event Popup ─────────────────────────────────────────── function showEventPopup(ev, anchor) { const popup = document.getElementById('popup-event'); @@ -877,6 +916,11 @@ function showEventPopup(ev, anchor) { popup.style.left = Math.max(8, left) + 'px'; popup.style.top = Math.max(8, top) + 'px'; + // Hide edit/delete for read-only iCal subscription events + const isReadOnly = (ev.source === 'ical'); + document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; + document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; + document.getElementById('popup-edit').onclick = () => { popup.classList.add('hidden'); openEditEventModal(ev); @@ -921,7 +965,7 @@ function showEventPopup(ev, anchor) { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); } else { - await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } showToast(t('event_deleted')); fetchAndRender(true); @@ -1005,11 +1049,13 @@ function openNewEventModal(date) { toggleAlldayFields(false); populateCalendarSelect(null); resetColorPicker(''); + resetRecurrenceUI(); document.getElementById('ev-delete').classList.add('hidden'); openModal('modal-event'); } function openEditEventModal(ev) { + if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } state.editingEvent = ev; state.selectedEventColor = ev.color || ''; @@ -1033,6 +1079,23 @@ function openEditEventModal(ev) { populateCalendarSelect(ev.calendar_id); resetColorPicker(ev.color || ''); + + // Recurrence + const rrule = ev.rrule || ''; + const recSel = document.getElementById('ev-recurrence'); + const customPanel = document.getElementById('ev-recurrence-custom'); + if (!rrule) { + recSel.value = ''; + customPanel.classList.add('hidden'); + } else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) { + recSel.value = rrule; + customPanel.classList.add('hidden'); + } else { + recSel.value = 'custom'; + customPanel.classList.remove('hidden'); + parseRruleIntoUI(rrule); + } + document.getElementById('ev-delete').classList.remove('hidden'); openModal('modal-event'); } @@ -1050,24 +1113,142 @@ function resetColorPicker(color) { preview.style.background = color || 'var(--primary)'; } +function buildRruleFromUI() { + const sel = document.getElementById('ev-recurrence').value; + if (!sel) return null; + if (sel !== 'custom') return sel; + + const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1; + const freq = document.getElementById('ev-rec-freq').value; + let rule = `FREQ=${freq}`; + if (interval > 1) rule += `;INTERVAL=${interval}`; + + if (freq === 'WEEKLY') { + const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day); + if (days.length) rule += `;BYDAY=${days.join(',')}`; + } + + const endType = document.getElementById('ev-rec-end-type').value; + if (endType === 'count') { + rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`; + } else if (endType === 'until') { + const until = document.getElementById('ev-rec-until').value; + if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; + } + return rule; +} + +function parseRruleIntoUI(rruleStr) { + const parts = {}; + rruleStr.split(';').forEach(p => { + const [k, v] = p.split('=', 2); + if (k && v) parts[k] = v; + }); + + document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1'; + document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY'; + document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY'); + + // Reset all weekday buttons + document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); + if (parts.BYDAY) { + parts.BYDAY.split(',').forEach(day => { + const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`); + if (btn) btn.classList.add('active'); + }); + } + + if (parts.COUNT) { + document.getElementById('ev-rec-end-type').value = 'count'; + document.getElementById('ev-rec-count').value = parts.COUNT; + document.getElementById('ev-rec-end-count').classList.remove('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); + } else if (parts.UNTIL) { + document.getElementById('ev-rec-end-type').value = 'until'; + // Parse UNTIL: 20260501T235959Z → 2026-05-01 + const u = parts.UNTIL.replace('Z', ''); + const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : ''; + if (formatted) setDtValue('ev-rec-until', formatted, 'date'); + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.remove('hidden'); + } else { + document.getElementById('ev-rec-end-type').value = 'never'; + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); + } +} + +function resetRecurrenceUI() { + document.getElementById('ev-recurrence').value = ''; + document.getElementById('ev-recurrence-custom').classList.add('hidden'); + document.getElementById('ev-rec-interval').value = '1'; + document.getElementById('ev-rec-freq').value = 'DAILY'; + document.getElementById('ev-rec-weekdays').classList.add('hidden'); + document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); + document.getElementById('ev-rec-end-type').value = 'never'; + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); +} + function bindEventModal() { document.getElementById('ev-allday').addEventListener('change', e => { toggleAlldayFields(e.target.checked); }); - // Date/time pickers + // Date/time pickers with auto-adjustment logic [ - { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' }, - { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' }, - { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' }, - { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' }, - ].forEach(({ displayId, inputId, mode }) => { + { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' }, + { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' }, + { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' }, + { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' }, + ].forEach(({ displayId, inputId, mode, role }) => { const disp = document.getElementById(displayId); if (!disp) return; const open = async () => { const current = document.getElementById(inputId)?.value || ''; - const result = await openDatePicker(disp, current, mode); - if (result !== null) setDtValue(inputId, result, mode); + const oldStart = mode === 'datetime' + ? document.getElementById('ev-start').value + : document.getElementById('ev-start-date').value; + const oldEnd = mode === 'datetime' + ? document.getElementById('ev-end').value + : document.getElementById('ev-end-date').value; + + const result = await openDatePicker(disp, current, mode); + if (result === null) return; + setDtValue(inputId, result, mode); + + if (role === 'start') { + // Adjust end to maintain duration + if (mode === 'datetime') { + const os = oldStart ? new Date(oldStart) : null; + const oe = oldEnd ? new Date(oldEnd) : null; + const ns = new Date(result); + const duration = (os && oe && oe > os) ? (oe - os) : 3600000; + const ne = new Date(ns.getTime() + duration); + setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime'); + } else { + const endVal = document.getElementById('ev-end-date').value; + if (!endVal || endVal < result) { + setDtValue('ev-end-date', result, 'date'); + } + } + } else { + // Validate end is not before start + 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); + } + } 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); + } + } + } }; disp.addEventListener('click', open); disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); }); @@ -1091,6 +1272,41 @@ function bindEventModal() { } }); + // ── Recurrence UI ────────────────────────────────────── + const recSel = document.getElementById('ev-recurrence'); + const customPanel = document.getElementById('ev-recurrence-custom'); + const recFreq = document.getElementById('ev-rec-freq'); + const weekdaysDiv = document.getElementById('ev-rec-weekdays'); + const endTypeSel = document.getElementById('ev-rec-end-type'); + + recSel.addEventListener('change', () => { + customPanel.classList.toggle('hidden', recSel.value !== 'custom'); + }); + + recFreq.addEventListener('change', () => { + weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY'); + }); + + document.querySelectorAll('.rec-day-btn').forEach(btn => { + btn.addEventListener('click', () => btn.classList.toggle('active')); + }); + + endTypeSel.addEventListener('change', () => { + document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count'); + document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until'); + }); + + const untilDisp = document.getElementById('ev-rec-until-display'); + if (untilDisp) { + const openUntil = async () => { + const current = document.getElementById('ev-rec-until').value || ''; + const result = await openDatePicker(untilDisp, current, 'date'); + if (result !== null) setDtValue('ev-rec-until', result, 'date'); + }; + untilDisp.addEventListener('click', openUntil); + untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); }); + } + document.getElementById('ev-save').onclick = async () => { const title = document.getElementById('ev-title').value.trim(); if (!title) { showToast(t('error_enter_title'), true); return; } @@ -1102,6 +1318,7 @@ function bindEventModal() { const loc = document.getElementById('ev-location').value.trim(); const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; + const rrule = buildRruleFromUI(); let start, end; if (allDay) { @@ -1127,7 +1344,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 } + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); @@ -1136,8 +1353,8 @@ function bindEventModal() { ); } else { await api.put( - `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`, - { title, start, end, allDay, location: loc, description: desc, color: color || null } + `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`, + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } showToast(t('event_updated')); @@ -1153,6 +1370,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, }); showToast(t('event_created')); } else { @@ -1160,6 +1378,7 @@ function bindEventModal() { await api.post('/caldav/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, + rrule: rrule || null, }); showToast(t('event_created')); } @@ -1184,7 +1403,7 @@ function bindEventModal() { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); } else { - await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } showToast(t('event_deleted')); closeModal('modal-event'); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 48de8ae..8400b08 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -142,6 +142,15 @@ const translations = { error_enter_title: 'Bitte Titel eingeben', error_enter_date: 'Bitte Datum eingeben', error_enter_start: 'Bitte Start-Zeit eingeben', + error_end_before_start: 'Ende kann nicht vor dem Start liegen', + ctx_create_event: 'Neuen Termin erstellen', + event_readonly: 'Abonnierte Termine können nicht bearbeitet werden', + rec_label: 'Wiederholung', + rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich', + rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…', + rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', + rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', + rec_on_date: 'Am Datum', rec_occurrences: 'Termine', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', confirm_delete_event: '"{title}" wirklich löschen?', @@ -337,6 +346,15 @@ const translations = { error_enter_title: 'Please enter a title', error_enter_date: 'Please enter a date', error_enter_start: 'Please enter a start time', + error_end_before_start: 'End cannot be before start', + ctx_create_event: 'Create new event', + event_readonly: 'Subscribed events cannot be edited', + rec_label: 'Recurrence', + rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly', + rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…', + rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', + rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', + rec_on_date: 'On date', rec_occurrences: 'occurrences', copy_to_calendar: 'Copy to…', event_copied: 'Event copied', event_updated: 'Event updated', event_created: 'Event created', confirm_delete_event: 'Really delete "{title}"?', diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 201636e..72d0e4e 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,4 +1,4 @@ -import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; +import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; const LANE_H = 20; // px per lane (event height 18px + 2px gap) @@ -124,10 +124,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC rowCells.forEach(cell => { const key = dateKey(cell); const isOther = cell.getMonth() !== primaryMonth; - const todayCls = isToday(cell) ? 'today' : ''; - const otherCls = isOther ? 'other-month' : ''; - const numCls = isToday(cell) ? 'today' : ''; - colsHtml += `
+ const todayCls = isToday(cell) ? 'today' : ''; + const otherCls = isOther ? 'other-month' : ''; + const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : ''; + const numCls = isToday(cell) ? 'today' : ''; + colsHtml += `
${cell.getDate()}
`; }); @@ -148,6 +149,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // Click handlers via event delegation on the body const body = container.querySelector('.month-body'); + + // Single click: select day (or handle event / more clicks) body.addEventListener('click', e => { // Span event click const spanEl = e.target.closest('.month-span-event'); @@ -161,13 +164,30 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC const moreEl = e.target.closest('.month-more'); if (moreEl) { e.stopPropagation(); - onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); + onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate'); return; } - // Column click → navigate to day view + // Column click → select day const colEl = e.target.closest('.month-col'); if (colEl) { - onDayClick(new Date(colEl.dataset.date + 'T00:00:00')); + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select'); + } + }); + + // Double click: navigate to day view + body.addEventListener('dblclick', e => { + const colEl = e.target.closest('.month-col'); + if (colEl && !e.target.closest('.month-span-event')) { + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate'); + } + }); + + // Right click: context menu + body.addEventListener('contextmenu', e => { + const colEl = e.target.closest('.month-col'); + if (colEl) { + e.preventDefault(); + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e); } }); } diff --git a/requirements.txt b/requirements.txt index 22c76ee..d51baa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ requests==2.32.3 pyotp==2.9.0 qrcode[pil]==8.0 Pillow==11.0.0 +python-dateutil==2.9.0 -- 2.47.3 From 1638c9f631d01558be79dac03642b2f6dbbae878 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Wed, 29 Apr 2026 18:13:12 +0200 Subject: [PATCH 036/114] =?UTF-8?q?fix:=20Runde-2-Fixes=20=E2=80=93=20Mona?= =?UTF-8?q?tsauswahl,=20CalDAV-Update,=20L=C3=B6sch-Dialog,=20EXDATE=20-?= =?UTF-8?q?=20Monatsansicht:=20selectedDate=20von=20currentDate=20getrennt?= =?UTF-8?q?,=20Klick=20verschiebt=20View=20nicht=20mehr=20-=20Selected-Day?= =?UTF-8?q?=20Styling:=20wei=C3=9Fer=20Text=20auf=20Primary-Hintergrund=20?= =?UTF-8?q?statt=20nur=20Textfarbe=20-=20Kontextmen=C3=BC:=20--bg-surface?= =?UTF-8?q?=20statt=20fehlendem=20--bg-card=20-=20CalDAV=20Update/Delete:?= =?UTF-8?q?=20parent=20Calendar-Objekt=20=C3=BCbergeben=20(behebt=20NoneTy?= =?UTF-8?q?pe-Fehler)=20-=20HA-Kalender=20im=20Kalender-Selektor=20erg?= =?UTF-8?q?=C3=A4nzt=20-=20Browser-confirm()=20durch=20styled=20Modal-Dial?= =?UTF-8?q?og=20ersetzt=20mit=20Serie/Einzeln-Option=20-=20EXDATE-Support:?= =?UTF-8?q?=20einzelne=20Vorkommen=20wiederkehrender=20Termine=20l=C3=B6sc?= =?UTF-8?q?hen=20(lokal=20+=20CalDAV)=20-=20Fehlende=20i18n-Keys=20f=C3=BC?= =?UTF-8?q?r=20L=C3=B6sch-Dialog=20erg=C3=A4nzt=20(DE=20+=20EN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 31 +++++-- backend/main.py | 7 ++ backend/models.py | 1 + backend/routers/caldav_router.py | 33 +++++++- backend/routers/local_router.py | 8 ++ frontend/css/app.css | 6 +- frontend/index.html | 26 ++++++ frontend/js/calendar.js | 138 ++++++++++++++++++++++++------- frontend/js/i18n.js | 8 ++ frontend/js/views/month.js | 5 +- 10 files changed, 220 insertions(+), 43 deletions(-) diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 0cacd8b..d7a8db2 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone from typing import Dict, List, Optional import caldav -from icalendar import Calendar, Event, vRecur +from icalendar import Calendar, Event, vDatetime, vRecur logger = logging.getLogger(__name__) @@ -213,10 +213,15 @@ def create_event( def update_event( - url: str, username: str, password: str, event_url: str, data: Dict + url: str, username: str, password: str, event_url: str, data: Dict, + calendar_url: str = None, ): client = _client(url, username, password) - resource = caldav.Event(client=client, url=event_url) + if calendar_url: + cal_obj = client.calendar(url=calendar_url) + resource = caldav.Event(client=client, url=event_url, parent=cal_obj) + else: + resource = caldav.Event(client=client, url=event_url) resource.load() raw = resource.data @@ -258,15 +263,31 @@ def update_event( elif "RRULE" in component: del component["RRULE"] + if "exdate" in data and data["exdate"]: + # Parse YYYYMMDD string into a proper EXDATE + exdate_str = data["exdate"] + # Determine if event uses dates or datetimes + dtstart_prop = component.get("DTSTART") + if dtstart_prop and isinstance(dtstart_prop.dt, date) and not isinstance(dtstart_prop.dt, datetime): + exdate_val = date(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8])) + else: + exdate_val = datetime(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]), tzinfo=timezone.utc) + component.add("exdate", [exdate_val]) + new_cal.add_component(component) resource.data = new_cal.to_ical().decode("utf-8") resource.save() -def delete_event(url: str, username: str, password: str, event_url: str): +def delete_event(url: str, username: str, password: str, event_url: str, + calendar_url: str = None): client = _client(url, username, password) - resource = caldav.Event(client=client, url=event_url) + if calendar_url: + cal_obj = client.calendar(url=calendar_url) + resource = caldav.Event(client=client, url=event_url, parent=cal_obj) + else: + resource = caldav.Event(client=client, url=event_url) resource.delete() diff --git a/backend/main.py b/backend/main.py index c457c27..46ccbde 100644 --- a/backend/main.py +++ b/backend/main.py @@ -90,6 +90,13 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT")) + conn.commit() + logging.info("Migration: added exdate to local_events") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 98b65fb..92a57ec 100644 --- a/backend/models.py +++ b/backend/models.py @@ -113,6 +113,7 @@ class LocalEvent(Base): description = Column(Text, nullable=True) color = Column(String(7), nullable=True) rrule = Column(Text, nullable=True) + exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude calendar = relationship("LocalCalendar", back_populates="events") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 6ef8806..447663f 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -57,6 +57,7 @@ class EventUpdate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + exdate: Optional[str] = None def _account_dict(a: models.CalDAVAccount) -> dict: @@ -84,6 +85,13 @@ def _account_dict(a: models.CalDAVAccount) -> dict: def _expand_recurring_local(ev, local_cal, range_start, range_end): """Expand a recurring LocalEvent into individual occurrences within the date range.""" results = [] + # Parse excluded dates + excluded = set() + if ev.exdate: + for d in ev.exdate.split(","): + d = d.strip() + if d: + excluded.add(d) try: ev_start_str = ev.start.replace("Z", "+00:00") ev_end_str = ev.end.replace("Z", "+00:00") @@ -98,6 +106,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end): occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) for occ in occurrences: occ_start = occ.date() + occ_key = occ_start.strftime("%Y%m%d") + if occ_key in excluded: + continue occ_end = occ_start + duration results.append({ "id": ev.uid, @@ -132,6 +143,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end): r_end = r_end.replace(tzinfo=dt_timezone.utc) occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) for occ in occurrences: + occ_key = occ.strftime("%Y%m%d") + if occ_key in excluded: + continue occ_end = occ + duration results.append({ "id": ev.uid, @@ -548,6 +562,7 @@ def update_event( .all() ) account = None + cal_url = None if calendar_id is not None: cal = ( db.query(models.Calendar) @@ -557,8 +572,15 @@ def update_event( ) if cal: account = next((a for a in accounts if a.id == cal.account_id), None) + cal_url = cal.cal_id if not account: account = _find_account_for_event_url(event_url, accounts) + # Try to find the calendar URL for the account + if account and not cal_url: + for c in account.calendars: + if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)): + cal_url = c.cal_id + break if not account: raise HTTPException(404, "Event not found or not authorized") try: @@ -568,6 +590,7 @@ def update_event( account.password, event_url, data.model_dump(exclude_none=True) if data else {}, + calendar_url=cal_url, ) return {"ok": True} except Exception as exc: @@ -588,6 +611,7 @@ def delete_event( .all() ) account = None + cal_url = None if calendar_id is not None: cal = ( db.query(models.Calendar) @@ -597,13 +621,20 @@ def delete_event( ) if cal: account = next((a for a in accounts if a.id == cal.account_id), None) + cal_url = cal.cal_id if not account: account = _find_account_for_event_url(event_url, accounts) + if account and not cal_url: + for c in account.calendars: + if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)): + cal_url = c.cal_id + break if not account: raise HTTPException(404, "Event not found or not authorized") try: caldav_client.delete_event( - account.url, account.username, account.password, event_url + account.url, account.username, account.password, event_url, + calendar_url=cal_url, ) return {"ok": True} except Exception as exc: diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index d0b2900..e7d6b97 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -44,6 +44,7 @@ class EventUpdate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + exdate: Optional[str] = None def _cal_dict(cal: models.LocalCalendar) -> dict: @@ -67,6 +68,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: "description": ev.description or "", "color": ev.color, "rrule": ev.rrule, + "exdate": ev.exdate, "calendar_id": f"local-{cal.id}", "calendar_name": cal.name, "calendarColor": cal.color, @@ -225,6 +227,12 @@ def update_event( ev.color = data.color if data.rrule is not None: ev.rrule = data.rrule if data.rrule else None + if data.exdate is not None: + existing = ev.exdate or "" + dates = [d for d in existing.split(",") if d] + if data.exdate not in dates: + dates.append(data.exdate) + ev.exdate = ",".join(dates) db.commit() return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index 010297b..0bd114d 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -487,7 +487,7 @@ a { color: var(--primary); text-decoration: none; } .month-col:hover { background: var(--bg-hover); } .month-col.today { background: rgba(66,133,244,.08); } .month-col.month-selected { background: var(--primary-dim); } -.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; } +.month-col.month-selected .cell-day { background: var(--primary); color: #fff; font-weight: 700; } .month-col.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); @@ -802,8 +802,8 @@ a { color: var(--primary); text-decoration: none; } /* ── Day Context Menu ──────────────────────────────────── */ .cal-context-menu { position: fixed; z-index: 1000; - background: var(--bg-card); border: 1px solid var(--border); - border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3); + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.5); min-width: 180px; padding: 4px 0; } .ctx-item { diff --git a/frontend/index.html b/frontend/index.html index ae02952..40795b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -317,6 +317,32 @@
+ + + - + +
@@ -834,7 +841,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/app.js b/frontend/js/app.js index b99f093..c33fed0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -188,3 +188,12 @@ function loadAvatarImage(avatarEl, username) { // ── Start ───────────────────────────────────────────────── boot(); + +// ── Service Worker registration (PWA) ───────────────────── +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => { + console.warn('SW registration failed:', err); + }); + }); +} diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 7b3b41b..1135044 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -820,7 +820,10 @@ function bindTopbar() { function bindSidebar() { document.getElementById('sidebar-toggle').onclick = () => { document.getElementById('sidebar').classList.toggle('collapsed'); + document.body.classList.toggle('sidebar-open'); // mobile slide-in }; + const backdrop = document.getElementById('sidebar-backdrop'); + if (backdrop) backdrop.onclick = () => document.body.classList.remove('sidebar-open'); // Add calendar dropdown const addBtn = document.getElementById('btn-add-cal'); diff --git a/frontend/js/version.js b/frontend/js/version.js index 9d3baa6..b54d089 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 = 'v2'; +export const APP_VERSION = 'v3'; diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..496cdbb --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Calendarr", + "short_name": "Calendarr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0e0e14", + "theme_color": "#4285f4", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..81d23ad --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,86 @@ +// Calendarr Service Worker +// Cache-first for static assets, network-first for /api/* (graceful offline) + +const CACHE_VERSION = 'calendarr-v3'; +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/static/css/app.css', + '/static/favicon.svg', + '/static/js/app.js', + '/static/js/api.js', + '/static/js/calendar.js', + '/static/js/color-picker.js', + '/static/js/date-picker.js', + '/static/js/i18n.js', + '/static/js/utils.js', + '/static/js/version.js', + '/static/js/views/agenda.js', + '/static/js/views/month.js', + '/static/js/views/quarter.js', + '/static/js/views/week.js', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon.svg', +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_VERSION).then(cache => + // Use addAll with a fallback so a single missing file doesn't abort install + Promise.all( + STATIC_ASSETS.map(url => + cache.add(url).catch(err => console.warn('[SW] skip', url, err)) + ) + ) + ).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const req = event.request; + if (req.method !== 'GET') return; + + const url = new URL(req.url); + + // Network-first for API routes — fail silently if offline + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(req).catch(() => + new Response(JSON.stringify({ offline: true }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + return; + } + + // Cache-first for everything else (static) + event.respondWith( + caches.match(req).then(cached => { + if (cached) return cached; + return fetch(req).then(resp => { + // Only cache successful, basic-origin responses + if (resp && resp.status === 200 && resp.type === 'basic') { + const clone = resp.clone(); + caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); + } + return resp; + }).catch(() => { + // Offline fallback for navigation requests + if (req.mode === 'navigate') return caches.match('/index.html'); + return new Response('', { status: 503 }); + }); + }) + ); +}); -- 2.47.3 From fdf9af09cdede6ed9d2c51a1a4550081fabf6af4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 18:52:51 +0200 Subject: [PATCH 057/114] fix(mobile): Zoom blocken, Long-Press, KW-Bubble, Swipe-Nav, Safe-Area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Viewport: maximum-scale=1, user-scalable=no — kein Pinch-Zoom mehr - Profil-Dropdown öffnet wieder: overflow:hidden auf .topbar-right in der Mobile-Media-Query entfernt (hatte das absolut positionierte Dropdown abgeschnitten) - Long-Press auf Kalenderzellen markiert keinen Text mehr: user-select/touch-callout/tap-highlight in der ganzen Mobile-UI aus - Long-Press auf Avatar zeigt nicht "Bild speichern": -webkit-touch-callout:none + pointer-events:none auf - Kalenderwochen erscheinen als kleine Bubble oben links in jeder Zeile statt als eigene 38px-Spalte - Status-Bar-Overlap im Settings-Modal behoben: safe-area-inset-top auf .settings-page-header und Modal-Header in der Mobile-Media-Query - Swipe links/rechts auf #view-container navigiert prev/next (≥60 px, überwiegend horizontal, < 700 ms) - Version v3 → v4 (auch SW-Cache) Co-Authored-By: Claude Sonnet 4.6 --- frontend/css/app.css | 60 ++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 10 +++---- frontend/js/calendar.js | 28 +++++++++++++++++++ frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index bbca5fb..213afb0 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1270,7 +1270,7 @@ a { color: var(--primary); text-decoration: none; } } .topbar-right { min-width: 0; - overflow: hidden; + /* no overflow:hidden — would clip the user dropdown on tap */ } .view-switcher { overflow-x: auto; @@ -1346,6 +1346,64 @@ a { color: var(--primary); text-decoration: none; } /* ── Misc safety: prevent overflow on flex topbar items ──── */ .main-view { width: 100%; min-width: 0; } #view-container { max-width: 100%; overflow-x: hidden; } + + /* ── Long-press / text-selection / image-save fixes ──────── */ + /* Calendar UI shouldn't be selectable on touch — long-press + should reach our handlers, not trigger iOS text selection. */ + .topbar, .sidebar, .main-view, + .month-view, .month-row, .month-col, .cell-day, .month-events-overlay, .month-span-event, + .week-view, .day-view, .week-day-col, .week-day-header, .week-allday-row, + .quarter-view, .agenda-view, + .view-switcher, .view-btn, .btn, .icon-btn, .user-avatar, .user-dropdown, .dropdown-item { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + } + /* Faster taps, no double-tap zoom on interactive elements */ + .icon-btn, .btn, .view-btn, .user-avatar, .month-col, .week-day-col, .dropdown-item, + .sidebar-copyright, .impressum-link, .modal-close, [data-modal] { + touch-action: manipulation; + } + /* Avatar long-press: don't show "Save Image" — taps reach parent */ + .user-avatar img { + -webkit-touch-callout: none; + pointer-events: none; + } + + /* ── Calendar weeks (KW) shown as a small bubble, not a column ── */ + .month-header { + grid-template-columns: repeat(7, 1fr) !important; + } + .month-kw-header { display: none !important; } + .month-row-right { + margin-left: 0 !important; + } + .month-kw-cell { + position: absolute; + left: 3px; top: 3px; + width: auto; height: auto; + bottom: auto; + padding: 1px 7px; + background: var(--bg-active); + border: none !important; + border-radius: 10px; + font-size: 10px; font-weight: 600; + color: var(--text-2); + z-index: 5; + display: inline-flex; + align-items: center; + } + + /* ── Status-bar safe area inside full-screen modals (PWA) ── */ + .modal-card .modal-header, + .settings-page-header { + padding-top: calc(16px + env(safe-area-inset-top, 0px)); + } + .modal-footer, + .settings-page-body { + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + } } /* iOS notch / home-indicator safe areas (PWA standalone) */ diff --git a/frontend/index.html b/frontend/index.html index 69bce84..9efd076 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - Calendarr v3 + Calendarr v4 @@ -77,7 +77,7 @@ - + @@ -179,7 +179,7 @@
- + @@ -841,7 +841,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 1135044..464aeda 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -76,6 +76,7 @@ export async function initCalendar() { bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); + bindSwipeNavigation(); handleHAOAuthReturn(); } @@ -755,6 +756,33 @@ function renderCalendarList() { }); } +// ── Swipe navigation (mobile) ───────────────────────────── +function bindSwipeNavigation() { + const container = document.getElementById('view-container'); + if (!container) return; + let startX = 0, startY = 0, startT = 0, active = false; + container.addEventListener('touchstart', e => { + if (e.touches.length !== 1) { active = false; return; } + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + startT = Date.now(); + active = true; + }, { passive: true }); + container.addEventListener('touchend', e => { + if (!active) return; + active = false; + const t = e.changedTouches[0]; + const dx = t.clientX - startX; + const dy = t.clientY - startY; + const dt = Date.now() - startT; + // Horizontal swipe: ≥ 60px, mostly horizontal, faster than 700ms + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && dt < 700) { + navigate(dx < 0 ? 1 : -1); + fetchAndRender(); + } + }, { passive: true }); +} + // ── Navigation ──────────────────────────────────────────── function navigate(dir) { const d = state.currentDate; diff --git a/frontend/js/version.js b/frontend/js/version.js index b54d089..9f06a98 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 = 'v3'; +export const APP_VERSION = 'v4'; diff --git a/frontend/sw.js b/frontend/sw.js index 81d23ad..0213bb1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v3'; +const CACHE_VERSION = 'calendarr-v4'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From 264c47fefdc8d5da4164819986ecadc62fc57bbd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:08:20 +0200 Subject: [PATCH 058/114] fix(mobile): Monatstitel sichtbar, KW-Bubble unten, Termine mit Text, Long-Press, Settings-Hamburger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - View-Switcher auf Mobile in Popup-Menü ausgelagert (neuer Icon-Button rechts in der Topbar). Dadurch wird in der Topbar Platz frei für prev/next + Monatstitel ("Mai 2026" usw.). - Topbar-Settings-Icon auf Mobile ausgeblendet, dafür neuer "Einstellungen"-Eintrag im User-Dropdown. "Heute" wandert ins View-Popup. - KW-Bubble: von oben-links nach unten-links verschoben — überlappt jetzt nicht mehr die Tagesnummer. - Termine in der Monatsansicht zeigen wieder ihren Text (kleinere 14px-Höhe, 9px Schrift) statt nur farbiger Punkte. - Long-Press auf einen Tag öffnet das Kontextmenü "Termin erstellen" (synthetisches contextmenu-Event nach 500 ms ohne Bewegung). Der nachfolgende synthetische Click wird unterdrückt. - Settings-Modal: Sidebar (Darstellung/Konten/Benutzerverwaltung) auf Mobile als slide-in Overlay mit Hamburger-Toggle. Auf Desktop bleibt sie immer sichtbar. - Version v4 → v5 (auch SW-Cache) --- frontend/css/app.css | 88 +++++++++++++++++++++++++++++++------ frontend/index.html | 29 +++++++++++-- frontend/js/calendar.js | 96 ++++++++++++++++++++++++++++++++++++++++- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 5 files changed, 198 insertions(+), 19 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 213afb0..6eefc88 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1218,6 +1218,12 @@ a { color: var(--primary); text-decoration: none; } /* Backdrop element exists in DOM but is hidden by default on desktop */ .sidebar-backdrop { display: none; } +/* Mobile-only UI elements: hidden on desktop ───────────── */ +.view-mobile-wrapper { position: relative; display: none; } +.settings-nav-toggle { display: none; } +.settings-nav-backdrop { display: none; } +.dropdown-item-mobile-only { display: none; } + @media (max-width: 768px) { html, body { overflow-x: hidden; max-width: 100vw; } @@ -1291,16 +1297,14 @@ a { color: var(--primary); text-decoration: none; } .btn { min-height: 44px; } .view-switcher .view-btn { min-height: 40px; } - /* ── Month view: dots instead of full event titles ───────── */ + /* ── Month view: events keep text, just smaller ──────────── */ .month-span-event { - height: 6px !important; - line-height: 0 !important; - padding: 0 !important; - border-radius: 3px !important; - font-size: 0 !important; - text-overflow: clip !important; + height: 14px !important; + line-height: 14px !important; + padding: 0 4px !important; + font-size: 9px !important; + font-weight: 500; } - .month-events-overlay { gap: 1px; } .month-more { font-size: 9px; padding: 0 2px; @@ -1381,18 +1385,20 @@ a { color: var(--primary); text-decoration: none; } } .month-kw-cell { position: absolute; - left: 3px; top: 3px; + /* Bottom-left: away from the day-number (top-left) */ + left: 3px; bottom: 3px; + top: auto; right: auto; width: auto; height: auto; - bottom: auto; - padding: 1px 7px; + padding: 1px 6px; background: var(--bg-active); border: none !important; border-radius: 10px; - font-size: 10px; font-weight: 600; + font-size: 9px; font-weight: 600; color: var(--text-2); z-index: 5; display: inline-flex; align-items: center; + pointer-events: none; } /* ── Status-bar safe area inside full-screen modals (PWA) ── */ @@ -1404,6 +1410,64 @@ a { color: var(--primary); text-decoration: none; } .settings-page-body { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); } + + /* ── Topbar: hide desktop view-switcher + settings, show + mobile view-toggle and let the title breathe ──────────── */ + .topbar .view-switcher { display: none; } + .topbar #btn-settings { display: none; } + .view-mobile-wrapper { display: inline-block; } + .dropdown-item-mobile-only { display: flex; } + + /* Hide "Heute" button on desktop topbar (it's in the view popup now) */ + .topbar #btn-today { display: none; } + + /* The title is the most important info — let it grow */ + .topbar-center { flex: 1; min-width: 0; } + .topbar-center .view-title { + font-size: 17px; + font-weight: 500; + padding-left: 4px; + flex: 1; + } + .topbar-left { gap: 0; } + .topbar-right { gap: 0; } + + /* ── Settings modal: nav becomes a slide-in overlay ──────── */ + .settings-nav-toggle { display: inline-flex !important; } + .settings-page-body { position: relative; } + .settings-nav { + position: absolute; + top: 0; left: 0; bottom: 0; + width: min(75vw, 280px); + z-index: 50; + background: var(--bg-app); + border-right: 1px solid var(--border); + transform: translateX(-100%); + transition: transform .25s ease; + box-shadow: var(--shadow-lg); + } + .settings-page-card.nav-open .settings-nav { + transform: translateX(0); + } + .settings-nav-backdrop { + display: block; + position: absolute; inset: 0; + background: rgba(0,0,0,.5); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity .2s ease; + } + .settings-page-card.nav-open .settings-nav-backdrop { + opacity: 1; + pointer-events: auto; + } + .settings-panels { padding: 16px; } + + /* Modal headers: tighter on mobile */ + .modal-header { padding: 12px 16px; } + .modal-body { padding: 16px; } + .modal-footer { padding: 12px 16px; } } /* iOS notch / home-indicator safe areas (PWA standalone) */ diff --git a/frontend/index.html b/frontend/index.html index 9efd076..4a90364 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v4 + Calendarr v5 @@ -77,7 +77,7 @@ - + @@ -112,6 +112,19 @@ +
+ + +
@@ -123,6 +136,10 @@ Profil + + @@ -534,10 +551,14 @@ +

Einstellungen

+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 464aeda..85ace73 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -756,21 +756,57 @@ function renderCalendarList() { }); } -// ── Swipe navigation (mobile) ───────────────────────────── +// ── Swipe navigation + long-press → context menu (mobile) ── function bindSwipeNavigation() { const container = document.getElementById('view-container'); if (!container) return; let startX = 0, startY = 0, startT = 0, active = false; + let lpTimer = null, lpTarget = null, lpFired = false; + container.addEventListener('touchstart', e => { if (e.touches.length !== 1) { active = false; return; } startX = e.touches[0].clientX; startY = e.touches[0].clientY; startT = Date.now(); active = true; + lpFired = false; + + // Long-press → context menu (only on day cells, not on events) + lpTarget = e.target.closest('.month-col, .week-day-col'); + if (lpTarget && !e.target.closest('.month-span-event, .week-event')) { + lpTimer = setTimeout(() => { + const t = e.touches[0]; + const ev = new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, + clientX: t.clientX, clientY: t.clientY, + }); + lpTarget.dispatchEvent(ev); + lpFired = true; + }, 500); + } }, { passive: true }); + + container.addEventListener('touchmove', e => { + if (!active) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - startX) > 8 || Math.abs(t.clientY - startY) > 8) { + if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } + } + }, { passive: true }); + container.addEventListener('touchend', e => { + if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } if (!active) return; active = false; + + // Suppress the click that follows a long-press + if (lpFired) { + const blocker = ev => { ev.stopPropagation(); ev.preventDefault(); }; + document.addEventListener('click', blocker, { capture: true, once: true }); + lpFired = false; + return; + } + const t = e.changedTouches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; @@ -781,6 +817,11 @@ function bindSwipeNavigation() { fetchAndRender(); } }, { passive: true }); + + container.addEventListener('touchcancel', () => { + if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } + active = false; + }, { passive: true }); } // ── Navigation ──────────────────────────────────────────── @@ -825,6 +866,59 @@ function bindTopbar() { document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.selectedDate || state.currentDate); + // Mobile view-toggle popup + const viewMobileBtn = document.getElementById('btn-view-mobile'); + const viewMobileDropdown = document.getElementById('view-mobile-dropdown'); + if (viewMobileBtn && viewMobileDropdown) { + viewMobileBtn.onclick = e => { + e.stopPropagation(); + viewMobileDropdown.classList.toggle('hidden'); + }; + document.addEventListener('click', e => { + if (!viewMobileDropdown.contains(e.target) && !viewMobileBtn.contains(e.target)) { + viewMobileDropdown.classList.add('hidden'); + } + }); + viewMobileDropdown.querySelectorAll('[data-mobile-view]').forEach(btn => { + btn.onclick = () => { + state.currentView = btn.dataset.mobileView; + updateViewButtons(); + fetchAndRender(); + viewMobileDropdown.classList.add('hidden'); + }; + }); + const todayMobile = document.getElementById('btn-today-mobile'); + if (todayMobile) todayMobile.onclick = () => { + state.currentDate = new Date(); + fetchAndRender(); + viewMobileDropdown.classList.add('hidden'); + }; + } + + // Settings entry inside the user dropdown (mobile) + const settingsFromUser = document.getElementById('btn-settings-from-user'); + if (settingsFromUser) settingsFromUser.onclick = () => { + document.getElementById('user-dropdown').classList.add('hidden'); + openSettingsModal(); + }; + + // Settings nav hamburger (only does something on mobile via CSS) + const settingsNavToggle = document.getElementById('settings-nav-toggle'); + const settingsCard = document.querySelector('#modal-settings .settings-page-card'); + const settingsNavBackdrop = document.getElementById('settings-nav-backdrop'); + if (settingsNavToggle && settingsCard) { + settingsNavToggle.onclick = () => settingsCard.classList.toggle('nav-open'); + } + if (settingsNavBackdrop && settingsCard) { + settingsNavBackdrop.onclick = () => settingsCard.classList.remove('nav-open'); + } + // After picking a section in the nav, close the overlay (mobile UX) + document.querySelectorAll('.settings-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (settingsCard) settingsCard.classList.remove('nav-open'); + }); + }); + // Mouse wheel / trackpad scroll navigation – only for month & quarter let _wheelLast = 0; document.getElementById('view-container').addEventListener('wheel', e => { diff --git a/frontend/js/version.js b/frontend/js/version.js index 9f06a98..d5ed145 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 = 'v4'; +export const APP_VERSION = 'v5'; diff --git a/frontend/sw.js b/frontend/sw.js index 0213bb1..4783304 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v4'; +const CACHE_VERSION = 'calendarr-v5'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From 2f8fed060055d1b40cc020563c3dc4ba9d1ff809 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:17:26 +0200 Subject: [PATCH 059/114] feat(auth): "Angemeldet bleiben"-Checkbox auf Login-Screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn aktiviert, bekommt der JWT-Token statt der üblichen 7 Tage eine Lebensdauer von 180 Tagen. Der Token liegt wie bisher in localStorage, bleibt also bis zum manuellen Löschen / Cookie-Reset gültig. - backend/routers/auth_router.py: LoginRequest.remember_me, längere expires_delta beim Token-Erstellen - index.html: Checkbox unter dem 2FA-Feld - api.js: login() reicht remember_me als 4. Parameter durch - app.js: Wert aus #login-remember lesen und mitschicken - Version v5 → v6 --- backend/routers/auth_router.py | 8 +++++++- frontend/index.html | 21 ++++++++++++--------- frontend/js/api.js | 4 ++-- frontend/js/app.js | 3 ++- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index 3f18f7e..98ede29 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -1,3 +1,4 @@ +from datetime import timedelta from typing import Optional import pyotp @@ -11,6 +12,9 @@ import models from auth import create_access_token, get_current_user, get_password_hash, verify_password from database import get_db +# When "Angemeldet bleiben" is ticked the token lives for half a year. +REMEMBER_ME_EXPIRY = timedelta(days=180) + router = APIRouter() @@ -24,6 +28,7 @@ class LoginRequest(BaseModel): username: str password: str totp_code: Optional[str] = None + remember_me: Optional[bool] = False def _user_dict(user: models.User) -> dict: @@ -98,7 +103,8 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)): status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger 2FA-Code", ) - token = create_access_token({"sub": user.username}) + expires = REMEMBER_ME_EXPIRY if req.remember_me else None + token = create_access_token({"sub": user.username}, expires_delta=expires) return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} diff --git a/frontend/index.html b/frontend/index.html index 4a90364..6565e70 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v5 + Calendarr v6 @@ -73,11 +73,14 @@ + - + @@ -196,7 +199,7 @@
- + @@ -232,7 +235,7 @@
- +
@@ -240,7 +243,7 @@
- +
@@ -250,7 +253,7 @@
- +
@@ -258,7 +261,7 @@
- +
@@ -308,7 +311,7 @@
- +
@@ -862,7 +865,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/api.js b/frontend/js/api.js index 38b4a1e..b715277 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -64,8 +64,8 @@ export const api = { delete: (path) => request('DELETE', path), upload: (path, form) => uploadRequest(path, form), - login: (username, password, totp_code = null) => - request('POST', '/auth/login', { username, password, totp_code }), + login: (username, password, totp_code = null, remember_me = false) => + request('POST', '/auth/login', { username, password, totp_code, remember_me }), setupRequired: () => request('GET', '/auth/setup-required'), setup: (data) => request('POST', '/auth/setup', data), diff --git a/frontend/js/app.js b/frontend/js/app.js index c33fed0..cf2c277 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -141,11 +141,12 @@ function bindLoginForm() { const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; const totpCode = document.getElementById('login-totp')?.value.trim() || null; + const remember = document.getElementById('login-remember')?.checked || false; const errEl = document.getElementById('login-error'); errEl.classList.add('hidden'); try { - const res = await api.login(username, password, totpCode); + const res = await api.login(username, password, totpCode, remember); localStorage.setItem('token', res.access_token); localStorage.setItem('user', JSON.stringify(res.user)); await launchApp(); diff --git a/frontend/js/version.js b/frontend/js/version.js index d5ed145..bc8e7e5 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 = 'v5'; +export const APP_VERSION = 'v6'; diff --git a/frontend/sw.js b/frontend/sw.js index 4783304..3e9316e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v5'; +const CACHE_VERSION = 'calendarr-v6'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From 85d427f9b2e06765204b2598883f061982ee49a5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:23:35 +0200 Subject: [PATCH 060/114] =?UTF-8?q?perf:=20Event-Cache=20von=20=C2=B18=20W?= =?UTF-8?q?ochen=20auf=20=C2=B110=20Monate=20erweitern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damit lädt beim Swipen durch Monate erst nach ~10 Monaten in beide Richtungen erneut Daten nach. Vorher reichte der Cache nur ±2 Monate, sodass nach 2-3 Wischen ein Spinner kam. - CACHE_BUF 56 → 300 Tage (initial ±10 Monate) - PREFETCH_EXT 56 → 180 Tage (Verlängerung bei Edge ~6 Monate) - PREFETCH_EDGE 28 → 90 Tage (Trigger ~3 Monate vor Cache-Rand) Version v6 → v7. --- frontend/index.html | 22 +++++++++++----------- frontend/js/calendar.js | 6 +++--- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 6565e70..5233a62 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v6 + Calendarr v7 @@ -80,7 +80,7 @@ - + @@ -159,7 +159,7 @@ @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -865,7 +865,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 85ace73..267723a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -106,9 +106,9 @@ function handleHAOAuthReturn() { } // ── Event cache ─────────────────────────────────────────── -const CACHE_BUF = 56 * 86400000; // initial ±8 weeks -const PREFETCH_EXT = 56 * 86400000; // extend by 8 weeks when triggered -const PREFETCH_EDGE = 28 * 86400000; // trigger when within 4 weeks of cache edge +const CACHE_BUF = 300 * 86400000; // initial ±10 months around the view +const PREFETCH_EXT = 180 * 86400000; // extend by ~6 months when triggered +const PREFETCH_EDGE = 90 * 86400000; // trigger when within ~3 months of cache edge const eventCache = { start: null, end: null, events: [], diff --git a/frontend/js/version.js b/frontend/js/version.js index bc8e7e5..6e19206 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 = 'v6'; +export const APP_VERSION = 'v7'; diff --git a/frontend/sw.js b/frontend/sw.js index 3e9316e..63e8715 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v6'; +const CACHE_VERSION = 'calendarr-v7'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From 15388e5806605b2085206bb6f23e4e13be9b2c60 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:31:17 +0200 Subject: [PATCH 061/114] =?UTF-8?q?feat(mobile):=20Heute-Button=20im=20Top?= =?UTF-8?q?bar=20+=20runder=20FAB=20f=C3=BCr=20Termin-Erstellen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Heute"-Button auf Mobile wieder im Topbar-Center sichtbar (kompakter mit weniger Padding) statt nur im View-Popup. - Neuer runder Floating-Action-Button unten rechts auf Mobile mit Plus-Icon, öffnet das "Termin erstellen"-Modal — Google-Calendar- artige Bedienung. - Der "Erstellen"-Button in der Sidebar wird auf Mobile ausgeblendet, weil der FAB ihn ersetzt. Auf Desktop bleibt alles wie bisher. - iOS-Safe-Area unten respektiert (Home-Indicator). Version v7 → v8. --- frontend/css/app.css | 32 ++++++++++++++++++++++++++++++-- frontend/index.html | 27 ++++++++++++++++----------- frontend/js/calendar.js | 2 ++ frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 6eefc88..547de3b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1223,6 +1223,7 @@ a { color: var(--primary); text-decoration: none; } .settings-nav-toggle { display: none; } .settings-nav-backdrop { display: none; } .dropdown-item-mobile-only { display: none; } +.create-fab { display: none; } @media (max-width: 768px) { html, body { overflow-x: hidden; max-width: 100vw; } @@ -1418,8 +1419,35 @@ a { color: var(--primary); text-decoration: none; } .view-mobile-wrapper { display: inline-block; } .dropdown-item-mobile-only { display: flex; } - /* Hide "Heute" button on desktop topbar (it's in the view popup now) */ - .topbar #btn-today { display: none; } + /* Heute-Button bleibt im Topbar-Center, kompakter Stil für Mobile */ + .topbar #btn-today { + padding: 6px 10px; + font-size: 13px; + min-height: 36px; + flex-shrink: 0; + } + /* Sidebar create-button: auf Mobile durch den runden FAB ersetzt */ + #btn-create-event { display: none; } + /* Floating action button — bottom-right round button */ + .create-fab { + display: flex; + position: fixed; + bottom: calc(20px + env(safe-area-inset-bottom, 0px)); + right: 20px; + width: 56px; height: 56px; + border-radius: 50%; + background: var(--primary); + color: #fff; + align-items: center; + justify-content: center; + box-shadow: 0 4px 14px rgba(0,0,0,.5); + z-index: 450; + transition: filter var(--transition), transform var(--transition); + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + .create-fab:active { transform: scale(.95); } + .create-fab:hover { filter: brightness(1.1); } /* The title is the most important info — let it grow */ .topbar-center { flex: 1; min-width: 0; } diff --git a/frontend/index.html b/frontend/index.html index 5233a62..1ebe8b2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v7 + Calendarr v8 @@ -80,7 +80,7 @@ - + @@ -159,7 +159,7 @@ @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -844,6 +844,11 @@ + + + @@ -865,7 +870,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 267723a..9d1cc46 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -865,6 +865,8 @@ function bindTopbar() { document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.selectedDate || state.currentDate); + const fab = document.getElementById('btn-create-fab'); + if (fab) fab.onclick = () => openNewEventModal(state.selectedDate || state.currentDate); // Mobile view-toggle popup const viewMobileBtn = document.getElementById('btn-view-mobile'); diff --git a/frontend/js/version.js b/frontend/js/version.js index 6e19206..5056cca 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 = 'v7'; +export const APP_VERSION = 'v8'; diff --git a/frontend/sw.js b/frontend/sw.js index 63e8715..b987b31 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v7'; +const CACHE_VERSION = 'calendarr-v8'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From e7247d2ee1b2f6768777ea77ca56809d61b82123 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:40:20 +0200 Subject: [PATCH 062/114] fix(mobile): zweizeiliger Titel, kompaktes Event-Popup, keine Uhrzeit in Monatszelle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Titel im Topbar wird auf Mobile auf 2 Zeilen aufgeteilt: Hauptlabel (z.B. "Mai – Jun") oben, Jahr ("2026") darunter in kleinerer Schrift. Auf Desktop bleibt es einzeilig durch margin-left auf der Year-Span. - Event-Popup: 44px-Mindestgröße der Icon-Buttons greift hier nicht mehr — Buttons bleiben kompakt 32px, weniger Gap, schmaleres Popup (max 92vw / 340px), sodass das Schließen-X nicht aus dem Rand herausragt. - Monatsansicht auf Mobile: Startuhrzeit ("00:00 Lemgo") wird versteckt, nur der Titel ist sichtbar. Auf Desktop wie bisher mit Uhrzeit-Präfix. Die Info bleibt im Termin-Popup verfügbar. Version v8 → v9. --- frontend/css/app.css | 36 ++++++++++++++++++++++++++++++++++- frontend/index.html | 22 ++++++++++----------- frontend/js/calendar.js | 39 ++++++++++++++++++++++++-------------- frontend/js/version.js | 2 +- frontend/js/views/month.js | 9 +++++---- frontend/sw.js | 2 +- 6 files changed, 78 insertions(+), 32 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 547de3b..d2d4da2 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1225,6 +1225,9 @@ a { color: var(--primary); text-decoration: none; } .dropdown-item-mobile-only { display: none; } .create-fab { display: none; } +/* View-title spans: visual space between main and year on desktop */ +.view-title-year { margin-left: 6px; } + @media (max-width: 768px) { html, body { overflow-x: hidden; max-width: 100vw; } @@ -1452,14 +1455,45 @@ a { color: var(--primary); text-decoration: none; } /* The title is the most important info — let it grow */ .topbar-center { flex: 1; min-width: 0; } .topbar-center .view-title { - font-size: 17px; + font-size: 15px; font-weight: 500; padding-left: 4px; flex: 1; + line-height: 1.1; + display: flex; + flex-direction: column; + align-items: flex-start; + overflow: hidden; + } + .view-title-main { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .view-title-year { + font-size: 11px; + font-weight: 400; + color: var(--text-2); + line-height: 1.1; + margin-left: 0; } .topbar-left { gap: 0; } .topbar-right { gap: 0; } + /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */ + .event-popup .icon-btn { + min-width: 32px !important; + min-height: 32px !important; + width: 32px; + height: 32px; + } + .event-popup .popup-header { gap: 2px; padding: 10px 12px; } + .event-popup { width: min(92vw, 340px); max-width: 92vw; } + + /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ + .month-event-time { display: none; } + /* ── Settings modal: nav becomes a slide-in overlay ──────── */ .settings-nav-toggle { display: inline-flex !important; } .settings-page-body { position: relative; } diff --git a/frontend/index.html b/frontend/index.html index 1ebe8b2..12ee6ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v8 + Calendarr v9 @@ -80,7 +80,7 @@ - + @@ -159,7 +159,7 @@ @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -870,7 +870,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 9d1cc46..7f65df4 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -328,38 +328,49 @@ function showLoading() { function updateTitle() { const d = state.currentDate; - let title = ''; + let main = ''; // primary label (months / day range) + let year = ''; // year — separated so mobile can wrap to a 2nd line const M = t('months'); if (state.currentView === 'month') { - // Show date range of the rolling 5-week window const ws = weekStart(d, weekStartDay); - const we = new Date(ws); we.setDate(we.getDate() + 34); // last day of 5th week + const we = new Date(ws); we.setDate(we.getDate() + 34); const Ms = t('months_short'); if (ws.getFullYear() !== we.getFullYear()) { - title = `${Ms[ws.getMonth()]} ${ws.getFullYear()} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; + // Cross-year: keep both years inline in main, no separate year + main = `${Ms[ws.getMonth()]} ${ws.getFullYear()} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; } else if (ws.getMonth() !== we.getMonth()) { - title = `${Ms[ws.getMonth()]} – ${Ms[we.getMonth()]} ${we.getFullYear()}`; + main = `${Ms[ws.getMonth()]} – ${Ms[we.getMonth()]}`; + year = `${we.getFullYear()}`; } else { - title = `${M[ws.getMonth()]} ${ws.getFullYear()}`; + main = `${M[ws.getMonth()]}`; + year = `${ws.getFullYear()}`; } } else if (state.currentView === 'week') { const mon = weekStart(d, weekStartDay); const sun = new Date(mon); sun.setDate(mon.getDate() + 6); const sameMonth = mon.getMonth() === sun.getMonth(); - title = sameMonth - ? `${mon.getDate()}. – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}` - : `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`; + main = sameMonth + ? `${mon.getDate()}. – ${sun.getDate()}. ${M[sun.getMonth()]}` + : `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]}`; + year = `${sun.getFullYear()}`; } else if (state.currentView === 'day') { - title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`; + main = `${d.getDate()}. ${M[d.getMonth()]}`; + year = `${d.getFullYear()}`; } else if (state.currentView === 'quarter') { const q = Math.floor(d.getMonth() / 3) + 1; - title = `Q${q} ${d.getFullYear()}`; + main = `Q${q}`; + year = `${d.getFullYear()}`; } else { - title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`; + main = `${d.getDate()}. ${M[d.getMonth()]}`; + year = `${d.getFullYear()}`; } - document.getElementById('view-title').textContent = title; - document.title = `Calendarr - ${title}`; + const fullText = year ? `${main} ${year}` : main; + const titleEl = document.getElementById('view-title'); + titleEl.innerHTML = + `${main}` + + (year ? `${year}` : ''); + document.title = `Calendarr - ${fullText}`; } function updateViewButtons() { diff --git a/frontend/js/version.js b/frontend/js/version.js index 5056cca..90db215 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 = 'v8'; +export const APP_VERSION = 'v9'; diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 1b19c2a..b9a3431 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -102,13 +102,14 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC const pastCls = isPast(ev) ? 'past' : ''; const cL = continuesLeft ? 'continues-left' : ''; const cR = continuesRight ? 'continues-right' : ''; - const label = ev.allDay - ? ev.title - : `${fmtTime(new Date(ev.start))} ${ev.title}`; + const titleEsc = escHtml(ev.title); + const labelHtml = ev.allDay + ? titleEsc + : `${escHtml(fmtTime(new Date(ev.start)))} ${titleEsc}`; eventsHtml += `
${escHtml(label)}
`; + title="${escAttr(ev.title)}">${labelHtml}`; }); // "+N more" per column diff --git a/frontend/sw.js b/frontend/sw.js index b987b31..f19b29b 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v8'; +const CACHE_VERSION = 'calendarr-v9'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From e52299fc0877b69f9e555acdbbca75ce86c54cb9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 19:49:48 +0200 Subject: [PATCH 063/114] =?UTF-8?q?fix:=20Plus-Icon=20symmetrisch=20+=20Se?= =?UTF-8?q?rvice-Worker=20Network-First=20f=C3=BCr=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Erstellen-Button in der Sidebar: SVG-Path war asymmetrisch (M19 13h-6v9...) — Vertikalbalken hing nach unten heraus. Jetzt Standard-Plus mit gleich langen Armen. - Service Worker holt index.html und version.js ab sofort per Network-First — neue Releases greifen damit beim nächsten Seiten-Reload, ohne dass der SW manuell deregistriert werden muss. Statics bleiben Cache-First für Offline-Tauglichkeit; auf Aktivierung wird der alte Cache komplett gelöscht. Version v9 → v10. --- frontend/index.html | 22 +++++++++++----------- frontend/js/version.js | 2 +- frontend/sw.js | 25 ++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 12ee6ed..c61f3c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v9 + Calendarr v10 @@ -80,7 +80,7 @@ - + @@ -159,7 +159,7 @@ @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -870,7 +870,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index 90db215..31ecc0c 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 = 'v9'; +export const APP_VERSION = 'v10'; diff --git a/frontend/sw.js b/frontend/sw.js index f19b29b..11b9f36 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v9'; +const CACHE_VERSION = 'calendarr-v10'; const STATIC_ASSETS = [ '/', '/index.html', @@ -65,6 +65,29 @@ self.addEventListener('fetch', event => { return; } + // Network-first for navigation (HTML) and the version-defining files — + // ensures users always get the freshest entry point so new releases + // take effect on the next reload without a manual SW unregister. + const isHtml = req.mode === 'navigate' + || url.pathname === '/' + || url.pathname === '/index.html'; + const isVersionFile = url.pathname === '/static/js/version.js'; + + if (isHtml || isVersionFile) { + event.respondWith( + fetch(req).then(resp => { + if (resp && resp.status === 200) { + const clone = resp.clone(); + caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); + } + return resp; + }).catch(() => + caches.match(req).then(c => c || caches.match('/index.html')) + ) + ); + return; + } + // Cache-first for everything else (static) event.respondWith( caches.match(req).then(cached => { -- 2.47.3 From 15b6c90b11fada345f32b624e3bef13085aa5977 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 7 May 2026 20:01:48 +0200 Subject: [PATCH 064/114] =?UTF-8?q?fix(pwa):=20Layout=20ber=C3=BCcksichtig?= =?UTF-8?q?t=20iOS-Safe-Area=20auch=20im=20Hauptbereich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher bekam nur .topbar Safe-Area-Padding, aber .content-wrapper rechnete weiter starr mit --topbar-h. Im PWA-Standalone-Modus auf iPhones mit Notch lief der Kalender dadurch oben in die Status-Bar und unten in den Home-Indicator hinein — die Wochentag-Header und Tagesnummern der ersten Zeile waren verdeckt, die letzte Zeile zu kurz. - .content-wrapper: margin-top und height berechnen jetzt safe-top und safe-bottom mit ein - .sidebar (Mobile-Overlay): top startet ebenfalls unterhalb der vergrösserten Topbar Version v10 → v11. --- frontend/css/app.css | 15 ++++++++++++++- frontend/index.html | 20 ++++++++++---------- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d2d4da2..944e58a 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1534,7 +1534,20 @@ a { color: var(--primary); text-decoration: none; } /* iOS notch / home-indicator safe areas (PWA standalone) */ @supports (padding: env(safe-area-inset-top)) { - .topbar { padding-top: env(safe-area-inset-top); height: calc(var(--topbar-h) + env(safe-area-inset-top)); } + .topbar { + padding-top: env(safe-area-inset-top); + height: calc(var(--topbar-h) + env(safe-area-inset-top)); + } .sidebar { padding-bottom: env(safe-area-inset-bottom); } + /* The main content sits below the (now taller) topbar and must not + extend behind the home indicator at the bottom. */ + .content-wrapper { + margin-top: calc(var(--topbar-h) + env(safe-area-inset-top)); + height: calc(100vh - var(--topbar-h) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } + /* On mobile the slide-in sidebar also needs to start below the larger topbar */ + @media (max-width: 768px) { + .sidebar { top: calc(var(--topbar-h) + env(safe-area-inset-top)); } + } } diff --git a/frontend/index.html b/frontend/index.html index c61f3c6..6043c48 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v10 + Calendarr v11 @@ -80,7 +80,7 @@ - + @@ -185,7 +185,7 @@ Meine Kalender
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -870,7 +870,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index 31ecc0c..3fc6af0 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 = 'v10'; +export const APP_VERSION = 'v11'; diff --git a/frontend/sw.js b/frontend/sw.js index 11b9f36..a76f4fb 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,7 +1,7 @@ // Calendarr Service Worker // Cache-first for static assets, network-first for /api/* (graceful offline) -const CACHE_VERSION = 'calendarr-v10'; +const CACHE_VERSION = 'calendarr-v11'; const STATIC_ASSETS = [ '/', '/index.html', -- 2.47.3 From 006c1f994c1876b5004fce036370751ee8dce8cc Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 9 May 2026 16:49:52 +0200 Subject: [PATCH 065/114] feat: Monatswechsel-Markierung in Monatsansicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In der rolling Monatsansicht wird jetzt am Monatswechsel: - eine dickere Trennlinie gezeichnet (links bei Wechsel mitten in Zeile, oben bei Zeilenstart) - das 3-Buchstaben-Monatskürzel (z.B. JUL, AUG) groß über der "1" angezeigt Beide Farben (Linie und Kürzel) sind in den Einstellungen unter "Farben" individuell anpassbar (Default: #7090c0). Backend: neue UserSettings-Felder month_divider_color und month_label_color mit Migration. Frontend: applyTheme setzt entsprechende CSS-Variablen. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 12 +++++++++++ backend/models.py | 2 ++ backend/routers/settings_router.py | 4 ++++ frontend/css/app.css | 24 ++++++++++++++++++++++ frontend/index.html | 14 +++++++++++++ frontend/js/calendar.js | 32 +++++++++++++++++------------- frontend/js/i18n.js | 4 ++++ frontend/js/utils.js | 3 +++ frontend/js/views/month.js | 19 +++++++++++++++--- 9 files changed, 97 insertions(+), 17 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9c122f4..dade943 100644 --- a/backend/main.py +++ b/backend/main.py @@ -97,6 +97,18 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_label_color VARCHAR(7) DEFAULT '#7090c0'")) + conn.commit() + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 92a57ec..1892076 100644 --- a/backend/models.py +++ b/backend/models.py @@ -82,6 +82,8 @@ class UserSettings(Base): line_contrast = Column(Integer, default=3) hour_height = Column(Integer, default=60) language = Column(String(5), default="de") + month_divider_color = Column(String(7), default="#7090c0") + month_label_color = Column(String(7), default="#7090c0") user = relationship("User", back_populates="settings") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 967b4c1..a82ecb0 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -22,6 +22,8 @@ class SettingsUpdate(BaseModel): line_contrast: Optional[int] = None hour_height: Optional[int] = None language: Optional[str] = None + month_divider_color: Optional[str] = None + month_label_color: Optional[str] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -36,6 +38,8 @@ def _settings_dict(s: models.UserSettings) -> dict: "line_contrast": s.line_contrast or 3, "hour_height": s.hour_height or 60, "language": s.language or "de", + "month_divider_color": s.month_divider_color or "#7090c0", + "month_label_color": s.month_label_color or "#7090c0", } diff --git a/frontend/css/app.css b/frontend/css/app.css index 944e58a..eb1455d 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -497,6 +497,30 @@ a { color: var(--primary); text-decoration: none; } border-radius: 50%; flex-shrink: 0; } .cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } + +/* Month boundary marker: thicker line + month abbreviation on the 1st */ +.month-col.first-of-month { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; +} +.month-col.month-divider-left { + box-shadow: inset 3px 0 0 0 var(--month-divider-color, #7090c0); +} +.month-row.month-divider-top { + box-shadow: inset 0 3px 0 0 var(--month-divider-color, #7090c0); +} +.month-marker { + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--month-label-color, #7090c0); + line-height: 1; + padding: 0 2px; + margin-bottom: -2px; +} /* Events overlay — pointer-events:none so clicks pass to columns */ .month-events-overlay { position: absolute; top: 30px; left: 0; right: 0; bottom: 0; diff --git a/frontend/index.html b/frontend/index.html index 6043c48..b145cc6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -603,6 +603,20 @@
+
+ +
+ +
+
+
+
+ +
+ +
+
+

Schriftkontrast

Helligkeit der Beschriftungen und Texte

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 7f65df4..061431e 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1966,9 +1966,11 @@ function openSettingsModal() { 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-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(); @@ -2291,7 +2293,7 @@ async function loadUsers() { } function bindSettingsModal() { - ['cfg-primary','cfg-accent','cfg-today'].forEach(prefix => { + ['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 () => { @@ -2345,16 +2347,18 @@ function bindSettingsModal() { return btn ? Number(btn.dataset.val) : 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, - dim_past_events: document.getElementById('cfg-dim-past').checked, - text_contrast: getActive('cfg-text-contrast') || 3, - line_contrast: getActive('cfg-line-contrast') || 3, - hour_height: getActive('cfg-hour-height') || 44, - language: document.getElementById('cfg-language').value, + 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, + dim_past_events: document.getElementById('cfg-dim-past').checked, + text_contrast: getActive('cfg-text-contrast') || 3, + line_contrast: getActive('cfg-line-contrast') || 3, + hour_height: getActive('cfg-hour-height') || 44, + language: document.getElementById('cfg-language').value, }; try { await api.put('/settings/', settings); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 66a15f4..b484017 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -65,6 +65,8 @@ const translations = { settings_colors: 'Farben', settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe', settings_today_color: 'Heutige-Tag-Farbe', + settings_month_divider_color: 'Monatswechsel-Linie', + settings_month_label_color: 'Monatskürzel-Farbe', settings_text_contrast: 'Schriftkontrast', settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte', contrast_dark: 'Dunkel', contrast_medium: 'Mittel', @@ -274,6 +276,8 @@ const translations = { settings_colors: 'Colors', settings_primary_color: 'Primary color', settings_accent_color: 'Accent color', settings_today_color: 'Today highlight color', + settings_month_divider_color: 'Month divider line', + settings_month_label_color: 'Month label color', settings_text_contrast: 'Text contrast', settings_text_contrast_desc: 'Brightness of labels and text', contrast_dark: 'Dark', contrast_medium: 'Medium', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 58a151a..5db6786 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -94,6 +94,9 @@ export function applyTheme(settings) { const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); + + root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0'); + root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0'); } function hexToRgba(hex, alpha) { diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index b9a3431..fb65ac3 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -121,8 +121,9 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC }); // Full-height column divs (click targets + borders) + const monthsShort = t('months_short'); let colsHtml = ''; - rowCells.forEach(cell => { + rowCells.forEach((cell, idx) => { const key = dateKey(cell); const isOther = cell.getMonth() !== primaryMonth; const todayCls = isToday(cell) ? 'today' : ''; @@ -130,12 +131,24 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC const selDate = selectedDate || currentDate; const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : ''; const numCls = isToday(cell) ? 'today' : ''; - colsHtml += `
+ // First-of-month marker: show month abbreviation, push day number below + const isFirstOfMonth = cell.getDate() === 1; + const firstCls = isFirstOfMonth ? 'first-of-month' : ''; + // Add divider class on the cell BEFORE a month change (for right border styling) + // and on the cell AT a month change (for left border styling) — except at row start + const dividerCls = (isFirstOfMonth && idx > 0) ? 'month-divider-left' : ''; + const monthLabel = isFirstOfMonth + ? `
${monthsShort[cell.getMonth()]}
` + : ''; + colsHtml += `
+ ${monthLabel}
${cell.getDate()}
`; }); - bodyHtml += `
+ // If the row starts on the 1st of a new month, draw a divider above the row + const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : ''; + bodyHtml += `
${kw}
${colsHtml} -- 2.47.3 From 496d4e5745cc973c836f361c29dd65bc49a66da7 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 9 May 2026 17:08:17 +0200 Subject: [PATCH 066/114] =?UTF-8?q?fix:=20Monatswechsel-Markierung=20?= =?UTF-8?q?=E2=80=93=20Linien=20=C3=BCber=20Events,=20mehr=20Abstand,=20im?= =?UTF-8?q?mer=20waagerecht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Korrekturen: - Linie verschwand hinter Events: Pseudo-Elemente mit z-index 4 statt box-shadow inset, damit Trennlinien immer über den Event-Bars liegen - Waagerechte Linie auch bei Monatswechsel mitten in einer Zeile (vorher nur wenn der Monat am Zeilenanfang begann) - "1" verschwand hinter Events: cell-day und month-marker bekommen z-index 3 + position relative, plus Events-Overlay wird in Zeilen mit Monatsmarker um ~26px nach unten geschoben - Mehr Abstand zwischen Monatskürzel und Trennlinie (padding-top 8px, margin-bottom am marker positiv statt negativ) Co-Authored-By: Claude Opus 4.6 --- frontend/css/app.css | 49 ++++++++++++++++++++++++++++++++++---- frontend/js/views/month.js | 23 ++++++++++++++---- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index eb1455d..f075962 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -499,17 +499,46 @@ a { color: var(--primary); text-decoration: none; } .cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } /* Month boundary marker: thicker line + month abbreviation on the 1st */ +.month-col { + position: relative; /* anchor for divider pseudo-elements */ +} .month-col.first-of-month { display: flex; flex-direction: column; align-items: flex-start; gap: 0; + padding-top: 8px; } -.month-col.month-divider-left { - box-shadow: inset 3px 0 0 0 var(--month-divider-color, #7090c0); +/* Dividers via pseudo-elements so they render above events (z-index 2) */ +.month-col.month-divider-left::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--month-divider-color, #7090c0); + z-index: 4; + pointer-events: none; +} +.month-col.month-divider-top::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--month-divider-color, #7090c0); + z-index: 4; + pointer-events: none; } .month-row.month-divider-top { - box-shadow: inset 0 3px 0 0 var(--month-divider-color, #7090c0); + position: relative; +} +.month-row.month-divider-top::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--month-divider-color, #7090c0); + z-index: 4; + pointer-events: none; } .month-marker { font-size: 14px; @@ -519,7 +548,19 @@ a { color: var(--primary); text-decoration: none; } color: var(--month-label-color, #7090c0); line-height: 1; padding: 0 2px; - margin-bottom: -2px; + margin-bottom: 2px; + position: relative; + z-index: 3; /* above events overlay (z-index 2) */ +} +/* Make sure the day number stays visible above events */ +.cell-day { + position: relative; + z-index: 3; +} +/* Push events overlay down when row contains a first-of-month cell so the + day "1" (which sits below the month marker) isn't hidden by event bars */ +.month-row.has-month-marker .month-events-overlay { + top: 56px; } /* Events overlay — pointer-events:none so clicks pass to columns */ .month-events-overlay { diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index fb65ac3..4a019c4 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -120,6 +120,13 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}
`; }); + // Detect a month boundary in this row. monthChangeIdx is the index of + // the first-of-month cell, or -1 if the row doesn't span a month change. + let monthChangeIdx = -1; + rowCells.forEach((cell, idx) => { + if (cell.getDate() === 1 && idx > 0) monthChangeIdx = idx; + }); + // Full-height column divs (click targets + borders) const monthsShort = t('months_short'); let colsHtml = ''; @@ -134,9 +141,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // First-of-month marker: show month abbreviation, push day number below const isFirstOfMonth = cell.getDate() === 1; const firstCls = isFirstOfMonth ? 'first-of-month' : ''; - // Add divider class on the cell BEFORE a month change (for right border styling) - // and on the cell AT a month change (for left border styling) — except at row start - const dividerCls = (isFirstOfMonth && idx > 0) ? 'month-divider-left' : ''; + // Vertical line at month change, horizontal line spanning from change to end of row + const dividerClasses = []; + if (isFirstOfMonth && idx > 0) dividerClasses.push('month-divider-left'); + if (monthChangeIdx > 0 && idx >= monthChangeIdx) dividerClasses.push('month-divider-top'); + const dividerCls = dividerClasses.join(' '); const monthLabel = isFirstOfMonth ? `
${monthsShort[cell.getMonth()]}
` : ''; @@ -146,9 +155,13 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
`; }); - // If the row starts on the 1st of a new month, draw a divider above the row + // If the row starts on the 1st of a new month, draw a full-width divider above the row const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : ''; - bodyHtml += `
+ // If any cell in the row is first-of-month, push events overlay down so the day + // number isn't hidden by spanning event bars + const hasMonthMarker = rowCells.some(c => c.getDate() === 1); + const rowMarkerCls = hasMonthMarker ? 'has-month-marker' : ''; + bodyHtml += `
${kw}
${colsHtml} -- 2.47.3 From 87ebc22d17de84cf4f1aea7e7661dfc391d2ab5a Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 9 May 2026 17:12:57 +0200 Subject: [PATCH 067/114] =?UTF-8?q?fix:=20Stufenf=C3=B6rmige=20Monatsgrenz?= =?UTF-8?q?e=20=E2=80=93=20auch=20waagerechte=20Linie=20unter=20Vormonats-?= =?UTF-8?q?Tagen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Trennlinie hat jetzt eine 'Stufen'-Form: unten unter den letzten Tagen des Vormonats in derselben Zeile, dann links runter zum 1. des neuen Monats, dann oben über die ersten Tage des neuen Monats. So ist die Monatsgrenze visuell vollständig umrandet. Co-Authored-By: Claude Opus 4.6 --- frontend/css/app.css | 11 +++++++++++ frontend/js/views/month.js | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index f075962..35ddf75 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -528,6 +528,17 @@ a { color: var(--primary); text-decoration: none; } z-index: 4; pointer-events: none; } +/* Bottom divider on the previous-month cells in the same row as the change. + Uses ::before because these cells never have month-divider-left. */ +.month-col.month-divider-bottom::before { + content: ''; + position: absolute; + bottom: 0; left: 0; right: 0; + height: 3px; + background: var(--month-divider-color, #7090c0); + z-index: 4; + pointer-events: none; +} .month-row.month-divider-top { position: relative; } diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 4a019c4..e213a75 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -141,10 +141,12 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // First-of-month marker: show month abbreviation, push day number below const isFirstOfMonth = cell.getDate() === 1; const firstCls = isFirstOfMonth ? 'first-of-month' : ''; - // Vertical line at month change, horizontal line spanning from change to end of row + // Step-shaped boundary at month change: bottom under the previous-month + // tail, vertical at the change, top across the new-month head. const dividerClasses = []; if (isFirstOfMonth && idx > 0) dividerClasses.push('month-divider-left'); if (monthChangeIdx > 0 && idx >= monthChangeIdx) dividerClasses.push('month-divider-top'); + if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom'); const dividerCls = dividerClasses.join(' '); const monthLabel = isFirstOfMonth ? `
${monthsShort[cell.getMonth()]}
` -- 2.47.3 From 74ebf6465d1ef1508e0fbbe9ba9c93147a3221c6 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 9 May 2026 17:17:36 +0200 Subject: [PATCH 068/114] =?UTF-8?q?fix:=20Monatsk=C3=BCrzel=20~4px=20(?= =?UTF-8?q?=E2=89=881mm)=20nach=20rechts=20verschoben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 35ddf75..556a5db 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -559,7 +559,7 @@ a { color: var(--primary); text-decoration: none; } color: var(--month-label-color, #7090c0); line-height: 1; padding: 0 2px; - margin-bottom: 2px; + margin: 0 0 2px 4px; position: relative; z-index: 3; /* above events overlay (z-index 2) */ } -- 2.47.3 From 05e55b33265125f386fd32f091517cab6fbb49f6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 9 May 2026 17:54:31 +0200 Subject: [PATCH 069/114] =?UTF-8?q?fix:=20Impressum=20=E2=80=93=20Datenspe?= =?UTF-8?q?icherungsabschnitt=20korrigiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speicherort hängt vom Server-Betreiber ab, nicht hardcoded "Schweiz" - Home Assistant-Anbindung erwähnt mit Hinweis auf Datenaustausch - Trademark-Hinweis: Home Assistant ist eingetragene Marke der Home Assistant Foundation Co-Authored-By: Claude Opus 4.6 --- frontend/index.html | 2 +- frontend/js/i18n.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index b145cc6..3e34f79 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -877,7 +877,7 @@ Software & Webentwicklung

Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.

Datenspeicherung
- Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.

+ Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist eine eingetragene Marke der Home Assistant Foundation.

Haftungsausschluss
Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.

Kontakt
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index b484017..9590105 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -130,7 +130,7 @@ const translations = { impressum_desc: 'Software & Webentwicklung', impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.', impressum_data_title: 'Datenspeicherung', - impressum_data_text: 'Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.', + impressum_data_text: 'Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist eine eingetragene Marke der Home Assistant Foundation.', impressum_disclaimer_title: 'Haftungsausschluss', impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.', impressum_contact: 'Kontakt', @@ -341,7 +341,7 @@ const translations = { impressum_desc: 'Software & Web Development', impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.', impressum_data_title: 'Data Storage', - impressum_data_text: 'All application data is stored and processed exclusively in Switzerland. When using the Google Calendar integration, data is retrieved via the Google API, which Google processes on their infrastructure outside Switzerland. Google\'s privacy policy applies to this data.', + impressum_data_text: 'All application data is stored and processed on the server hosting this Calendarr instance. The storage location therefore depends on whoever operates that server. When using the Google Calendar integration, data is exchanged via the Google API; Google\'s privacy policy applies to this data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a registered trademark of the Home Assistant Foundation.', impressum_disclaimer_title: 'Disclaimer', impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.', impressum_contact: 'Contact', -- 2.47.3 From 50c19c799976f36e42427e84f85a3f00b50725cd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 9 May 2026 18:03:47 +0200 Subject: [PATCH 070/114] =?UTF-8?q?fix:=20Impressum=20=E2=80=93=20Open=20H?= =?UTF-8?q?ome=20Foundation=20statt=20Home=20Assistant=20Foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 2 +- frontend/js/i18n.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 3e34f79..8197413 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -877,7 +877,7 @@ Software & Webentwicklung

Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.

Datenspeicherung
- Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist eine eingetragene Marke der Home Assistant Foundation.

+ Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.

Haftungsausschluss
Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.

Kontakt
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 9590105..ab9310b 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -130,7 +130,7 @@ const translations = { impressum_desc: 'Software & Webentwicklung', impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.', impressum_data_title: 'Datenspeicherung', - impressum_data_text: 'Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist eine eingetragene Marke der Home Assistant Foundation.', + impressum_data_text: 'Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.', impressum_disclaimer_title: 'Haftungsausschluss', impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.', impressum_contact: 'Kontakt', @@ -341,7 +341,7 @@ const translations = { impressum_desc: 'Software & Web Development', impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.', impressum_data_title: 'Data Storage', - impressum_data_text: 'All application data is stored and processed on the server hosting this Calendarr instance. The storage location therefore depends on whoever operates that server. When using the Google Calendar integration, data is exchanged via the Google API; Google\'s privacy policy applies to this data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a registered trademark of the Home Assistant Foundation.', + impressum_data_text: 'All application data is stored and processed on the server hosting this Calendarr instance. The storage location therefore depends on whoever operates that server. When using the Google Calendar integration, data is exchanged via the Google API; Google\'s privacy policy applies to this data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.', impressum_disclaimer_title: 'Disclaimer', impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.', impressum_contact: 'Contact', -- 2.47.3 From ba86092cc845a11893e5d599c33c6536c25fcf77 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 10 May 2026 11:01:13 +0200 Subject: [PATCH 071/114] =?UTF-8?q?fix:=20Wochenansicht=20=E2=80=93=20ganz?= =?UTF-8?q?t=C3=A4gige=20Termine=20spannen=20sich=20nicht=20mehr=20=C3=BCb?= =?UTF-8?q?er=20zwei=20Tage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei der Layout-Berechnung für ganztägige Termine wurden Start/Ende als UTC-Zeitstempel mit der Lokal-Zeit der Tagesgrenze verglichen — das führte in Zeitzonen mit positivem UTC-Offset (z.B. CET) dazu, dass das exklusive DTEND zwei Stunden in den nächsten Tag hineinragte und die UI den Termin auf zwei Tagen darstellte. Fix: Für ganztägige Events normalisieren wir auf reine Datumswerte (setHours(0,0,0,0)) und ziehen einen Tag vom End-Datum ab, sodass die Vergleiche dieselbe inklusive Semantik wie die Monatsansicht nutzen. Timed Events behalten die ursprüngliche strict-overlap Logik. Auch die continues-left/right Marker arbeiten jetzt mit den normalisierten Daten. Co-Authored-By: Claude Opus 4.6 --- frontend/js/views/week.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index bc440bc..e84c0cb 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -63,8 +63,19 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const color = ev.color || ev.calendarColor || '#4285f4'; const pastCls = isPast(ev) ? 'past' : ''; const multiCls = isMultiTimed ? 'multiday-timed' : ''; - const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : ''; - const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : ''; + // continues-left/right: compute on date-only basis for all-day events + let evStart = new Date(ev.start); + let evEnd = new Date(ev.end); + if (ev.allDay) { + evStart.setHours(0, 0, 0, 0); + evEnd.setHours(0, 0, 0, 0); + if (evEnd > evStart) evEnd.setDate(evEnd.getDate() - 1); + } + const firstDay = new Date(days[0]); firstDay.setHours(0, 0, 0, 0); + const lastDayMidnight = new Date(days[n-1]); lastDayMidnight.setHours(24, 0, 0, 0); + const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0); + const cL = evStart < firstDay ? 'continues-left' : ''; + const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : ''; const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; @@ -236,11 +247,28 @@ function renderNowLine(container, days, hourH = 60) { function layoutWeekAllDay(evs, days) { const items = []; evs.forEach(ev => { + // For all-day events, normalize to date-only with inclusive end-day + // (iCal stores exclusive end → subtract 1). For timed events, keep + // the original strict-overlap logic so events ending exactly at + // midnight don't bleed into the next day. + let ns, ne; + if (ev.allDay) { + ns = new Date(ev.start); ns.setHours(0, 0, 0, 0); + ne = new Date(ev.end); ne.setHours(0, 0, 0, 0); + if (ne > ns) ne.setDate(ne.getDate() - 1); + } + let colStart = -1, colEnd = -1; days.forEach((day, i) => { const ds = new Date(day); ds.setHours(0, 0, 0, 0); - const de = new Date(day); de.setHours(24, 0, 0, 0); - if (new Date(ev.start) < de && new Date(ev.end) > ds) { + let matches; + if (ev.allDay) { + matches = ds >= ns && ds <= ne; + } else { + const de = new Date(day); de.setHours(24, 0, 0, 0); + matches = new Date(ev.start) < de && new Date(ev.end) > ds; + } + if (matches) { if (colStart === -1) colStart = i; colEnd = i; } -- 2.47.3 From 3152c744a0a994a23c61a76133775c4a4e18d9f0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 10 May 2026 13:15:28 +0200 Subject: [PATCH 072/114] =?UTF-8?q?feat:=20URL-State=20=E2=80=93=20Reload?= =?UTF-8?q?=20erh=C3=A4lt=20View=20und=20Datum=20statt=20auf=20heute=20zu?= =?UTF-8?q?=20springen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aktuelle View und Datum werden als URL-Hash gespiegelt (#date=YYYY-MM-DD&view=). Beim Init liest initCalendar() den Hash und überschreibt damit die Defaults (settings.default_view + today). fetchAndRender() schreibt nach jedem Render den aktuellen State zurück (replaceState, damit prev/next-Clicks keinen History-Müll erzeugen). Browser-Back/Forward funktioniert via hashchange-Listener. Edge case: HA-OAuth-Callback erhält jetzt den Hash beim URL-Cleanup (window.location.pathname + window.location.hash statt nur pathname). Komplett Frontend-only — kein Backend-Touch. Co-Authored-By: Claude Opus 4.6 --- frontend/js/calendar.js | 59 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 061431e..e5fac64 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -40,6 +40,38 @@ let state = { selectedEventColor: '', // '' = use calendar color }; +// ── URL state ──────────────────────────────────────────── +// View + Date werden in der URL als #date=YYYY-MM-DD&view= gespiegelt, +// damit Reload/PWA-restore den letzten Stand wiederherstellen statt auf +// heute zu springen. +const VALID_VIEWS = ['month', 'week', 'day', 'quarter', 'agenda']; + +function readUrlState() { + const hash = window.location.hash.replace(/^#/, ''); + if (!hash) return {}; + const params = new URLSearchParams(hash); + const out = {}; + const view = params.get('view'); + if (view && VALID_VIEWS.includes(view)) out.view = view; + const date = params.get('date'); + if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + const d = new Date(date + 'T00:00:00'); + if (!isNaN(d.getTime())) out.date = d; + } + return out; +} + +function writeUrlState() { + const d = state.currentDate; + const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + const newHash = `date=${dateStr}&view=${state.currentView}`; + if (window.location.hash.replace(/^#/,'') !== newHash) { + // replaceState statt pushState: prev/next-Klicks sollen nicht jeden + // einzelnen Tag in den Browser-History-Stack drücken + window.history.replaceState(null, '', '#' + newHash); + } +} + // ── Public init ─────────────────────────────────────────── export async function initCalendar() { const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([ @@ -61,6 +93,11 @@ export async function initCalendar() { state.dimPast = settings.dim_past_events; weekStartDay = settings.week_start_day || 'monday'; + // URL state takes precedence over defaults (settings + today) + const urlState = readUrlState(); + if (urlState.date) state.currentDate = urlState.date; + if (urlState.view) state.currentView = urlState.view; + setLang(settings.language || 'de'); applyTheme(settings); updateViewButtons(); @@ -78,6 +115,22 @@ export async function initCalendar() { bindProfileModal(); bindSwipeNavigation(); handleHAOAuthReturn(); + + // Browser-Back/Forward: URL-Hash → State synchronisieren + window.addEventListener('hashchange', () => { + const u = readUrlState(); + let changed = false; + if (u.view && u.view !== state.currentView) { + state.currentView = u.view; + updateViewButtons(); + changed = true; + } + if (u.date && !isSameDay(u.date, state.currentDate)) { + state.currentDate = u.date; + changed = true; + } + if (changed) fetchAndRender(); + }); } function handleHAOAuthReturn() { @@ -91,7 +144,7 @@ function handleHAOAuthReturn() { }; if (params.has('ha_connected')) { showToast('Home Assistant verbunden'); - window.history.replaceState({}, '', window.location.pathname); + window.history.replaceState({}, '', window.location.pathname + window.location.hash); fetchAndRender(true); api.get('/homeassistant/accounts').then(accs => { state.haAccounts = accs || []; @@ -101,7 +154,7 @@ function handleHAOAuthReturn() { } else if (params.has('ha_error')) { const code = params.get('ha_error'); showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); - window.history.replaceState({}, '', window.location.pathname); + window.history.replaceState({}, '', window.location.pathname + window.location.hash); } } @@ -196,6 +249,7 @@ async function fetchAndRender(force = false, silent = false) { renderView(); updateTitle(); renderMiniCal(); + writeUrlState(); prefetchIfNeeded(start, end); // extend cache in background if approaching an edge return; } @@ -224,6 +278,7 @@ async function fetchAndRender(force = false, silent = false) { renderView(); updateTitle(); renderMiniCal(); + writeUrlState(); } function getViewRange() { -- 2.47.3 From 199a65e2a5d6284e592965abda3fcc93eef1aa80 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 07:44:25 +0200 Subject: [PATCH 073/114] fix: Caching auf max 2 h reduzieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher konnten alte JS-/CSS-Dateien durch Service-Worker- und Browser- Cache hartnäckig hängen bleiben, obwohl auf dem Server schon eine neue Version lag. Strategie jetzt: Backend (main.py) - Neue HTTP-Middleware setzt explizite Cache-Control-Header: * /, /index.html, /manifest.json, /sw.js, /static/js/version.js bekommen no-cache, no-store, must-revalidate * /static/* und /icons/* bekommen public, max-age=7200, must-revalidate (2 h) * SPA-Fallback-Antworten ebenfalls no-cache * /api/* bleibt unangetastet Service Worker (sw.js) - Wechsel von Cache-First zu Network-First für alles - Cache wird nur noch für die index.html-Offline-Hülle vorgehalten, nicht mehr für JS/CSS — Browser-HTTP-Cache übernimmt das mit den 2-h-Headern vom Server - Bei Netzwerkfehler bleibt nur die HTML-Shell offline verfügbar Version v11 → v12 (auch SW-Cache-Key). --- backend/main.py | 35 +++++++++++++- frontend/index.html | 20 ++++---- frontend/js/version.js | 2 +- frontend/sw.js | 107 ++++++++++++++--------------------------- 4 files changed, 82 insertions(+), 82 deletions(-) diff --git a/backend/main.py b/backend/main.py index dade943..5225ef6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,11 +4,16 @@ import sys from pathlib import Path import uvicorn -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import text +# How long the browser may keep static assets before revalidating. +STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours +NO_CACHE = "no-cache, no-store, must-revalidate" +STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" + sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine @@ -113,6 +118,34 @@ _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) + +@app.middleware("http") +async def add_cache_headers(request: Request, call_next): + """Force ≤ 2h browser cache for static assets and disable cache for the + entry HTML / SW / version file. API responses are left alone (handlers + decide their own caching).""" + response = await call_next(request) + path = request.url.path + + # Never cache: entry HTML, manifest, service worker, version marker + if ( + path in ("/", "/index.html", "/manifest.json", "/sw.js") + or path == "/static/js/version.js" + ): + response.headers["Cache-Control"] = NO_CACHE + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + # 2h cache for the rest of the frontend (JS/CSS/icons/etc.) + elif path.startswith("/static/") or path.startswith("/icons/"): + response.headers["Cache-Control"] = STATIC_CACHE + # SPA fallback (everything else that isn't an API route) returns HTML; + # don't let the browser cache that either. + elif not path.startswith("/api/"): + response.headers["Cache-Control"] = NO_CACHE + + return response + + app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) diff --git a/frontend/index.html b/frontend/index.html index 8197413..96a3587 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v11 + Calendarr v12 @@ -80,7 +80,7 @@

- +
@@ -185,7 +185,7 @@ Meine Kalender
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -884,7 +884,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index 3fc6af0..0ad52c0 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 = 'v11'; +export const APP_VERSION = 'v12'; diff --git a/frontend/sw.js b/frontend/sw.js index a76f4fb..e94ffbc 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,39 +1,21 @@ -// Calendarr Service Worker -// Cache-first for static assets, network-first for /api/* (graceful offline) +// Calendarr Service Worker — minimal-cache strategy +// +// Strategy: network-first for everything. The cache is only used as a +// last-resort fallback when offline (so the app shell still opens). This +// means every online request hits the network and respects the +// server's Cache-Control headers (≤ 2h for static assets, no-cache for +// the entry HTML / version files). New releases take effect on the next +// reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v11'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/manifest.json', - '/static/css/app.css', - '/static/favicon.svg', - '/static/js/app.js', - '/static/js/api.js', - '/static/js/calendar.js', - '/static/js/color-picker.js', - '/static/js/date-picker.js', - '/static/js/i18n.js', - '/static/js/utils.js', - '/static/js/version.js', - '/static/js/views/agenda.js', - '/static/js/views/month.js', - '/static/js/views/quarter.js', - '/static/js/views/week.js', - '/icons/icon-192.png', - '/icons/icon-512.png', - '/icons/icon.svg', -]; +const CACHE_VERSION = 'calendarr-v12'; +const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_VERSION).then(cache => - // Use addAll with a fallback so a single missing file doesn't abort install - Promise.all( - STATIC_ASSETS.map(url => - cache.add(url).catch(err => console.warn('[SW] skip', url, err)) - ) - ) + Promise.all(OFFLINE_SHELL.map(url => + cache.add(url).catch(err => console.warn('[SW] skip', url, err)) + )) ).then(() => self.skipWaiting()) ); }); @@ -52,7 +34,8 @@ self.addEventListener('fetch', event => { const url = new URL(req.url); - // Network-first for API routes — fail silently if offline + // API routes: always go to the network, no offline fallback (we'd just + // be returning stale account/event data otherwise). if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(req).catch(() => @@ -65,45 +48,29 @@ self.addEventListener('fetch', event => { return; } - // Network-first for navigation (HTML) and the version-defining files — - // ensures users always get the freshest entry point so new releases - // take effect on the next reload without a manual SW unregister. - const isHtml = req.mode === 'navigate' - || url.pathname === '/' - || url.pathname === '/index.html'; - const isVersionFile = url.pathname === '/static/js/version.js'; - - if (isHtml || isVersionFile) { - event.respondWith( - fetch(req).then(resp => { - if (resp && resp.status === 200) { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => - caches.match(req).then(c => c || caches.match('/index.html')) - ) - ); - return; - } - - // Cache-first for everything else (static) + // Everything else: network-first. The browser's HTTP cache (driven by + // the server's Cache-Control headers) already throttles re-fetches — + // the SW just makes sure offline still works for the entry HTML. event.respondWith( - caches.match(req).then(cached => { - if (cached) return cached; - return fetch(req).then(resp => { - // Only cache successful, basic-origin responses - if (resp && resp.status === 200 && resp.type === 'basic') { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => { - // Offline fallback for navigation requests - if (req.mode === 'navigate') return caches.match('/index.html'); - return new Response('', { status: 503 }); - }); + fetch(req).then(resp => { + // Keep a fresh copy of navigation requests / index.html for offline + const isNavigation = req.mode === 'navigate' + || url.pathname === '/' + || url.pathname === '/index.html'; + if (isNavigation && resp && resp.status === 200) { + const clone = resp.clone(); + caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); + } + return resp; + }).catch(() => { + // Offline fallback: only the HTML shell is served from cache, so the + // app at least renders and can show its own offline UI. + if (req.mode === 'navigate' + || url.pathname === '/' + || url.pathname === '/index.html') { + return caches.match(req).then(c => c || caches.match('/index.html')); + } + return new Response('', { status: 503 }); }) ); }); -- 2.47.3 From 9013f57d024a1c18b4000b5682c72a5a618a22b7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 07:56:13 +0200 Subject: [PATCH 074/114] feat(ui): Buttons im modernen Pill-Stil + Plus-Icon fixen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Großes Frontend-Update für alle Buttons. Der Stil orientiert sich an modernen App-Designs (Pill mit dezentem Schatten, sanft "abhebender" Hover-Effekt), die Farbe folgt der gewählten Primärfarbe des Users dynamisch via color-mix(). - .btn: fully rounded (border-radius: 999px), grösseres Padding, smooth Transitions für Schatten/Transform/Brightness - .btn-primary: Primärfarbe als Hintergrund + dezenter farbiger Schatten; Hover hebt um 1px, Schatten wird kräftiger, leichte Aufhellung - .btn-secondary: dezenter Border, auf Hover wird er primär-farben - .btn-ghost / .btn-danger entsprechend angepasst - .btn-fab (Sidebar "Erstellen"): jetzt in Primärfarbe statt grau, passt zum FAB unten rechts auf Mobile und zur Marken-Sprache - .icon-btn: kleines Scale-Down beim Drücken, Focus-Ring sichtbar für Tastatur-Nutzer - Form-Inputs: 8px Radius, sanfter Hover-Border, beim Focus jetzt Primärfarben-Ring (color-mix-Glow) Fix: kaputtes Plus-SVG am Kalender-Hinzufügen-Button — Vertikalbalken war zu lang (v12 statt v6), jetzt symmetrisch. Version v12 → v13. --- frontend/css/app.css | 131 ++++++++++++++++++++++++++++++++++------- frontend/index.html | 20 +++---- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 121 insertions(+), 34 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 556a5db..7546367 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -55,49 +55,129 @@ a { color: var(--primary); text-decoration: none; } .flex-col { display: flex; flex-direction: column; } .gap-8 { gap: 8px; } -/* ── Buttons ────────────────────────────────────────────── */ +/* ── Buttons ────────────────────────────────────────────── + Modern pill style: fully rounded, subtle coloured shadow on the + prominent variants, lift on hover, snap back on press. The + primary-coloured glow follows --primary via color-mix(), so it adapts + when the user changes the theme colour in settings. */ .btn { - display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; border-radius: 20px; - font-weight: 500; transition: background var(--transition), color var(--transition); + display: inline-flex; align-items: center; justify-content: center; + gap: 8px; + padding: 10px 22px; + border-radius: 999px; + font-weight: 500; font-size: 14px; + letter-spacing: .1px; white-space: nowrap; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + border-color var(--transition), + box-shadow .18s ease, + transform .12s ease, + filter var(--transition); } +.btn:active { transform: translateY(0) scale(.985); transition-duration: .05s; } +.btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + .btn-primary { background: var(--primary); color: #fff; + box-shadow: 0 2px 8px rgba(66,133,244,.28); + box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent); } -.btn-primary:hover { filter: brightness(1.12); } +.btn-primary:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(66,133,244,.42); + box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 45%, transparent); +} + .btn-secondary { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); } -.btn-secondary:hover { background: var(--bg-hover); } -.btn-ghost { color: var(--primary); } -.btn-ghost:hover { background: var(--primary-dim); } -.btn-danger { background: var(--accent); color: #fff; } -.btn-danger:hover { filter: brightness(1.1); } -.btn-full { width: 100%; justify-content: center; } +.btn-secondary:hover { + background: var(--bg-hover); + border-color: var(--primary); + transform: translateY(-1px); +} + +.btn-ghost { + color: var(--primary); + background: transparent; +} +.btn-ghost:hover { + background: var(--primary-dim); + transform: translateY(-1px); +} + +.btn-danger { + background: var(--accent); + color: #fff; + box-shadow: 0 2px 8px rgba(234,67,53,.28); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent); +} +.btn-danger:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(234,67,53,.42); + box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.btn-full { width: 100%; } + +/* The big sidebar "Erstellen" button: same pill aesthetic, primary tinted, + lives in the calm dark sidebar so the shadow is a touch stronger. */ .btn-fab { display: flex; align-items: center; gap: 10px; - padding: 12px 20px; border-radius: 24px; - background: var(--bg-surface); - color: var(--text-1); + padding: 12px 22px; + border-radius: 999px; + background: var(--primary); + color: #fff; font-weight: 600; - box-shadow: var(--shadow); margin: 16px 12px 8px; - transition: background var(--transition), box-shadow var(--transition); + box-shadow: 0 4px 14px rgba(66,133,244,.32); + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent); + transition: + background var(--transition), + box-shadow .18s ease, + transform .12s ease, + filter var(--transition); } -.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); } +.btn-fab:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 8px 22px rgba(66,133,244,.5); + box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 50%, transparent); +} +.btn-fab:active { transform: translateY(0) scale(.985); } +/* Circular icon buttons (topbar nav, modal close, etc.) */ .icon-btn { display: inline-flex; align-items: center; justify-content: center; - width: 40px; height: 40px; border-radius: 50%; - color: var(--text-2); transition: background var(--transition); + width: 40px; height: 40px; + border-radius: 50%; + color: var(--text-2); flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + transform .1s ease; } .icon-btn svg { width: 20px; height: 20px; fill: currentColor; } .icon-btn:hover { background: var(--bg-hover); color: var(--text-1); } +.icon-btn:active { transform: scale(.92); } +.icon-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} /* ── Auth Screens ───────────────────────────────────────── */ .auth-screen { @@ -140,15 +220,22 @@ a { color: var(--primary); text-decoration: none; } .form-group input, .form-group select, .form-group textarea { background: var(--bg-app); border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; + border-radius: 8px; + padding: 11px 14px; color: var(--text-1); outline: none; - transition: border-color var(--transition); + transition: border-color var(--transition), box-shadow var(--transition); width: 100%; } +.form-group input:hover:not(:focus), +.form-group select:hover:not(:focus), +.form-group textarea:hover:not(:focus) { + border-color: var(--text-3); +} .form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(66,133,244,.18); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); } .form-group textarea { resize: vertical; } diff --git a/frontend/index.html b/frontend/index.html index 96a3587..be466cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v12 + Calendarr v13 @@ -80,7 +80,7 @@ - + @@ -185,7 +185,7 @@ Meine Kalender
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -884,7 +884,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index 0ad52c0..e4bf081 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 = 'v12'; +export const APP_VERSION = 'v13'; diff --git a/frontend/sw.js b/frontend/sw.js index e94ffbc..c9f7b78 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-v12'; +const CACHE_VERSION = 'calendarr-v13'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 1d6acceafc92039366a2a783e294ad0e06c6d1d8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 08:08:12 +0200 Subject: [PATCH 075/114] ui: Event-Popup-Aktionsbuttons polieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die drei Aktions-Icons (Bearbeiten, Kopieren, Löschen) und der Schließen-X im Termin-Popup hatten bisher nur den schlichten icon-btn-Hover (graue Fläche). Jetzt im selben modernen Stil wie die neuen Pill-Buttons: - Bearbeiten/Kopieren/Löschen: Hover bekommt Primärfarben-Tint (color-mix-Hintergrund + farbige Schrift) plus dezenten farbigen Schatten - Schließen-X: Hover zeigt die Akzentfarbe (rot), passend zur destruktiven Geste - Klick fühlt sich mit kurzem Scale-Down (.92) taktiler an Version v13 → v14. --- frontend/css/app.css | 27 ++++++++++++++++++++++++++- frontend/index.html | 18 +++++++++--------- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 7546367..bd1fb56 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -990,7 +990,32 @@ a { color: var(--primary); text-decoration: none; } } .popup-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } .popup-header h4 { flex: 1; font-size: 15px; font-weight: 500; } -.popup-action, .popup-close { width: 32px; height: 32px; font-size: 16px; } +.popup-action, .popup-close { + width: 34px; height: 34px; + font-size: 16px; + border-radius: 50%; + color: var(--text-2); + transition: + background var(--transition), + color var(--transition), + transform .1s ease, + box-shadow .15s ease; +} +.popup-action:hover { + background: rgba(66,133,244,.15); + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); + box-shadow: 0 2px 8px rgba(66,133,244,.18); + box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 22%, transparent); +} +.popup-close:hover { + background: rgba(234,67,53,.15); + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} +.popup-action svg, .popup-close svg { width: 16px; height: 16px; } +.popup-close { font-size: 20px; } +.popup-action:active, .popup-close:active { transform: scale(.92); } .popup-body { padding: 12px 16px; } .popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } .popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } diff --git a/frontend/index.html b/frontend/index.html index be466cd..dce93f4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v13 + Calendarr v14 @@ -80,7 +80,7 @@ - + @@ -199,7 +199,7 @@
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -884,7 +884,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index e4bf081..ada9b11 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 = 'v13'; +export const APP_VERSION = 'v14'; diff --git a/frontend/sw.js b/frontend/sw.js index c9f7b78..2b3878c 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-v13'; +const CACHE_VERSION = 'calendarr-v14'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From baa7e4c0645aa86415b7775239b9ef17e413c7fa Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 08:24:48 +0200 Subject: [PATCH 076/114] =?UTF-8?q?ui:=20Event-Popup=20neu=20strukturiert?= =?UTF-8?q?=20=E2=80=94=20Titel=20volle=20Breite,=20Actions=20im=20Footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher haben Bearbeiten/Kopieren/Löschen/Schließen im Header über die Hälfte der Breite gefressen, sodass der Titel auf 2-3 Zeilen zusammenschrumpfen musste. Neues Layout: - Schließen-X klein in der oberen rechten Ecke (absolut positioniert) - Header zeigt nur Color-Dot + Titel — voller Platz fürs Lesen - Drei beschriftete Aktions-Buttons (Bearbeiten / Kopieren / Löschen) als gleichbreite Reihe im Footer - Hover-Tint folgt der Primärfarbe; Löschen tönt zur Akzentfarbe - Popup-Breite leicht erhöht (300 → 340 px) für mehr Atemraum - Mobile bekommt die Action-Buttons etwas kompakter IDs der Buttons unverändert (popup-edit/copy/delete/close), bestehende JS-Handler funktionieren weiter. Version v14 → v15. --- frontend/css/app.css | 111 +++++++++++++++++++++++++++-------------- frontend/index.html | 43 +++++++++------- frontend/js/i18n.js | 4 +- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 5 files changed, 102 insertions(+), 60 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index bd1fb56..b481fd6 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -981,44 +981,86 @@ a { color: var(--primary); text-decoration: none; } background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); - width: 300px; + width: 340px; box-shadow: var(--shadow-lg); } -.popup-header { - display: flex; align-items: center; gap: 8px; - padding: 12px 16px; border-bottom: 1px solid var(--border); -} -.popup-color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } -.popup-header h4 { flex: 1; font-size: 15px; font-weight: 500; } -.popup-action, .popup-close { - width: 34px; height: 34px; - font-size: 16px; + +/* Close-X tucked into the top-right corner so the title gets full width */ +.popup-close { + position: absolute; + top: 8px; right: 8px; + width: 28px; height: 28px; border-radius: 50%; - color: var(--text-2); - transition: - background var(--transition), - color var(--transition), - transform .1s ease, - box-shadow .15s ease; -} -.popup-action:hover { - background: rgba(66,133,244,.15); - background: color-mix(in srgb, var(--primary) 16%, transparent); - color: var(--primary); - box-shadow: 0 2px 8px rgba(66,133,244,.18); - box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 22%, transparent); + display: flex; align-items: center; justify-content: center; + font-size: 18px; line-height: 1; + color: var(--text-3); + background: transparent; + border: none; + cursor: pointer; + z-index: 1; + transition: background var(--transition), color var(--transition), transform .1s ease; } .popup-close:hover { - background: rgba(234,67,53,.15); - background: color-mix(in srgb, var(--accent) 16%, transparent); + background: rgba(234,67,53,.18); + background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); } -.popup-action svg, .popup-close svg { width: 16px; height: 16px; } -.popup-close { font-size: 20px; } -.popup-action:active, .popup-close:active { transform: scale(.92); } +.popup-close:active { transform: scale(.92); } + +.popup-header { + display: flex; align-items: flex-start; gap: 10px; + padding: 14px 44px 12px 16px; /* right padding leaves room for close-X */ + border-bottom: 1px solid var(--border); +} +.popup-color-dot { + width: 12px; height: 12px; border-radius: 50%; + flex-shrink: 0; + margin-top: 5px; /* visually align with the title's first line */ +} +.popup-header h4 { + flex: 1; font-size: 15px; font-weight: 500; + line-height: 1.35; + word-break: break-word; +} + .popup-body { padding: 12px 16px; } .popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } .popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } + +/* Footer with the three action buttons, each takes equal width */ +.popup-actions { + display: flex; + gap: 4px; + padding: 8px 10px 10px; + border-top: 1px solid var(--border); +} +.popup-action-btn { + flex: 1; + display: inline-flex; align-items: center; justify-content: center; + gap: 6px; + padding: 8px 6px; + border-radius: 8px; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + -webkit-tap-highlight-color: transparent; + transition: background var(--transition), color var(--transition), transform .1s ease; +} +.popup-action-btn svg { width: 16px; height: 16px; fill: currentColor; } +.popup-action-btn:hover { + background: rgba(66,133,244,.14); + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); +} +.popup-action-btn.popup-action-danger:hover { + background: rgba(234,67,53,.14); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); +} +.popup-action-btn:active { transform: scale(.96); } .popup-copy-menu { border-top: 1px solid var(--border); padding: 4px 0; @@ -1669,15 +1711,10 @@ a { color: var(--primary); text-decoration: none; } .topbar-left { gap: 0; } .topbar-right { gap: 0; } - /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */ - .event-popup .icon-btn { - min-width: 32px !important; - min-height: 32px !important; - width: 32px; - height: 32px; - } - .event-popup .popup-header { gap: 2px; padding: 10px 12px; } - .event-popup { width: min(92vw, 340px); max-width: 92vw; } + /* Event-Popup auf Mobile: an Viewport-Breite anpassen */ + .event-popup { width: min(94vw, 360px); max-width: 94vw; } + .popup-actions { padding: 8px 8px 10px; } + .popup-action-btn { font-size: 11px; padding: 8px 4px; } /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ .month-event-time { display: none; } diff --git a/frontend/index.html b/frontend/index.html index dce93f4..35256fd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v14 + Calendarr v15 @@ -80,7 +80,7 @@ - + @@ -199,7 +199,7 @@
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -372,19 +372,10 @@ @@ -884,7 +889,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index ab9310b..ba49419 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -154,7 +154,7 @@ const translations = { rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', rec_on_date: 'Am Datum', rec_occurrences: 'Termine', - copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', + copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren', edit_before_copy: 'Vor dem Kopieren bearbeiten', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', confirm_delete_event: '"{title}" wirklich löschen?', @@ -365,7 +365,7 @@ const translations = { rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', rec_on_date: 'On date', rec_occurrences: 'occurrences', - copy_to_calendar: 'Copy to…', event_copied: 'Event copied', + copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy', edit_before_copy: 'Edit before copying', event_updated: 'Event updated', event_created: 'Event created', confirm_delete_event: 'Really delete "{title}"?', diff --git a/frontend/js/version.js b/frontend/js/version.js index ada9b11..0384aba 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 = 'v14'; +export const APP_VERSION = 'v15'; diff --git a/frontend/sw.js b/frontend/sw.js index 2b3878c..aeed36d 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-v14'; +const CACHE_VERSION = 'calendarr-v15'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From dc1cb4b57d67c25c11025e329d71403f557f4e7a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 08:54:20 +0200 Subject: [PATCH 077/114] =?UTF-8?q?fix:=20Popup-Action-Icons=20riesig,=20"?= =?UTF-8?q?copy"=20als=20Text=20=E2=80=94=20Cache-Robustheit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn der Browser noch die alte CSS bzw. i18n.js aus dem Cache hatte, lief das neu strukturierte Popup ins Leere: - SVGs ohne CSS-Width-Constraint nahmen die Browser-Standardgröße (300×150) an → riesige Icons, Layout brach in Vertikalstapel - Der Key "copy" fehlte in der alten i18n.js → "Kopieren" wurde durch den Roh-Key "copy" ersetzt Robust gemacht: - SVGs der Action-Buttons bekommen jetzt direkt im HTML width="16" height="16" — funktioniert auch ohne dass die zugehörige CSS-Regel geladen wurde - applyLang() in i18n.js fällt bei fehlendem Schlüssel auf den HTML-Default-Text zurück, anstatt den Key als Text einzuschreiben (gleiches Prinzip für data-i18n, -i18n-ph, -i18n-title) Version v15 → v16. --- frontend/index.html | 24 ++++++++++++------------ frontend/js/i18n.js | 19 +++++++++++++++---- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 35256fd..e73b5dd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v15 + Calendarr v16 @@ -80,7 +80,7 @@ - + @@ -199,7 +199,7 @@
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -385,15 +385,15 @@ @@ -889,7 +889,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index ba49419..957b180 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -442,15 +442,26 @@ export function t(key, vars = {}) { return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? ''); } +// Look up a translation but return null if the key is undefined in both +// the current language and German. Lets callers fall back to the existing +// HTML default rather than displaying the raw key. +function tOrNull(key) { + const dict = translations[currentLang] ?? translations.de; + const val = dict[key] ?? translations.de[key]; + return typeof val === 'string' ? val : null; +} + export function applyLang() { document.querySelectorAll('[data-i18n]').forEach(el => { - const v = t(el.dataset.i18n); - if (typeof v === 'string') el.textContent = v; + const v = tOrNull(el.dataset.i18n); + if (v != null) el.textContent = v; }); document.querySelectorAll('[data-i18n-ph]').forEach(el => { - el.placeholder = t(el.dataset.i18nPh); + const v = tOrNull(el.dataset.i18nPh); + if (v != null) el.placeholder = v; }); document.querySelectorAll('[data-i18n-title]').forEach(el => { - el.title = t(el.dataset.i18nTitle); + const v = tOrNull(el.dataset.i18nTitle); + if (v != null) el.title = v; }); } diff --git a/frontend/js/version.js b/frontend/js/version.js index 0384aba..15b9c55 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 = 'v15'; +export const APP_VERSION = 'v16'; diff --git a/frontend/sw.js b/frontend/sw.js index aeed36d..344e6a0 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-v15'; +const CACHE_VERSION = 'calendarr-v16'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 254adfa12ac47a3d0e26d1518138f88ab73e6339 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 11 May 2026 09:10:07 +0200 Subject: [PATCH 078/114] =?UTF-8?q?ui:=20Event-Popup-Aktionen=20modernisie?= =?UTF-8?q?rt=20=E2=80=94=20kompakte=20Icon-Toolbar=20im=20Header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Popup hatte vorher Text+Icon-Buttons in einem Footer mit verschwendeter vertikaler Höhe. Jetzt: - Color-Dot + Titel links (volle Breite, kann sauber umbrechen) - Kompakte 30px-Icon-Toolbar rechts oben: Bearbeiten / Kopieren / Löschen / Schließen - Icons im Ruhezustand transparent (nur SVG sichtbar, sehr dezent) - Auf Hover: runder farbiger Hintergrund. Edit/Copy in Primärfarbe, Delete in Akzentrot, Close in neutralem bg-hover - Klick gibt mit Scale-Down (.9) taktilen Feedback - Popup-Breite leicht erhöht (340 → 360 px) damit Titel + Toolbar bequem nebeneinander passen - Trash- und Copy-SVG-Pfade auf den 24x24-viewBox normalisiert (waren vorher zu lang) Version v16 → v17. --- frontend/css/app.css | 122 +++++++++++++++++++---------------------- frontend/index.html | 47 ++++++++-------- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 80 insertions(+), 93 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index b481fd6..d3a38f0 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -975,92 +975,78 @@ a { color: var(--primary); text-decoration: none; } } .ctx-item:hover { background: var(--bg-hover); } -/* ── Event Popup ────────────────────────────────────────── */ +/* ── Event Popup ────────────────────────────────────────── + Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. + Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar), + bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt + modern und lässt dem Titel die meiste Breite. */ .event-popup { position: fixed; z-index: 600; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); - width: 340px; + width: 360px; box-shadow: var(--shadow-lg); } -/* Close-X tucked into the top-right corner so the title gets full width */ -.popup-close { - position: absolute; - top: 8px; right: 8px; - width: 28px; height: 28px; - border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: 18px; line-height: 1; - color: var(--text-3); - background: transparent; - border: none; - cursor: pointer; - z-index: 1; - transition: background var(--transition), color var(--transition), transform .1s ease; -} -.popup-close:hover { - background: rgba(234,67,53,.18); - background: color-mix(in srgb, var(--accent) 18%, transparent); - color: var(--accent); -} -.popup-close:active { transform: scale(.92); } - .popup-header { display: flex; align-items: flex-start; gap: 10px; - padding: 14px 44px 12px 16px; /* right padding leaves room for close-X */ + padding: 12px 10px 12px 16px; border-bottom: 1px solid var(--border); } .popup-color-dot { - width: 12px; height: 12px; border-radius: 50%; + width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; - margin-top: 5px; /* visually align with the title's first line */ + margin-top: 6px; } .popup-header h4 { - flex: 1; font-size: 15px; font-weight: 500; - line-height: 1.35; + flex: 1; + font-size: 14px; font-weight: 500; + line-height: 1.4; word-break: break-word; + padding-top: 2px; } +.popup-toolbar { + display: flex; + gap: 2px; + flex-shrink: 0; + margin-left: 4px; +} +.popup-icon-btn { + width: 30px; height: 30px; + border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-3); + -webkit-tap-highlight-color: transparent; + transition: + background var(--transition), + color var(--transition), + transform .1s ease; +} +.popup-icon-btn svg { width: 15px; height: 15px; fill: currentColor; flex-shrink: 0; } +.popup-icon-btn:hover { + background: rgba(66,133,244,.16); + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); +} +.popup-icon-btn-danger:hover { + background: rgba(234,67,53,.16); + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} +.popup-icon-btn-close:hover { + background: var(--bg-hover); + color: var(--text-1); +} +.popup-icon-btn:active { transform: scale(.9); } + .popup-body { padding: 12px 16px; } .popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; } .popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; } - -/* Footer with the three action buttons, each takes equal width */ -.popup-actions { - display: flex; - gap: 4px; - padding: 8px 10px 10px; - border-top: 1px solid var(--border); -} -.popup-action-btn { - flex: 1; - display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - padding: 8px 6px; - border-radius: 8px; - background: transparent; - border: none; - cursor: pointer; - color: var(--text-2); - font-size: 12px; - font-weight: 500; - -webkit-tap-highlight-color: transparent; - transition: background var(--transition), color var(--transition), transform .1s ease; -} -.popup-action-btn svg { width: 16px; height: 16px; fill: currentColor; } -.popup-action-btn:hover { - background: rgba(66,133,244,.14); - background: color-mix(in srgb, var(--primary) 14%, transparent); - color: var(--primary); -} -.popup-action-btn.popup-action-danger:hover { - background: rgba(234,67,53,.14); - background: color-mix(in srgb, var(--accent) 14%, transparent); - color: var(--accent); -} -.popup-action-btn:active { transform: scale(.96); } .popup-copy-menu { border-top: 1px solid var(--border); padding: 4px 0; @@ -1712,9 +1698,11 @@ a { color: var(--primary); text-decoration: none; } .topbar-right { gap: 0; } /* Event-Popup auf Mobile: an Viewport-Breite anpassen */ - .event-popup { width: min(94vw, 360px); max-width: 94vw; } - .popup-actions { padding: 8px 8px 10px; } - .popup-action-btn { font-size: 11px; padding: 8px 4px; } + .event-popup { width: min(94vw, 380px); max-width: 94vw; } + .popup-header { padding: 10px 8px 10px 14px; } + .popup-header h4 { font-size: 13.5px; } + .popup-icon-btn { width: 32px; height: 32px; } + .popup-icon-btn svg { width: 16px; height: 16px; } /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ .month-event-time { display: none; } diff --git a/frontend/index.html b/frontend/index.html index e73b5dd..5ee5a49 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v16 + Calendarr v17 @@ -80,7 +80,7 @@ - + @@ -199,7 +199,7 @@
- + @@ -235,7 +235,7 @@
- +
@@ -243,7 +243,7 @@
- +
@@ -253,7 +253,7 @@
- +
@@ -261,7 +261,7 @@
- +
@@ -311,7 +311,7 @@
- +
@@ -372,10 +372,23 @@ @@ -889,7 +888,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/version.js b/frontend/js/version.js index 15b9c55..9b7797e 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 = 'v16'; +export const APP_VERSION = 'v17'; diff --git a/frontend/sw.js b/frontend/sw.js index 344e6a0..7d26e5f 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-v16'; +const CACHE_VERSION = 'calendarr-v17'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 8f9eafe56183d4c7cebc462b48706d334ab88d2c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 09:49:45 +0200 Subject: [PATCH 079/114] feat(settings): Schriftfarbe, Linienfarbe und Hintergrundfarbe per Color-Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die bisherigen Stufen-Wähler ("Dunkel/Mittel/Hell/Maximum" und "Kaum/Subtil/Normal/Stark") für Schrift- bzw. Linienkontrast sind durch echte Hex-Color-Picker ersetzt. Zusätzlich kann jetzt auch die Hintergrundfarbe der Seite frei gewählt werden. Wenn ein Override gesetzt ist: - text_color → setzt --text-1 direkt, --text-2/--text-3 werden daraus per shadeHex(-0.25 / -0.55) abgeleitet, damit der Hue passt - line_color → setzt --border, --border-light wird leicht abgedunkelt - bg_color → setzt --bg-app, daraus werden Topbar/Sidebar/Surface/ Hover/Active per shadeHex(+0.10…+0.40) konsistent hochskaliert Per "Reset"-Knopf wird der Override geleert und die alte Stufen-Logik (falls noch vorhanden) bzw. der Default-Theme greift wieder. Backend: - 3 neue nullable VARCHAR(7)-Spalten in user_settings (text_color, line_color, bg_color) inkl. Migrationen in main.py - settings_router nutzt model_dump(exclude_unset=True) und respektiert explizite null-Werte nur für diese 3 Override-Felder, damit Reset funktioniert Auch enthalten: Auflösen der Merge-Konflikte in sw.js, index.html, version.js (HEAD-Stand v17 behalten) und Bump auf v18. --- backend/main.py | 18 ++++++ backend/models.py | 3 + backend/routers/settings_router.py | 18 +++++- frontend/index.html | 96 +++++++++--------------------- frontend/js/calendar.js | 54 ++++++++++++++++- frontend/js/i18n.js | 8 +++ frontend/js/utils.js | 68 ++++++++++++++++++--- frontend/js/version.js | 6 +- frontend/sw.js | 86 +------------------------- 9 files changed, 187 insertions(+), 170 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5225ef6..3312035 100644 --- a/backend/main.py +++ b/backend/main.py @@ -114,6 +114,24 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)")) + conn.commit() + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 1892076..18f6cc7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -84,6 +84,9 @@ class UserSettings(Base): language = Column(String(5), default="de") month_divider_color = Column(String(7), default="#7090c0") month_label_color = Column(String(7), default="#7090c0") + text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) + line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast) + bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default) user = relationship("User", back_populates="settings") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index a82ecb0..b5c639d 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -24,6 +24,9 @@ class SettingsUpdate(BaseModel): language: Optional[str] = None month_divider_color: Optional[str] = None month_label_color: Optional[str] = None + text_color: Optional[str] = None + line_color: Optional[str] = None + bg_color: Optional[str] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -40,6 +43,9 @@ def _settings_dict(s: models.UserSettings) -> dict: "language": s.language or "de", "month_divider_color": s.month_divider_color or "#7090c0", "month_label_color": s.month_label_color or "#7090c0", + "text_color": s.text_color, + "line_color": s.line_color, + "bg_color": s.bg_color, } @@ -76,8 +82,16 @@ def update_settings( settings = models.UserSettings(user_id=current_user.id) db.add(settings) - for field, value in data.model_dump(exclude_none=True).items(): - setattr(settings, field, value) + # For these three override colours, an explicit null is meaningful + # ("reset to default") and must be persisted as NULL. All other fields + # keep the previous behaviour where a null/missing value is ignored. + NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"} + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field in NULLABLE_OVERRIDES: + setattr(settings, field, value or None) + elif value is not None: + setattr(settings, field, value) db.commit() return {"ok": True} diff --git a/frontend/index.html b/frontend/index.html index 8526ce5..827f1eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,14 +1,10 @@ - + -<<<<<<< HEAD - Calendarr v17 -======= - Calendarr v11 ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + Calendarr v18 @@ -84,11 +80,7 @@ -<<<<<<< HEAD - -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + @@ -207,11 +199,7 @@
-<<<<<<< HEAD - -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 + @@ -247,11 +235,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -259,11 +243,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -273,11 +253,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -285,11 +261,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -339,11 +311,7 @@
-<<<<<<< HEAD -======= - ->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -407,7 +375,6 @@ -

Schriftkontrast

-

Helligkeit der Beschriftungen und Texte

-
- - - - +
+ +
+ +
+ +
- -

Linienkontrast

-

Sichtbarkeit von Trennlinien und Rahmen

-
- - - - +
+ +
+ +
+ +
+
+
+ +
+ +
+ +

Kalenderansicht

@@ -933,11 +895,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index abe53c4..10f07e7 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -2045,6 +2045,24 @@ function openSettingsModal() { document.getElementById(id + '-hex').value = val.toUpperCase(); document.getElementById(id + '-preview').style.background = val; }); + + // Optional colour overrides — empty hex input means "auto" + [ + { id: 'cfg-text-color', val: s.text_color }, + { id: 'cfg-line-color', val: s.line_color }, + { id: 'cfg-bg-color', val: s.bg_color }, + ].forEach(({ id, val }) => { + const hex = document.getElementById(id + '-hex'); + const prev = document.getElementById(id + '-preview'); + if (!hex || !prev) return; + if (val) { + hex.value = String(val).toUpperCase(); + prev.style.background = val; + } else { + hex.value = ''; + prev.style.background = 'transparent'; + } + }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-language').value = getLang(); @@ -2376,6 +2394,32 @@ function bindSettingsModal() { }); }); + // Optional override colours (text / line / background) — empty = use default + [ + { prefix: 'cfg-text-color', defaultColor: '#c8c8d8' }, + { prefix: 'cfg-line-color', defaultColor: '#3a3a52' }, + { prefix: 'cfg-bg-color', defaultColor: '#0e0e14' }, + ].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; + preview.addEventListener('click', async () => { + const picked = await openColorPicker(preview, hex.value || defaultColor); + if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; } + }); + hex.addEventListener('change', () => { + let val = hex.value.trim(); + if (!val) { preview.style.background = 'transparent'; return; } + if (!val.startsWith('#')) val = '#' + val; + if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } + }); + reset.addEventListener('click', () => { + hex.value = ''; + preview.style.background = 'transparent'; + }); + }); + // Panel navigation document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel)); @@ -2415,6 +2459,11 @@ function bindSettingsModal() { const btn = document.querySelector(`#${id} .contrast-btn.active`); return btn ? Number(btn.dataset.val) : null; }; + // Optional override colours: empty input → null (use default) + const colourOrNull = (id) => { + const v = (document.getElementById(id).value || '').trim(); + return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null; + }; const settings = { default_view: document.getElementById('cfg-default-view').value, week_start_day: document.getElementById('cfg-week-start').value, @@ -2423,9 +2472,10 @@ function bindSettingsModal() { 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, - text_contrast: getActive('cfg-text-contrast') || 3, - line_contrast: getActive('cfg-line-contrast') || 3, hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, }; diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 250b214..c918a7e 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -67,6 +67,10 @@ const translations = { settings_today_color: 'Heutige-Tag-Farbe', settings_month_divider_color: 'Monatswechsel-Linie', settings_month_label_color: 'Monatskürzel-Farbe', + settings_text_color: 'Schriftfarbe', + settings_line_color: 'Linienfarbe', + settings_bg_color: 'Hintergrundfarbe', + reset: 'Reset', settings_text_contrast: 'Schriftkontrast', settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte', contrast_dark: 'Dunkel', contrast_medium: 'Mittel', @@ -282,6 +286,10 @@ const translations = { settings_today_color: 'Today highlight color', settings_month_divider_color: 'Month divider line', settings_month_label_color: 'Month label color', + settings_text_color: 'Text color', + settings_line_color: 'Line color', + settings_bg_color: 'Background color', + reset: 'Reset', settings_text_contrast: 'Text contrast', settings_text_contrast_desc: 'Brightness of labels and text', contrast_dark: 'Dark', contrast_medium: 'Medium', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 5db6786..4b71860 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -83,14 +83,47 @@ export function applyTheme(settings) { root.style.setProperty('--accent', settings.accent_color || '#ea4335'); root.style.setProperty('--today-color', settings.today_color || '#4285f4'); - const tc = TEXT_CONTRAST[settings.text_contrast || 3]; - root.style.setProperty('--text-1', tc.t1); - root.style.setProperty('--text-2', tc.t2); - root.style.setProperty('--text-3', tc.t3); + // Text colour: a custom hex (settings.text_color) wins over the legacy + // 1–4 contrast step. We derive --text-2/--text-3 by darkening the + // chosen colour so the secondary/tertiary text stays in the same hue. + if (settings.text_color) { + root.style.setProperty('--text-1', settings.text_color); + root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25)); + root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55)); + } else { + const tc = TEXT_CONTRAST[settings.text_contrast || 3]; + root.style.setProperty('--text-1', tc.t1); + root.style.setProperty('--text-2', tc.t2); + root.style.setProperty('--text-3', tc.t3); + } - const lc = LINE_CONTRAST[settings.line_contrast || 3]; - root.style.setProperty('--border', lc.border); - root.style.setProperty('--border-light', lc.light); + // Line colour: custom hex overrides the legacy contrast step. + if (settings.line_color) { + root.style.setProperty('--border', settings.line_color); + root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25)); + } else { + const lc = LINE_CONTRAST[settings.line_contrast || 3]; + root.style.setProperty('--border', lc.border); + root.style.setProperty('--border-light', lc.light); + } + + // Background colour: optional. If set, also tint the topbar/sidebar + // and surface variants so the whole UI stays coherent. + if (settings.bg_color) { + root.style.setProperty('--bg-app', settings.bg_color); + root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10)); + root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10)); + root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18)); + root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26)); + root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40)); + } else { + root.style.removeProperty('--bg-app'); + root.style.removeProperty('--bg-topbar'); + root.style.removeProperty('--bg-sidebar'); + root.style.removeProperty('--bg-surface'); + root.style.removeProperty('--bg-hover'); + root.style.removeProperty('--bg-active'); + } const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); @@ -105,3 +138,24 @@ function hexToRgba(hex, alpha) { const b = parseInt(hex.slice(5,7), 16); return `rgba(${r},${g},${b},${alpha})`; } + +// Brighten (positive amount) or darken (negative) a hex colour. +// Used to derive supporting shades (sidebar bg, hover bg, secondary text…) +// from a single user-picked colour so the whole UI stays in the same family. +function shadeHex(hex, amount) { + let r = parseInt(hex.slice(1,3), 16); + let g = parseInt(hex.slice(3,5), 16); + let b = parseInt(hex.slice(5,7), 16); + if (amount >= 0) { + r = Math.round(r + (255 - r) * amount); + g = Math.round(g + (255 - g) * amount); + b = Math.round(b + (255 - b) * amount); + } else { + const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75% + r = Math.round(r * a); + g = Math.round(g * a); + b = Math.round(b * a); + } + const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); + return '#' + h(r) + h(g) + h(b); +} diff --git a/frontend/js/version.js b/frontend/js/version.js index 9d0c41b..eefbcf1 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,6 +1,2 @@ // Increment APP_VERSION with every code change -<<<<<<< HEAD -export const APP_VERSION = 'v17'; -======= -export const APP_VERSION = 'v11'; ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 +export const APP_VERSION = 'v18'; diff --git a/frontend/sw.js b/frontend/sw.js index 1d172c7..24dd649 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,3 @@ -<<<<<<< HEAD // Calendarr Service Worker — minimal-cache strategy // // Strategy: network-first for everything. The cache is only used as a @@ -8,52 +7,15 @@ // the entry HTML / version files). New releases take effect on the next // reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v17'; +const CACHE_VERSION = 'calendarr-v18'; const OFFLINE_SHELL = ['/', '/index.html']; -======= -// Calendarr Service Worker -// Cache-first for static assets, network-first for /api/* (graceful offline) - -const CACHE_VERSION = 'calendarr-v11'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/manifest.json', - '/static/css/app.css', - '/static/favicon.svg', - '/static/js/app.js', - '/static/js/api.js', - '/static/js/calendar.js', - '/static/js/color-picker.js', - '/static/js/date-picker.js', - '/static/js/i18n.js', - '/static/js/utils.js', - '/static/js/version.js', - '/static/js/views/agenda.js', - '/static/js/views/month.js', - '/static/js/views/quarter.js', - '/static/js/views/week.js', - '/icons/icon-192.png', - '/icons/icon-512.png', - '/icons/icon.svg', -]; ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_VERSION).then(cache => -<<<<<<< HEAD Promise.all(OFFLINE_SHELL.map(url => cache.add(url).catch(err => console.warn('[SW] skip', url, err)) )) -======= - // Use addAll with a fallback so a single missing file doesn't abort install - Promise.all( - STATIC_ASSETS.map(url => - cache.add(url).catch(err => console.warn('[SW] skip', url, err)) - ) - ) ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 ).then(() => self.skipWaiting()) ); }); @@ -72,12 +34,8 @@ self.addEventListener('fetch', event => { const url = new URL(req.url); -<<<<<<< HEAD // API routes: always go to the network, no offline fallback (we'd just // be returning stale account/event data otherwise). -======= - // Network-first for API routes — fail silently if offline ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(req).catch(() => @@ -90,7 +48,6 @@ self.addEventListener('fetch', event => { return; } -<<<<<<< HEAD // Everything else: network-first. The browser's HTTP cache (driven by // the server's Cache-Control headers) already throttles re-fetches — // the SW just makes sure offline still works for the entry HTML. @@ -114,47 +71,6 @@ self.addEventListener('fetch', event => { return caches.match(req).then(c => c || caches.match('/index.html')); } return new Response('', { status: 503 }); -======= - // Network-first for navigation (HTML) and the version-defining files — - // ensures users always get the freshest entry point so new releases - // take effect on the next reload without a manual SW unregister. - const isHtml = req.mode === 'navigate' - || url.pathname === '/' - || url.pathname === '/index.html'; - const isVersionFile = url.pathname === '/static/js/version.js'; - - if (isHtml || isVersionFile) { - event.respondWith( - fetch(req).then(resp => { - if (resp && resp.status === 200) { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => - caches.match(req).then(c => c || caches.match('/index.html')) - ) - ); - return; - } - - // Cache-first for everything else (static) - event.respondWith( - caches.match(req).then(cached => { - if (cached) return cached; - return fetch(req).then(resp => { - // Only cache successful, basic-origin responses - if (resp && resp.status === 200 && resp.type === 'basic') { - const clone = resp.clone(); - caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {}); - } - return resp; - }).catch(() => { - // Offline fallback for navigation requests - if (req.mode === 'navigate') return caches.match('/index.html'); - return new Response('', { status: 503 }); - }); ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 }) ); }); -- 2.47.3 From fd7562966ae081fd28fde2e6de54937ba58f2939 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 09:57:40 +0200 Subject: [PATCH 080/114] =?UTF-8?q?fix(settings):=20Schrift-/Linien-/Hinte?= =?UTF-8?q?rgrundfarbe=20=E2=80=94=20Live-Vorschau=20+=20Hex=20ohne=20'#'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live-Vorschau beim Tippen statt erst bei Blur (input-Event) - Hex-Werte werden auch ohne fuehrendes '#' akzeptiert ("ff0000" -> "#FF0000") - Reset-Button wendet Standardwerte sofort an - v19 / sw cache v19 --- frontend/js/calendar.js | 57 ++++++++++++++++++++++++++++++++++------- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 10f07e7..23e905b 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -2394,7 +2394,19 @@ function bindSettingsModal() { }); }); - // Optional override colours (text / line / background) — empty = use default + // 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: '#c8c8d8' }, { prefix: 'cfg-line-color', defaultColor: '#3a3a52' }, @@ -2404,19 +2416,43 @@ function bindSettingsModal() { 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; } + 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 = 'transparent'; + liveApplyOverride(prefix, null); + } else if (norm) { + preview.style.background = norm; + liveApplyOverride(prefix, norm); + } + }; + hex.addEventListener('input', onTyped); hex.addEventListener('change', () => { - let val = hex.value.trim(); - if (!val) { preview.style.background = 'transparent'; return; } - if (!val.startsWith('#')) val = '#' + val; - if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; } + const norm = normalize(hex.value); + if (norm) hex.value = norm; }); + reset.addEventListener('click', () => { hex.value = ''; preview.style.background = 'transparent'; + liveApplyOverride(prefix, null); }); }); @@ -2459,10 +2495,13 @@ function bindSettingsModal() { const btn = document.querySelector(`#${id} .contrast-btn.active`); return btn ? Number(btn.dataset.val) : null; }; - // Optional override colours: empty input → null (use default) + // Optional override colours: empty input → null (use default). + // Tolerant: accepts both "ff0000" and "#ff0000". const colourOrNull = (id) => { - const v = (document.getElementById(id).value || '').trim(); - return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null; + 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, diff --git a/frontend/js/version.js b/frontend/js/version.js index eefbcf1..fb9a0e6 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 = 'v18'; +export const APP_VERSION = 'v19'; diff --git a/frontend/sw.js b/frontend/sw.js index 24dd649..d1e5ca9 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-v18'; +const CACHE_VERSION = 'calendarr-v19'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 43575f90423e31c7479357f4518798727c61a409 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 10:06:12 +0200 Subject: [PATCH 081/114] fix(theme): Defaults weiss-auf-schwarz + Kontrast-Sicherheitsbremse - Default-Schriftfarbe = #FFFFFF, Default-Hintergrund = #000000 - Wenn Schrift- und Hintergrundfarbe zu wenig Kontrast haben (< 2.5:1), wird automatisch auf weiss-auf-schwarz zurueckgefallen. So kann man sich nicht mehr in eine unsichtbare Seite manoevrieren. - Color-Picker zeigt jetzt die wirksame Default-Farbe in der Vorschau (statt leer/transparent), auch wenn keine Override gesetzt ist. --- frontend/js/calendar.js | 32 +++++++-------- frontend/js/utils.js | 86 +++++++++++++++++++++++------------------ frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 64 insertions(+), 58 deletions(-) diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 23e905b..702a33d 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 } from './utils.js'; +import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; @@ -2046,22 +2046,18 @@ function openSettingsModal() { document.getElementById(id + '-preview').style.background = val; }); - // Optional colour overrides — empty hex input means "auto" + // 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 }, - { id: 'cfg-line-color', val: s.line_color }, - { id: 'cfg-bg-color', val: s.bg_color }, - ].forEach(({ id, val }) => { + { 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; - if (val) { - hex.value = String(val).toUpperCase(); - prev.style.background = val; - } else { - hex.value = ''; - prev.style.background = 'transparent'; - } + 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(); @@ -2408,9 +2404,9 @@ function bindSettingsModal() { applyTheme(state.settings); }; [ - { prefix: 'cfg-text-color', defaultColor: '#c8c8d8' }, - { prefix: 'cfg-line-color', defaultColor: '#3a3a52' }, - { prefix: 'cfg-bg-color', defaultColor: '#0e0e14' }, + { 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'); @@ -2436,7 +2432,7 @@ function bindSettingsModal() { const onTyped = () => { const norm = normalize(hex.value); if (norm === '') { - preview.style.background = 'transparent'; + preview.style.background = defaultColor; liveApplyOverride(prefix, null); } else if (norm) { preview.style.background = norm; @@ -2451,7 +2447,7 @@ function bindSettingsModal() { reset.addEventListener('click', () => { hex.value = ''; - preview.style.background = 'transparent'; + preview.style.background = defaultColor; liveApplyOverride(prefix, null); }); }); diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 4b71860..b0e9b36 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -76,6 +76,12 @@ const LINE_CONTRAST = { 4: { border: '#5a5a78', light: '#484860' }, }; +// Defaults wenn kein Custom-Override gesetzt ist. +// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet. +export const DEFAULT_TEXT_COLOR = '#FFFFFF'; +export const DEFAULT_LINE_COLOR = '#3A3A52'; +export const DEFAULT_BG_COLOR = '#000000'; + export function applyTheme(settings) { const root = document.documentElement; root.style.setProperty('--primary', settings.primary_color || '#4285f4'); @@ -83,47 +89,33 @@ export function applyTheme(settings) { root.style.setProperty('--accent', settings.accent_color || '#ea4335'); root.style.setProperty('--today-color', settings.today_color || '#4285f4'); - // Text colour: a custom hex (settings.text_color) wins over the legacy - // 1–4 contrast step. We derive --text-2/--text-3 by darkening the - // chosen colour so the secondary/tertiary text stays in the same hue. - if (settings.text_color) { - root.style.setProperty('--text-1', settings.text_color); - root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25)); - root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55)); - } else { - const tc = TEXT_CONTRAST[settings.text_contrast || 3]; - root.style.setProperty('--text-1', tc.t1); - root.style.setProperty('--text-2', tc.t2); - root.style.setProperty('--text-3', tc.t3); + // Effektive Farben bestimmen (Override > Default). + let textColor = settings.text_color || DEFAULT_TEXT_COLOR; + let lineColor = settings.line_color || DEFAULT_LINE_COLOR; + let bgColor = settings.bg_color || DEFAULT_BG_COLOR; + + // Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug + // Kontrast haben (passiert wenn man aus Versehen text=bg eingibt), + // erzwinge weiss-auf-schwarz, damit man nicht in einer unbedienbaren + // Seite landet. + if (contrastRatio(textColor, bgColor) < 2.5) { + textColor = DEFAULT_TEXT_COLOR; + bgColor = DEFAULT_BG_COLOR; } - // Line colour: custom hex overrides the legacy contrast step. - if (settings.line_color) { - root.style.setProperty('--border', settings.line_color); - root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25)); - } else { - const lc = LINE_CONTRAST[settings.line_contrast || 3]; - root.style.setProperty('--border', lc.border); - root.style.setProperty('--border-light', lc.light); - } + root.style.setProperty('--text-1', textColor); + root.style.setProperty('--text-2', shadeHex(textColor, -0.25)); + root.style.setProperty('--text-3', shadeHex(textColor, -0.55)); - // Background colour: optional. If set, also tint the topbar/sidebar - // and surface variants so the whole UI stays coherent. - if (settings.bg_color) { - root.style.setProperty('--bg-app', settings.bg_color); - root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10)); - root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10)); - root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18)); - root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26)); - root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40)); - } else { - root.style.removeProperty('--bg-app'); - root.style.removeProperty('--bg-topbar'); - root.style.removeProperty('--bg-sidebar'); - root.style.removeProperty('--bg-surface'); - root.style.removeProperty('--bg-hover'); - root.style.removeProperty('--bg-active'); - } + root.style.setProperty('--border', lineColor); + root.style.setProperty('--border-light', shadeHex(lineColor, -0.25)); + + root.style.setProperty('--bg-app', bgColor); + root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10)); + root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10)); + root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18)); + root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26)); + root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40)); const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); @@ -132,6 +124,24 @@ export function applyTheme(settings) { root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0'); } +function luminance(hex) { + const c = (n) => { + const v = n / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }; + const r = c(parseInt(hex.slice(1, 3), 16)); + const g = c(parseInt(hex.slice(3, 5), 16)); + const b = c(parseInt(hex.slice(5, 7), 16)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +function contrastRatio(c1, c2) { + try { + const l1 = luminance(c1); + const l2 = luminance(c2); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); + } catch { return 21; } +} + function hexToRgba(hex, alpha) { const r = parseInt(hex.slice(1,3), 16); const g = parseInt(hex.slice(3,5), 16); diff --git a/frontend/js/version.js b/frontend/js/version.js index fb9a0e6..b8dfc41 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 = 'v19'; +export const APP_VERSION = 'v20'; diff --git a/frontend/sw.js b/frontend/sw.js index d1e5ca9..4c460d0 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-v19'; +const CACHE_VERSION = 'calendarr-v20'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From f102f02cb90ceff74ac8addaf1f8bf6db58710d0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 10:12:32 +0200 Subject: [PATCH 082/114] fix(version): Tab-Titel + Impressum dynamisch aus version.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher waren "Calendarr v18" in index.html hardcoded und wurden bei Releases nie mit gebumpt — v19/v20 wurden zwar in version.js gepflegt, landeten aber nie im Tab-Titel. Jetzt liest calendar.js APP_VERSION direkt aus version.js und setzt sowohl document.title als auch das Impressum-Footer-Label, damit das nicht mehr auseinanderlaufen kann. v21 / sw cache v21 --- frontend/index.html | 6 +++--- frontend/js/calendar.js | 10 +++++++++- frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 827f1eb..0a7569a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,8 @@ - - Calendarr v18 + + Calendarr @@ -895,7 +895,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 702a33d..de0be27 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -7,6 +7,14 @@ 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 { APP_VERSION } from './version.js'; + +// Version sofort beim Modul-Load ueberall sichtbar setzen. +document.title = `Calendarr ${APP_VERSION}`; +document.addEventListener('DOMContentLoaded', () => { + const imp = document.getElementById('impressum-version'); + if (imp) imp.textContent = `Calendarr ${APP_VERSION}`; +}); // Fetch avatar image as blob URL (with auth header) function fetchAvatarBlob() { @@ -439,7 +447,7 @@ function updateTitle() { titleEl.innerHTML = `${main}` + (year ? `${year}` : ''); - document.title = `Calendarr - ${fullText}`; + document.title = `Calendarr ${APP_VERSION} - ${fullText}`; } function updateViewButtons() { diff --git a/frontend/js/version.js b/frontend/js/version.js index b8dfc41..c7fd4d5 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 = 'v20'; +export const APP_VERSION = 'v21'; diff --git a/frontend/sw.js b/frontend/sw.js index 4c460d0..efb9c6b 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-v20'; +const CACHE_VERSION = 'calendarr-v21'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 275e5a2ae028f63d394d179328872509e6c6c40a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 10:18:49 +0200 Subject: [PATCH 083/114] fix: unaufgeloeste Merge-Konflikt-Marker aus i18n/calendar/week/css entfernt Beim letzten Beta->Master-Merge sind die <<<<<<< / ======= / >>>>>>> Marker mit committet worden. Das hat i18n.js mit einem SyntaxError beim Parsen abgebrochen und damit den gesamten Frontend-Start kaputt gemacht (=> komplett schwarze Seite, weil applyTheme nie lief). Acht Bloecke aufgeloest, in allen Faellen die HEAD-Seite behalten (neue Features: copy-Key, URL-State, all-day-continues-Logik, Event- Popup-Header). v22 / sw cache v22. --- frontend/css/app.css | 16 ---------------- frontend/js/calendar.js | 14 -------------- frontend/js/i18n.js | 8 -------- frontend/js/version.js | 2 +- frontend/js/views/week.js | 13 ------------- frontend/sw.js | 2 +- 6 files changed, 2 insertions(+), 53 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index 74654d8..d3a38f0 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -975,15 +975,11 @@ a { color: var(--primary); text-decoration: none; } } .ctx-item:hover { background: var(--bg-hover); } -<<<<<<< HEAD /* ── Event Popup ────────────────────────────────────────── Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben. Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar), bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt modern und lässt dem Titel die meiste Breite. */ -======= -/* ── Event Popup ────────────────────────────────────────── */ ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 .event-popup { position: fixed; z-index: 600; background: var(--bg-surface); @@ -1701,24 +1697,12 @@ a { color: var(--primary); text-decoration: none; } .topbar-left { gap: 0; } .topbar-right { gap: 0; } -<<<<<<< HEAD /* Event-Popup auf Mobile: an Viewport-Breite anpassen */ .event-popup { width: min(94vw, 380px); max-width: 94vw; } .popup-header { padding: 10px 8px 10px 14px; } .popup-header h4 { font-size: 13.5px; } .popup-icon-btn { width: 32px; height: 32px; } .popup-icon-btn svg { width: 16px; height: 16px; } -======= - /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */ - .event-popup .icon-btn { - min-width: 32px !important; - min-height: 32px !important; - width: 32px; - height: 32px; - } - .event-popup .popup-header { gap: 2px; padding: 10px 12px; } - .event-popup { width: min(92vw, 340px); max-width: 92vw; } ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 /* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */ .month-event-time { display: none; } diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index de0be27..3d15ac5 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -123,7 +123,6 @@ export async function initCalendar() { bindProfileModal(); bindSwipeNavigation(); handleHAOAuthReturn(); -<<<<<<< HEAD // Browser-Back/Forward: URL-Hash → State synchronisieren window.addEventListener('hashchange', () => { @@ -140,8 +139,6 @@ export async function initCalendar() { } if (changed) fetchAndRender(); }); -======= ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 } function handleHAOAuthReturn() { @@ -155,11 +152,7 @@ function handleHAOAuthReturn() { }; if (params.has('ha_connected')) { showToast('Home Assistant verbunden'); -<<<<<<< HEAD window.history.replaceState({}, '', window.location.pathname + window.location.hash); -======= - window.history.replaceState({}, '', window.location.pathname); ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 fetchAndRender(true); api.get('/homeassistant/accounts').then(accs => { state.haAccounts = accs || []; @@ -169,11 +162,7 @@ function handleHAOAuthReturn() { } else if (params.has('ha_error')) { const code = params.get('ha_error'); showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); -<<<<<<< HEAD window.history.replaceState({}, '', window.location.pathname + window.location.hash); -======= - window.history.replaceState({}, '', window.location.pathname); ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 } } @@ -268,10 +257,7 @@ async function fetchAndRender(force = false, silent = false) { renderView(); updateTitle(); renderMiniCal(); -<<<<<<< HEAD writeUrlState(); -======= ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 prefetchIfNeeded(start, end); // extend cache in background if approaching an edge return; } diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index c918a7e..c443647 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -158,11 +158,7 @@ const translations = { rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', rec_on_date: 'Am Datum', rec_occurrences: 'Termine', -<<<<<<< HEAD copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren', -======= - copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 edit_before_copy: 'Vor dem Kopieren bearbeiten', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', confirm_delete_event: '"{title}" wirklich löschen?', @@ -377,11 +373,7 @@ const translations = { rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', rec_on_date: 'On date', rec_occurrences: 'occurrences', -<<<<<<< HEAD copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy', -======= - copy_to_calendar: 'Copy to…', event_copied: 'Event copied', ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 edit_before_copy: 'Edit before copying', event_updated: 'Event updated', event_created: 'Event created', confirm_delete_event: 'Really delete "{title}"?', diff --git a/frontend/js/version.js b/frontend/js/version.js index c7fd4d5..7cd23f5 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 = 'v21'; +export const APP_VERSION = 'v22'; diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 4aa14d0..e84c0cb 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -63,7 +63,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const color = ev.color || ev.calendarColor || '#4285f4'; const pastCls = isPast(ev) ? 'past' : ''; const multiCls = isMultiTimed ? 'multiday-timed' : ''; -<<<<<<< HEAD // continues-left/right: compute on date-only basis for all-day events let evStart = new Date(ev.start); let evEnd = new Date(ev.end); @@ -77,10 +76,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0); const cL = evStart < firstDay ? 'continues-left' : ''; const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : ''; -======= - const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : ''; - const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : ''; ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; @@ -252,7 +247,6 @@ function renderNowLine(container, days, hourH = 60) { function layoutWeekAllDay(evs, days) { const items = []; evs.forEach(ev => { -<<<<<<< HEAD // For all-day events, normalize to date-only with inclusive end-day // (iCal stores exclusive end → subtract 1). For timed events, keep // the original strict-overlap logic so events ending exactly at @@ -275,13 +269,6 @@ function layoutWeekAllDay(evs, days) { matches = new Date(ev.start) < de && new Date(ev.end) > ds; } if (matches) { -======= - let colStart = -1, colEnd = -1; - days.forEach((day, i) => { - const ds = new Date(day); ds.setHours(0, 0, 0, 0); - const de = new Date(day); de.setHours(24, 0, 0, 0); - if (new Date(ev.start) < de && new Date(ev.end) > ds) { ->>>>>>> e744b1829e99db6b80922f75542ced329138e474 if (colStart === -1) colStart = i; colEnd = i; } diff --git a/frontend/sw.js b/frontend/sw.js index efb9c6b..4626683 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-v21'; +const CACHE_VERSION = 'calendarr-v22'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 362cc7212c4e6fed37b81115ae2ef029441c33fd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 10:22:13 +0200 Subject: [PATCH 084/114] fix(version): Sidebar-Copyright wird jetzt auch aus version.js befuellt --- frontend/index.html | 2 +- frontend/js/calendar.js | 2 ++ frontend/js/version.js | 2 +- frontend/sw.js | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 0a7569a..5b030f8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -199,7 +199,7 @@
- + diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 3d15ac5..8c4449e 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -14,6 +14,8 @@ document.title = `Calendarr ${APP_VERSION}`; document.addEventListener('DOMContentLoaded', () => { const imp = document.getElementById('impressum-version'); if (imp) imp.textContent = `Calendarr ${APP_VERSION}`; + const side = document.getElementById('sidebar-copyright'); + if (side) side.innerHTML = `© 2026 Scarriffleservices · ${APP_VERSION}`; }); // Fetch avatar image as blob URL (with auth header) diff --git a/frontend/js/version.js b/frontend/js/version.js index 7cd23f5..bb0b7ed 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 = 'v22'; +export const APP_VERSION = 'v23'; diff --git a/frontend/sw.js b/frontend/sw.js index 4626683..79c4d88 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-v22'; +const CACHE_VERSION = 'calendarr-v23'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { -- 2.47.3 From 32268a18b229119f790cab34ed014943a3a721be Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 16:05:18 +0200 Subject: [PATCH 085/114] feat: Kalender-Sharing, Gruppen, iCal Import/Export & Ersteller (Server) Kollaborations-Features ausschliesslich fuer lokale Kalender: - Sharing: calendar_shares-Tabelle, GET/POST/DELETE /api/local/calendars/{id}/shares (nur Besitzer), GET /api/users/directory, geteilte Kalender in GET /api/local/calendars (shared_by/permission/owned) und im Merge-Read. - Gruppen: groups/group_members/group_calendars + /api/groups-Router inkl. kombinierter Ansicht /api/groups/{id}/combined (owner + is_group_event). - Ersteller: local_events.creator_id (serverseitig gesetzt) + creator_name_external aus ORGANIZER; creator-Feld in allen lokalen Event-Responses. - Private-Flag: local_events.is_private + user_settings.private_event_visibility (hidden|busy), Filterung in der Gruppenansicht. - iCal Import/Export: ical_io.py, POST /api/local/calendars/{id}/import, POST /api/local/import, GET /api/local/calendars/{id}/export. - Zentraler Berechtigungs-Helper (permissions.py) und gemeinsamer Event-Dict- Builder (local_events_util.py) ersetzen die Nur-Besitzer-Filter. - pytest-Suite (12 Tests) fuer Sharing, Gruppen, Parser, Private-Filterung. Additiv & rueckwaertskompatibel; Migrationen in main.py._migrate(). Co-Authored-By: Claude Opus 4.8 --- backend/ical_io.py | 205 +++++++++++++++++ backend/local_events_util.py | 165 ++++++++++++++ backend/main.py | 29 ++- backend/models.py | 88 +++++++- backend/permissions.py | 126 +++++++++++ backend/routers/caldav_router.py | 125 +---------- backend/routers/groups_router.py | 334 ++++++++++++++++++++++++++++ backend/routers/local_router.py | 311 +++++++++++++++++++++----- backend/routers/settings_router.py | 7 +- backend/routers/users_router.py | 19 ++ backend/tests/conftest.py | 61 +++++ backend/tests/test_collaboration.py | 282 +++++++++++++++++++++++ requirements-dev.txt | 3 + 13 files changed, 1582 insertions(+), 173 deletions(-) create mode 100644 backend/ical_io.py create mode 100644 backend/local_events_util.py create mode 100644 backend/permissions.py create mode 100644 backend/routers/groups_router.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_collaboration.py create mode 100644 requirements-dev.txt diff --git a/backend/ical_io.py b/backend/ical_io.py new file mode 100644 index 0000000..905c471 --- /dev/null +++ b/backend/ical_io.py @@ -0,0 +1,205 @@ +"""iCal (.ics) import/export for local calendars. + +Reuses the already-installed ``icalendar`` library. The parser produces dicts +matching the LocalEvent storage shape (ISO strings, comma-separated EXDATE); +the generator emits a VCALENDAR with ORGANIZER, RRULE, etc. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import date, datetime, timedelta, timezone + +from icalendar import Calendar, Event, vCalAddress, vRecur, vText + +logger = logging.getLogger(__name__) + + +def _rrule_to_str(component) -> str | None: + prop = component.get("RRULE") + if not prop: + return None + return prop.to_ical().decode("utf-8") + + +def _exdate_to_csv(component) -> str | None: + """Collect EXDATE values as comma-separated YYYYMMDD strings.""" + exdate = component.get("EXDATE") + if not exdate: + return None + items = exdate if isinstance(exdate, list) else [exdate] + out = [] + for ex in items: + dts = getattr(ex, "dts", None) or [] + for d in dts: + val = d.dt + if isinstance(val, datetime): + out.append(val.strftime("%Y%m%d")) + elif isinstance(val, date): + out.append(val.strftime("%Y%m%d")) + return ",".join(out) if out else None + + +def _organizer_name(component) -> str | None: + org = component.get("ORGANIZER") + if not org: + return None + # CN parameter holds the display name; fall back to the mailto address. + try: + cn = org.params.get("CN") + if cn: + return str(cn) + except Exception: + pass + raw = str(org) + if raw.lower().startswith("mailto:"): + return raw[7:] + return raw or None + + +def parse_ics(raw: bytes) -> dict: + """Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}. + + Raises ValueError if the payload is not a parseable calendar at all. + """ + try: + cal = Calendar.from_ical(raw) + except Exception as e: + raise ValueError(f"Datei ist kein gültiges iCal-Format: {e}") from e + + events = [] + errors = [] + for component in cal.walk(): + if component.name != "VEVENT": + continue + try: + uid = str(component.get("UID") or uuid.uuid4()) + title = str(component.get("SUMMARY", "") or "") + location = str(component.get("LOCATION", "") or "") or None + description = str(component.get("DESCRIPTION", "") or "") or None + + dtstart_prop = component.get("DTSTART") + if dtstart_prop is None: + errors.append(f"VEVENT {uid}: kein DTSTART, übersprungen") + continue + dtstart = dtstart_prop.dt + dtend_prop = component.get("DTEND") + duration_prop = component.get("DURATION") + + all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime) + if all_day: + if dtend_prop: + dtend = dtend_prop.dt + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(days=1) + start_str = dtstart.isoformat() + end_str = (dtend.isoformat() if isinstance(dtend, date) + else (dtstart + timedelta(days=1)).isoformat()) + else: + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=timezone.utc) + if dtend_prop: + dtend = dtend_prop.dt + if isinstance(dtend, date) and not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time(), tzinfo=timezone.utc) + elif dtend.tzinfo is None: + dtend = dtend.replace(tzinfo=timezone.utc) + elif duration_prop: + dtend = dtstart + duration_prop.dt + else: + dtend = dtstart + timedelta(hours=1) + start_str = dtstart.isoformat() + end_str = dtend.isoformat() + + events.append({ + "uid": uid, + "title": title, + "start": start_str, + "end": end_str, + "all_day": all_day, + "location": location, + "description": description, + "rrule": _rrule_to_str(component), + "exdate": _exdate_to_csv(component), + "organizer": _organizer_name(component), + }) + except Exception as exc: + logger.warning("Skipping malformed VEVENT: %s", exc) + errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}") + return {"events": events, "errors": errors} + + +def _rrule_str_to_vrecur(rrule_str: str) -> vRecur: + params = {} + for part in rrule_str.split(";"): + if "=" not in part: + continue + key, val = part.split("=", 1) + params[key] = val.split(",") if "," in val else val + return vRecur(params) + + +def _parse_iso(s: str) -> datetime: + s = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def build_ics(calendar, events, *, name_cache: dict | None = None) -> str: + """Build a VCALENDAR string for a local calendar and its events.""" + cal = Calendar() + cal.add("prodid", "-//Calendarr//EN") + cal.add("version", "2.0") + cal.add("x-wr-calname", calendar.name) + + for ev in events: + item = Event() + item.add("uid", ev.uid) + item.add("summary", ev.title or "") + item.add("dtstamp", datetime.now(timezone.utc)) + + if ev.all_day: + try: + start = date.fromisoformat(ev.start[:10]) + end = date.fromisoformat(ev.end[:10]) + except ValueError: + continue + if end <= start: + end = start + timedelta(days=1) + item.add("dtstart", start) + item.add("dtend", end) + else: + try: + item.add("dtstart", _parse_iso(ev.start)) + item.add("dtend", _parse_iso(ev.end)) + except ValueError: + continue + + if ev.location: + item.add("location", ev.location) + if ev.description: + item.add("description", ev.description) + if ev.color: + item.add("x-calendarr-color", ev.color) + if ev.rrule: + item.add("rrule", _rrule_str_to_vrecur(ev.rrule)) + + # ORGANIZER from the creator (local user or imported name). + organizer_name = None + if getattr(ev, "creator_id", None) and name_cache: + organizer_name = name_cache.get(ev.creator_id) + if not organizer_name: + organizer_name = getattr(ev, "creator_name_external", None) + if organizer_name: + organizer = vCalAddress("mailto:noreply@calendarr.local") + organizer.params["CN"] = vText(organizer_name.replace('"', "")) + item.add("organizer", organizer) + + cal.add_component(item) + + return cal.to_ical().decode("utf-8") diff --git a/backend/local_events_util.py b/backend/local_events_util.py new file mode 100644 index 0000000..1a88c08 --- /dev/null +++ b/backend/local_events_util.py @@ -0,0 +1,165 @@ +"""Shared builders for local-event API dicts. + +Every local event returned by the API (the local router, the unified event +merge in caldav_router, and the group combined view) must look identical and +carry the additive collaboration fields: ``creator``, ``private``, ``type``, +and — in the group view — ``owner`` and ``is_group_event``. + +Centralising this avoids the three near-duplicate dict constructions that used +to live in caldav_router.py. +""" + +import logging +from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone +from typing import Optional + +from dateutil.rrule import rrulestr +from sqlalchemy.orm import Session + +import models + +logger = logging.getLogger(__name__) + + +def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]: + """Build the ``creator`` payload for an event. + + Returns ``{"id": int, "display_name": username}`` for a local creator, + ``{"id": None, "display_name": " (importiert)"}`` for an imported + event, or ``None`` when no creator info exists (legacy events). + + ``name_cache`` maps user_id -> username to avoid per-event DB lookups; the + creator relationship is used as a fallback. + """ + if ev.creator_id: + display = None + if name_cache is not None: + display = name_cache.get(ev.creator_id) + if display is None and ev.creator is not None: + display = ev.creator.username + if display is not None: + return {"id": ev.creator_id, "display_name": display} + if ev.creator_name_external: + return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} + return None + + +def build_local_event_dict( + ev: models.LocalEvent, + cal: models.LocalCalendar, + *, + start: Optional[str] = None, + end: Optional[str] = None, + all_day: Optional[bool] = None, + rrule: Optional[str] = ..., + creator: Optional[dict] = None, + owner: Optional[dict] = None, + is_group_event: bool = False, +) -> dict: + """Build the unified dict for a single local event (or occurrence). + + ``start``/``end``/``all_day`` override the stored values (used when emitting + an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set + by the group combined view. + """ + d = { + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": ev.start if start is None else start, + "end": ev.end if end is None else end, + "allDay": ev.all_day if all_day is None else all_day, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule if rrule is ... else rrule, + "exdate": ev.exdate, + "calendar_id": f"local-{cal.id}", + "calendar_name": cal.name, + "calendarColor": cal.color, + "source": "local", + "type": "local", + "creator": creator, + "private": bool(ev.is_private), + } + if owner is not None: + d["owner"] = owner + if is_group_event: + d["is_group_event"] = True + return d + + +def expand_recurring_local( + ev: models.LocalEvent, + local_cal: models.LocalCalendar, + range_start, + range_end, + *, + creator: Optional[dict] = None, + owner: Optional[dict] = None, + is_group_event: bool = False, +) -> list: + """Expand a recurring LocalEvent into individual occurrences in the range.""" + results = [] + excluded = set() + if ev.exdate: + for d in ev.exdate.split(","): + d = d.strip() + if d: + excluded.add(d) + try: + ev_start_str = ev.start.replace("Z", "+00:00") + ev_end_str = ev.end.replace("Z", "+00:00") + + if ev.all_day: + ev_start = dt_date.fromisoformat(ev_start_str[:10]) + ev_end = dt_date.fromisoformat(ev_end_str[:10]) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) + r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) + r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_start = occ.date() + occ_key = occ_start.strftime("%Y%m%d") + if occ_key in excluded: + continue + occ_end = occ_start + duration + results.append(build_local_event_dict( + ev, local_cal, + start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, + creator=creator, owner=owner, is_group_event=is_group_event, + )) + else: + ev_start = dt_datetime.fromisoformat(ev_start_str) + ev_end = dt_datetime.fromisoformat(ev_end_str) + if ev_start.tzinfo is None: + ev_start = ev_start.replace(tzinfo=dt_timezone.utc) + if ev_end.tzinfo is None: + ev_end = ev_end.replace(tzinfo=dt_timezone.utc) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) + r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + if r_start.tzinfo is None: + r_start = r_start.replace(tzinfo=dt_timezone.utc) + if r_end.tzinfo is None: + r_end = r_end.replace(tzinfo=dt_timezone.utc) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_key = occ.strftime("%Y%m%d") + if occ_key in excluded: + continue + occ_end = occ + duration + results.append(build_local_event_dict( + ev, local_cal, + start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, + creator=creator, owner=owner, is_group_event=is_group_event, + )) + except Exception as exc: + logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) + # Fall back to a single event. + results.append(build_local_event_dict( + ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, + )) + return results diff --git a/backend/main.py b/backend/main.py index 3312035..562fa3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -132,6 +132,32 @@ def _migrate(): except Exception: pass + # ── Collaboration features (sharing, groups, creator, private) ── + try: + conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'")) + conn.commit() + logging.info("Migration: added private_event_visibility to user_settings") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER")) + conn.commit() + logging.info("Migration: added creator_id to local_events") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT")) + conn.commit() + logging.info("Migration: added creator_name_external to local_events") + except Exception: + pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0")) + conn.commit() + logging.info("Migration: added is_private to local_events") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) @@ -170,6 +196,7 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"]) +app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(google_router.router, prefix="/api/google", tags=["google"]) app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"]) diff --git a/backend/models.py b/backend/models.py index 18f6cc7..5b3fb39 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy.orm import relationship from database import Base @@ -34,6 +34,11 @@ class User(Base): "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" ) + @property + def display_name(self) -> str: + """No dedicated display-name column exists — fall back to the username.""" + return self.username + class CalDAVAccount(Base): __tablename__ = "caldav_accounts" @@ -87,6 +92,9 @@ class UserSettings(Base): text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast) line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast) bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default) + # How this user's private events appear to other group members: + # 'hidden' = invisible, 'busy' = anonymous busy block (default). + private_event_visibility = Column(String(10), default="busy") user = relationship("User", back_populates="settings") @@ -119,8 +127,15 @@ class LocalEvent(Base): color = Column(String(7), nullable=True) rrule = Column(Text, nullable=True) exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude + # Creator: set server-side from the auth token on create, never from the client. + creator_id = Column(Integer, ForeignKey("users.id"), nullable=True) + # For imported events without a local user (from the .ics ORGANIZER field). + creator_name_external = Column(Text, nullable=True) + # Private events are filtered for other group members per their visibility setting. + is_private = Column(Boolean, default=False) calendar = relationship("LocalCalendar", back_populates="events") + creator = relationship("User") class ICalSubscription(Base): @@ -219,3 +234,74 @@ class HomeAssistantCalendar(Base): sidebar_hidden = Column(Boolean, default=False) account = relationship("HomeAssistantAccount", back_populates="calendars") + + +# ── Collaboration: sharing & groups (local calendars only) ──────────────── + + +class CalendarShare(Base): + """A local calendar shared with another Calendarr user.""" + + __tablename__ = "calendar_shares" + __table_args__ = ( + UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"), + ) + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + permission = Column(String(20), default="read") # 'read' | 'read_write' + created_at = Column(String(50), nullable=True) # ISO 8601 + + calendar = relationship("LocalCalendar") + user = relationship("User") + + +class Group(Base): + __tablename__ = "groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(String(50), nullable=True) # ISO 8601 + + members = relationship( + "GroupMember", back_populates="group", cascade="all, delete-orphan" + ) + group_calendar = relationship( + "GroupCalendar", back_populates="group", uselist=False, + cascade="all, delete-orphan", + ) + + +class GroupMember(Base): + __tablename__ = "group_members" + __table_args__ = ( + UniqueConstraint("group_id", "user_id", name="uq_group_member"), + ) + + id = Column(Integer, primary_key=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + role = Column(String(10), default="member") # 'owner' | 'member' + joined_at = Column(String(50), nullable=True) # ISO 8601 + + group = relationship("Group", back_populates="members") + user = relationship("User") + + +class GroupCalendar(Base): + """1:1 link between a group and its shared local calendar.""" + + __tablename__ = "group_calendars" + __table_args__ = ( + UniqueConstraint("group_id", name="uq_group_calendar_group"), + UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"), + ) + + id = Column(Integer, primary_key=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + + group = relationship("Group", back_populates="group_calendar") + calendar = relationship("LocalCalendar") diff --git a/backend/permissions.py b/backend/permissions.py new file mode 100644 index 0000000..3c86c3b --- /dev/null +++ b/backend/permissions.py @@ -0,0 +1,126 @@ +"""Central access control for local calendars. + +Local calendars are visible/writable to a user if any of the following holds: + - the user owns the calendar (LocalCalendar.user_id), + - the calendar is shared with the user (calendar_shares; write needs 'read_write'), + - the calendar is a group calendar and the user is a member of that group + (members get read & write). + +These helpers replace the scattered owner-only filters so sharing and groups +work consistently across every local-calendar endpoint and the event merge read. +""" + +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +import models + + +def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]: + return ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == user_id, + ) + .first() + ) + + +def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool: + gc = ( + db.query(models.GroupCalendar) + .filter(models.GroupCalendar.calendar_id == calendar_id) + .first() + ) + if not gc: + return False + member = ( + db.query(models.GroupMember) + .filter( + models.GroupMember.group_id == gc.group_id, + models.GroupMember.user_id == user_id, + ) + .first() + ) + return member is not None + + +def accessible_local_calendar( + db: Session, + user: models.User, + calendar_id: int, + *, + require_write: bool = False, +) -> models.LocalCalendar: + """Return the calendar if the user may access it, else raise 404/403. + + 404 when the calendar does not exist or is not visible to the user (so we + don't leak existence). 403 when it is visible (read) but write is required. + """ + cal = ( + db.query(models.LocalCalendar) + .filter(models.LocalCalendar.id == calendar_id) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + + if cal.user_id == user.id: + return cal # owner: full access + + if _is_group_calendar_member(db, calendar_id, user.id): + return cal # group members get read & write + + share = _share_for(db, calendar_id, user.id) + if share is None: + raise HTTPException(404, "Calendar not found") + if require_write and share.permission != "read_write": + raise HTTPException(403, "You only have read access to this calendar") + return cal + + +def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar: + """Return the calendar only if the user owns it, else raise 404.""" + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == calendar_id, + models.LocalCalendar.user_id == user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + return cal + + +def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]: + """All local calendar ids the user may read: own + shared + group calendars.""" + ids: set[int] = set() + + own = ( + db.query(models.LocalCalendar.id) + .filter(models.LocalCalendar.user_id == user.id) + .all() + ) + ids.update(r[0] for r in own) + + shared = ( + db.query(models.CalendarShare.calendar_id) + .filter(models.CalendarShare.user_id == user.id) + .all() + ) + ids.update(r[0] for r in shared) + + group_cals = ( + db.query(models.GroupCalendar.calendar_id) + .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) + .filter(models.GroupMember.user_id == user.id) + .all() + ) + ids.update(r[0] for r in group_cals) + + return list(ids) diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 98e2304..63cdac8 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -11,8 +11,10 @@ from sqlalchemy import or_ import caldav_client import models +import permissions from auth import get_current_user from database import get_db +from local_events_util import build_local_event_dict, expand_recurring_local, resolve_creator from routers.ical_router import _refresh_if_needed, get_events_for_subscription logger = logging.getLogger(__name__) @@ -82,101 +84,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict: } -def _expand_recurring_local(ev, local_cal, range_start, range_end): - """Expand a recurring LocalEvent into individual occurrences within the date range.""" - results = [] - # Parse excluded dates - excluded = set() - if ev.exdate: - for d in ev.exdate.split(","): - d = d.strip() - if d: - excluded.add(d) - try: - ev_start_str = ev.start.replace("Z", "+00:00") - ev_end_str = ev.end.replace("Z", "+00:00") - - if ev.all_day: - ev_start = dt_date.fromisoformat(ev_start_str[:10]) - ev_end = dt_date.fromisoformat(ev_end_str[:10]) - duration = ev_end - ev_start - rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) - r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) - r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) - occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) - for occ in occurrences: - occ_start = occ.date() - occ_key = occ_start.strftime("%Y%m%d") - if occ_key in excluded: - continue - occ_end = occ_start + duration - results.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": occ_start.isoformat(), - "end": occ_end.isoformat(), - "allDay": True, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) - else: - ev_start = dt_datetime.fromisoformat(ev_start_str) - ev_end = dt_datetime.fromisoformat(ev_end_str) - if ev_start.tzinfo is None: - ev_start = ev_start.replace(tzinfo=dt_timezone.utc) - if ev_end.tzinfo is None: - ev_end = ev_end.replace(tzinfo=dt_timezone.utc) - duration = ev_end - ev_start - rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) - r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) - r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) - if r_start.tzinfo is None: - r_start = r_start.replace(tzinfo=dt_timezone.utc) - if r_end.tzinfo is None: - r_end = r_end.replace(tzinfo=dt_timezone.utc) - occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) - for occ in occurrences: - occ_key = occ.strftime("%Y%m%d") - if occ_key in excluded: - continue - occ_end = occ + duration - results.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": occ.isoformat(), - "end": occ_end.isoformat(), - "allDay": False, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) - except Exception as exc: - logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) - # Fall back to single event - results.append({ - "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title, - "start": ev.start, "end": ev.end, "allDay": ev.all_day, - "location": ev.location or "", "description": ev.description or "", - "color": ev.color, "rrule": ev.rrule, - "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name, - "calendarColor": local_cal.color, "source": "local", - }) - return results - - def _normalize_url(url: str) -> str: """Normalize URL for comparison: lowercase scheme/host, strip trailing slash.""" parsed = urlparse(url) @@ -417,15 +324,17 @@ def get_events( "Error fetching calendar %s: %s", calendar.id, exc ) - # ── Local calendar events ───────────────────────────── + # ── Local calendar events (own + shared + group calendars) ───────────── + readable_ids = permissions.readable_local_calendar_ids(db, current_user) local_calendars = ( db.query(models.LocalCalendar) .filter( - models.LocalCalendar.user_id == current_user.id, + models.LocalCalendar.id.in_(readable_ids), models.LocalCalendar.enabled == True, ) .all() - ) + ) if readable_ids else [] + name_cache = {u.id: u.username for u in db.query(models.User).all()} for local_cal in local_calendars: local_events = ( db.query(models.LocalEvent) @@ -441,25 +350,11 @@ def get_events( .all() ) for ev in local_events: + creator = resolve_creator(ev, name_cache=name_cache) if ev.rrule: - all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt)) + all_events.extend(expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)) else: - all_events.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": None, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) + all_events.append(build_local_event_dict(ev, local_cal, rrule=None, creator=creator)) # ── iCal subscription events ────────────────────────── ical_subs = ( diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py new file mode 100644 index 0000000..f3314e1 --- /dev/null +++ b/backend/routers/groups_router.py @@ -0,0 +1,334 @@ +"""Groups: shared group calendar + combined member-calendar overlay view. + +A group has members and exactly one group calendar (a local calendar owned by +the creator, linked via group_calendars). Members get read/write on the group +calendar (enforced by permissions.accessible_local_calendar). The combined view +overlays every member's local calendars plus the group calendar, applying each +member's private-event visibility setting. +""" + +import logging +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from database import get_db +from local_events_util import build_local_event_dict, expand_recurring_local + +logger = logging.getLogger(__name__) +router = APIRouter() + +PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"] + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class GroupCreate(BaseModel): + name: str + member_ids: List[int] = [] + + +class MemberAdd(BaseModel): + user_id: int + + +def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]: + return ( + db.query(models.GroupMember) + .filter( + models.GroupMember.group_id == group_id, + models.GroupMember.user_id == user_id, + ) + .first() + ) + + +def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember: + m = _membership(db, group.id, user.id) + if not m: + raise HTTPException(403, "You are not a member of this group") + return m + + +def _require_owner(db: Session, group: models.Group, user: models.User) -> None: + m = _membership(db, group.id, user.id) + if not m or m.role != "owner": + raise HTTPException(403, "Only the group owner may do this") + + +def _get_group_or_404(db: Session, group_id: int) -> models.Group: + g = db.query(models.Group).filter(models.Group.id == group_id).first() + if not g: + raise HTTPException(404, "Group not found") + return g + + +def _group_calendar_id(db: Session, group_id: int) -> Optional[int]: + gc = ( + db.query(models.GroupCalendar) + .filter(models.GroupCalendar.group_id == group_id) + .first() + ) + return gc.calendar_id if gc else None + + +@router.post("/") +def create_group( + data: GroupCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = models.Group(name=data.name, created_by=current_user.id, created_at=_now_iso()) + db.add(group) + db.flush() + + # Creator is owner; add the requested members (deduped, excluding creator). + db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", joined_at=_now_iso())) + seen = {current_user.id} + for uid in data.member_ids: + if uid in seen: + continue + if not db.query(models.User).filter(models.User.id == uid).first(): + continue + db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", joined_at=_now_iso())) + seen.add(uid) + + # Auto-create the group calendar (a local calendar owned by the creator). + cal = models.LocalCalendar( + user_id=current_user.id, + name=f"{data.name} (Gruppe)", + color=PALETTE[group.id % len(PALETTE)], + ) + db.add(cal) + db.flush() + db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id)) + + db.commit() + db.refresh(group) + return _group_detail(db, group, current_user) + + +@router.get("/") +def list_groups( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + memberships = ( + db.query(models.GroupMember) + .filter(models.GroupMember.user_id == current_user.id) + .all() + ) + out = [] + for m in memberships: + group = db.query(models.Group).filter(models.Group.id == m.group_id).first() + if not group: + continue + member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count() + out.append({ + "id": group.id, + "name": group.name, + "role": m.role, + "member_count": member_count, + "group_calendar_id": _group_calendar_id(db, group.id), + }) + return out + + +def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict: + members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all() + member_dicts = [] + for m in members: + u = db.query(models.User).filter(models.User.id == m.user_id).first() + member_dicts.append({ + "id": m.user_id, + "display_name": u.username if u else None, + "role": m.role, + }) + return { + "id": group.id, + "name": group.name, + "created_by": group.created_by, + "members": member_dicts, + "group_calendar_id": _group_calendar_id(db, group.id), + } + + +@router.get("/{group_id}") +def get_group( + group_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_member(db, group, current_user) + return _group_detail(db, group, current_user) + + +@router.post("/{group_id}/members") +def add_member( + group_id: int, + data: MemberAdd, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_owner(db, group, current_user) + if not db.query(models.User).filter(models.User.id == data.user_id).first(): + raise HTTPException(404, "User not found") + if _membership(db, group_id, data.user_id): + return {"ok": True} # already a member + db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", joined_at=_now_iso())) + db.commit() + return {"ok": True} + + +@router.delete("/{group_id}/members/{user_id}") +def remove_member( + group_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + # Owner can remove anyone; a member may remove themselves (leave). + if user_id != current_user.id: + _require_owner(db, group, current_user) + else: + _require_member(db, group, current_user) + target = _membership(db, group_id, user_id) + if not target: + raise HTTPException(404, "Member not found") + if target.role == "owner": + raise HTTPException(422, "The owner cannot be removed; delete the group instead") + db.delete(target) + db.commit() + return {"ok": True} + + +@router.delete("/{group_id}") +def delete_group( + group_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_owner(db, group, current_user) + # Remove the group calendar (and its events) too. + gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first() + if gc: + cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first() + if cal: + db.delete(cal) # cascades to events + db.delete(group) # cascades to members + group_calendar link + db.commit() + return {"ok": True} + + +def _strip_busy(event: dict) -> dict: + """Anonymise a private event for the 'busy' visibility mode.""" + event = dict(event) + event["title"] = "Beschäftigt" + event["location"] = "" + event["description"] = "" + event["private"] = True + return event + + +@router.get("/{group_id}/combined") +def combined_events( + group_id: int, + start: str = Query(...), + end: str = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + group = _get_group_or_404(db, group_id) + _require_member(db, group, current_user) + + try: + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(400, "Invalid date format — use ISO 8601") + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + + members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all() + name_cache = {u.id: u.username for u in db.query(models.User).all()} + visibility_cache: dict[int, str] = {} + + def visibility_for(user_id: int) -> str: + if user_id not in visibility_cache: + s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first() + visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy" + return visibility_cache[user_id] + + group_cal_id = _group_calendar_id(db, group_id) + all_events: list[dict] = [] + + def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): + owner_user = name_cache.get(owner_id) + owner = {"id": owner_id, "display_name": owner_user} + events = ( + db.query(models.LocalEvent) + .filter( + models.LocalEvent.calendar_id == cal.id, + or_( + (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start), + models.LocalEvent.rrule != None, + ), + ) + .all() + ) + for ev in events: + creator_owner_id = ev.creator_id or owner_id + # Private filtering for events that belong to someone else. + if ev.is_private and creator_owner_id != current_user.id: + vis = visibility_for(creator_owner_id) + if vis == "hidden": + continue + creator = None + if ev.creator_id and name_cache.get(ev.creator_id): + creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]} + elif ev.creator_name_external: + creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} + + if ev.rrule: + built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group) + else: + built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)] + + for b in built: + if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": + b = _strip_busy(b) + all_events.append(b) + + # Each member's own local calendars (excluding the group calendar to avoid dupes). + for m in members: + member_cals = ( + db.query(models.LocalCalendar) + .filter(models.LocalCalendar.user_id == m.user_id) + .all() + ) + for cal in member_cals: + if group_cal_id is not None and cal.id == group_cal_id: + continue + emit_calendar(cal, m.user_id, is_group=False) + + # The group calendar itself. + if group_cal_id is not None: + group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first() + if group_cal: + emit_calendar(group_cal, group_cal.user_id, is_group=True) + + return {"events": all_events} diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index e7d6b97..7144c8b 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -1,17 +1,26 @@ import uuid +from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File +from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session +import ical_io import models +import permissions from auth import get_current_user from database import get_db +from local_events_util import build_local_event_dict, resolve_creator router = APIRouter() +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + class CalendarCreate(BaseModel): name: str color: str = "#34a853" @@ -33,6 +42,7 @@ class EventCreate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + private: bool = False class EventUpdate(BaseModel): @@ -45,35 +55,33 @@ class EventUpdate(BaseModel): color: Optional[str] = None rrule: Optional[str] = None exdate: Optional[str] = None + private: Optional[bool] = None -def _cal_dict(cal: models.LocalCalendar) -> dict: - return { +class ShareCreate(BaseModel): + user_id: int + permission: str = "read" + + +def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, + shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict: + d = { "id": cal.id, "name": cal.name, "color": cal.color, "enabled": cal.enabled, + "type": "local", + "owned": owned, } + if shared_by is not None: + d["shared_by"] = shared_by + if permission is not None: + d["permission"] = permission + return d -def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: - return { - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "rrule": ev.rrule, - "exdate": ev.exdate, - "calendar_id": f"local-{cal.id}", - "calendar_name": cal.name, - "calendarColor": cal.color, - "source": "local", - } +def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: + return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) # ── Calendar CRUD ───────────────────────────────────────── @@ -83,12 +91,31 @@ def list_calendars( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - cals = ( + # Own calendars + own = ( db.query(models.LocalCalendar) .filter(models.LocalCalendar.user_id == current_user.id) .all() ) - return [_cal_dict(c) for c in cals] + result = [_cal_dict(c, owned=True) for c in own] + + # Calendars shared with this user + shares = ( + db.query(models.CalendarShare) + .filter(models.CalendarShare.user_id == current_user.id) + .all() + ) + for share in shares: + cal = share.calendar + if cal is None: + continue + owner = db.query(models.User).filter(models.User.id == cal.user_id).first() + result.append(_cal_dict( + cal, owned=False, + shared_by=owner.username if owner else None, + permission=share.permission, + )) + return result @router.post("/calendars") @@ -164,16 +191,10 @@ def create_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - cal = ( - db.query(models.LocalCalendar) - .filter( - models.LocalCalendar.id == data.calendar_id, - models.LocalCalendar.user_id == current_user.id, - ) - .first() + # Owner, shared (read_write), or group-member calendars are writable. + cal = permissions.accessible_local_calendar( + db, current_user, data.calendar_id, require_write=True ) - if not cal: - raise HTTPException(404, "Calendar not found") ev = models.LocalEvent( calendar_id=cal.id, @@ -186,11 +207,22 @@ def create_event( description=data.description, color=data.color, rrule=data.rrule, + is_private=data.private, + creator_id=current_user.id, # server-side, never from the client ) db.add(ev) db.commit() db.refresh(ev) - return _event_dict(ev, cal) + return _event_dict(ev, cal, db) + + +def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent: + ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() + if not ev: + raise HTTPException(404, "Event not found") + # Raises 404/403 unless the user may write this event's calendar. + permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True) + return ev @router.put("/events/{uid}") @@ -200,17 +232,9 @@ def update_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - ev = ( - db.query(models.LocalEvent) - .join(models.LocalCalendar) - .filter( - models.LocalEvent.uid == uid, - models.LocalCalendar.user_id == current_user.id, - ) - .first() - ) - if not ev: - raise HTTPException(404, "Event not found") + ev = _writable_event(db, current_user, uid) + if data.private is not None: + ev.is_private = data.private if data.title is not None: ev.title = data.title if data.start is not None: @@ -243,17 +267,194 @@ def delete_event( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - ev = ( - db.query(models.LocalEvent) - .join(models.LocalCalendar) - .filter( - models.LocalEvent.uid == uid, - models.LocalCalendar.user_id == current_user.id, - ) - .first() - ) - if not ev: - raise HTTPException(404, "Event not found") + ev = _writable_event(db, current_user, uid) db.delete(ev) db.commit() return {"ok": True} + + +# ── Sharing (owner only) ────────────────────────────────── + +@router.get("/calendars/{calendar_id}/shares") +def list_shares( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + shares = ( + db.query(models.CalendarShare) + .filter(models.CalendarShare.calendar_id == calendar_id) + .all() + ) + out = [] + for s in shares: + u = db.query(models.User).filter(models.User.id == s.user_id).first() + out.append({ + "user_id": s.user_id, + "display_name": u.username if u else None, + "permission": s.permission, + "created_at": s.created_at, + }) + return out + + +@router.post("/calendars/{calendar_id}/shares") +def add_share( + calendar_id: int, + data: ShareCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + if data.permission not in ("read", "read_write"): + raise HTTPException(422, "permission must be 'read' or 'read_write'") + target = db.query(models.User).filter(models.User.id == data.user_id).first() + if not target: + raise HTTPException(404, "User not found") + if target.id == current_user.id: + raise HTTPException(422, "Cannot share a calendar with yourself") + + share = ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == data.user_id, + ) + .first() + ) + if share: + share.permission = data.permission # update existing + else: + share = models.CalendarShare( + calendar_id=calendar_id, + user_id=data.user_id, + permission=data.permission, + created_at=_now_iso(), + ) + db.add(share) + db.commit() + return {"ok": True} + + +@router.delete("/calendars/{calendar_id}/shares/{user_id}") +def remove_share( + calendar_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + permissions.is_calendar_owner(db, current_user, calendar_id) + share = ( + db.query(models.CalendarShare) + .filter( + models.CalendarShare.calendar_id == calendar_id, + models.CalendarShare.user_id == user_id, + ) + .first() + ) + if not share: + raise HTTPException(404, "Share not found") + db.delete(share) + db.commit() + return {"ok": True} + + +# ── iCal Import / Export (local calendars only) ─────────── + +def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict: + parsed = ical_io.parse_ics(raw) + imported = 0 + skipped = 0 + for item in parsed["events"]: + uid = item.get("uid") or str(uuid.uuid4()) + existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() + if existing: + skipped += 1 + continue + ev = models.LocalEvent( + calendar_id=cal.id, + uid=uid, + title=item.get("title") or "(ohne Titel)", + start=item["start"], + end=item["end"], + all_day=item.get("all_day", False), + location=item.get("location"), + description=item.get("description"), + rrule=item.get("rrule"), + exdate=item.get("exdate"), + creator_name_external=item.get("organizer"), + ) + db.add(ev) + imported += 1 + db.commit() + return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]} + + +@router.post("/calendars/{calendar_id}/import") +async def import_calendar( + calendar_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) + raw = await file.read() + try: + return _import_ics_into(cal, raw, db) + except ValueError as e: + raise HTTPException(422, str(e)) + + +@router.post("/import") +async def import_generic( + file: UploadFile = File(...), + calendar_id: Optional[int] = Form(None), + create_calendar: bool = Form(False), + calendar_name: Optional[str] = Form(None), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + if create_calendar: + cal = models.LocalCalendar( + user_id=current_user.id, + name=calendar_name or "Importiert", + ) + db.add(cal) + db.commit() + db.refresh(cal) + elif calendar_id is not None: + cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True) + else: + raise HTTPException(422, "Provide calendar_id or create_calendar=true") + + raw = await file.read() + try: + result = _import_ics_into(cal, raw, db) + except ValueError as e: + raise HTTPException(422, str(e)) + result["calendar_id"] = cal.id + return result + + +@router.get("/calendars/{calendar_id}/export") +def export_calendar( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = permissions.accessible_local_calendar(db, current_user, calendar_id) + events = ( + db.query(models.LocalEvent) + .filter(models.LocalEvent.calendar_id == cal.id) + .all() + ) + # Resolve creator display names for ORGANIZER. + name_cache = {u.id: u.username for u in db.query(models.User).all()} + ics = ical_io.build_ics(cal, events, name_cache=name_cache) + safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar" + return Response( + content=ics, + media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, + ) diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index b5c639d..1caad75 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -1,6 +1,6 @@ from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -27,6 +27,7 @@ class SettingsUpdate(BaseModel): text_color: Optional[str] = None line_color: Optional[str] = None bg_color: Optional[str] = None + private_event_visibility: Optional[str] = None def _settings_dict(s: models.UserSettings) -> dict: @@ -46,6 +47,7 @@ def _settings_dict(s: models.UserSettings) -> dict: "text_color": s.text_color, "line_color": s.line_color, "bg_color": s.bg_color, + "private_event_visibility": s.private_event_visibility or "busy", } @@ -82,6 +84,9 @@ def update_settings( settings = models.UserSettings(user_id=current_user.id) db.add(settings) + if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"): + raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'") + # For these three override colours, an explicit null is meaningful # ("reset to default") and must be persisted as NULL. All other fields # keep the previous behaviour where a null/missing value is ignored. diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py index 7b50248..35fbd08 100644 --- a/backend/routers/users_router.py +++ b/backend/routers/users_router.py @@ -35,6 +35,25 @@ def list_users( return [_user_dict(u) for u in db.query(models.User).all()] +@router.get("/directory") +def user_directory( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Lightweight list of all users (id + display_name) for sharing/group pickers. + + Available to any authenticated user (unlike GET / which is admin-only). + Excludes the requesting user. + """ + users = ( + db.query(models.User) + .filter(models.User.id != current_user.id) + .order_by(models.User.username) + .all() + ) + return [{"id": u.id, "display_name": u.username} for u in users] + + @router.post("/") def create_user( req: CreateUserRequest, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..105e3fd --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,61 @@ +"""Pytest fixtures: an isolated app + temp SQLite DB, wiped between tests.""" + +import os +import sys +import tempfile +from pathlib import Path + +# Use a throwaway data dir BEFORE importing the app (database.py reads DATA_DIR +# at import time and builds the engine from it). +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp(prefix="calendarr-test-")) +os.environ.setdefault("SECRET_KEY", "test-secret-key") + +BACKEND_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND_DIR)) + +import pytest +from fastapi.testclient import TestClient + +import main # noqa: E402 (creates tables + runs migrations against the temp DB) +import models # noqa: E402 +from database import engine # noqa: E402 + + +@pytest.fixture +def client(): + return TestClient(main.app) + + +@pytest.fixture(autouse=True) +def clean_db(): + """Wipe every table before each test for isolation.""" + with engine.begin() as conn: + for table in reversed(models.Base.metadata.sorted_tables): + conn.execute(table.delete()) + yield + + +# ── Helpers ─────────────────────────────────────────────── + +def register_admin(client, username="admin", password="pw"): + r = client.post("/api/auth/setup", json={"username": username, "password": password}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def create_user(client, admin_token, username, password="pw"): + r = client.post( + "/api/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"username": username, "password": password}, + ) + assert r.status_code == 200, r.text + uid = r.json()["id"] + # Log in to get the user's own token. + r2 = client.post("/api/auth/login", json={"username": username, "password": password}) + assert r2.status_code == 200, r2.text + return uid, r2.json()["access_token"] + + +def auth(token): + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py new file mode 100644 index 0000000..740cc24 --- /dev/null +++ b/backend/tests/test_collaboration.py @@ -0,0 +1,282 @@ +"""Tests for sharing, group permissions, the iCal parser, and private filtering.""" + +from conftest import register_admin, create_user, auth + +RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"} + + +def _make_calendar(client, token, name="Cal"): + r = client.post("/api/local/calendars", headers=auth(token), json={"name": name}) + assert r.status_code == 200, r.text + return r.json()["id"] + + +def _make_event(client, token, cal_id, title="Event", private=False, + start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"): + r = client.post("/api/local/events", headers=auth(token), json={ + "calendar_id": cal_id, "title": title, "start": start, "end": end, + "private": private, + }) + assert r.status_code == 200, r.text + return r.json() + + +# ── Sharing ─────────────────────────────────────────────── + +def test_share_read_then_read_write(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + + cal_id = _make_calendar(client, admin, "Admins Kalender") + ev = _make_event(client, admin, cal_id, "Meeting") + + # Creator field populated server-side. + assert ev["creator"]["display_name"] == "admin" + assert ev["type"] == "local" + + # Share read-only with bob. + r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin), + json={"user_id": b_id, "permission": "read"}) + assert r.status_code == 200, r.text + + # Bob sees the shared calendar with shared_by. + cals = client.get("/api/local/calendars", headers=auth(b_tok)).json() + shared = [c for c in cals if not c["owned"]] + assert len(shared) == 1 + assert shared[0]["shared_by"] == "admin" + assert shared[0]["permission"] == "read" + + # Bob sees the event in the merged read. + events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"] + assert any(e["title"] == "Meeting" for e in events) + + # Bob cannot write (read-only) -> 403. + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": cal_id, "title": "Nope", + "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00", + }) + assert r.status_code == 403, r.text + + # Upgrade to read_write -> bob can write. + client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin), + json={"user_id": b_id, "permission": "read_write"}) + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": cal_id, "title": "Bobs Eintrag", + "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00", + }) + assert r.status_code == 200, r.text + # Created by bob. + assert r.json()["creator"]["display_name"] == "bob" + + +def test_non_owner_cannot_manage_shares(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin) + # Bob (no access at all) cannot list shares -> 404 (existence hidden). + r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok)) + assert r.status_code == 404 + + +def test_unshared_calendar_invisible(client): + admin = register_admin(client) + _b_id, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin) + _make_event(client, admin, cal_id, "Privat") + events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"] + assert not any(e["title"] == "Privat" for e in events) + + +# ── Groups ──────────────────────────────────────────────── + +def test_group_create_and_members(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + c_id, c_tok = create_user(client, admin, "carol") + + r = client.post("/api/groups/", headers=auth(admin), + json={"name": "Familie", "member_ids": [b_id]}) + assert r.status_code == 200, r.text + group = r.json() + gid = group["id"] + assert group["group_calendar_id"] is not None + assert {m["display_name"] for m in group["members"]} == {"admin", "bob"} + + # Both members see the group. + assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json()) + # Carol is not a member. + assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json()) + assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403 + + # Only owner adds members. + assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok), + json={"user_id": c_id}).status_code == 403 + assert client.post(f"/api/groups/{gid}/members", headers=auth(admin), + json={"user_id": c_id}).status_code == 200 + + # Member can leave; owner cannot be removed. + assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200 + admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"] + assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422 + + +def test_group_members_can_write_group_calendar(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gcal = group["group_calendar_id"] + # Bob (member, not owner of the calendar) can create in the group calendar. + r = client.post("/api/local/events", headers=auth(b_tok), json={ + "calendar_id": gcal, "title": "Teamtermin", + "start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00", + }) + assert r.status_code == 200, r.text + + +def test_combined_view_marks_owner_and_group_event(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + gcal = group["group_calendar_id"] + + # Bob's own calendar + event. + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + _make_event(client, b_tok, b_cal, "Bobs Termin") + # A group-calendar event. + _make_event(client, admin, gcal, "Gruppentermin") + + events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"] + titles = {e["title"]: e for e in events} + assert "Bobs Termin" in titles + assert titles["Bobs Termin"]["owner"]["display_name"] == "bob" + assert titles["Bobs Termin"].get("is_group_event") is not True + assert "Gruppentermin" in titles + assert titles["Gruppentermin"]["is_group_event"] is True + + +# ── Private filtering ───────────────────────────────────── + +def _combined_titles(client, token, gid): + evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"] + return evs + + +def test_private_visibility_hidden_and_busy(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + _make_event(client, b_tok, b_cal, "Geheimes", private=True, + start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00") + + # Bob sees his own private event in full. + own = _combined_titles(client, b_tok, gid) + assert any(e["title"] == "Geheimes" for e in own) + + # Default visibility = busy -> admin sees it as anonymous "Beschäftigt". + seen = _combined_titles(client, admin, gid) + busy = [e for e in seen if e["start"].startswith("2026-06-15")] + assert busy and all(e["title"] == "Beschäftigt" for e in busy) + assert all(e["location"] == "" and e["description"] == "" for e in busy) + + # Switch bob to hidden -> admin no longer sees it at all. + client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"}) + seen2 = _combined_titles(client, admin, gid) + assert not any(e["start"].startswith("2026-06-15") for e in seen2) + + +def test_private_visibility_validation(client): + admin = register_admin(client) + r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"}) + assert r.status_code == 422 + + +# ── iCal import/export ──────────────────────────────────── + +SAMPLE_ICS = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:evt-1@test +SUMMARY:Importiert 1 +DTSTART:20260620T100000Z +DTEND:20260620T110000Z +LOCATION:Buero +ORGANIZER;CN=Max Mustermann:mailto:max@example.com +RRULE:FREQ=WEEKLY;BYDAY=MO +END:VEVENT +BEGIN:VEVENT +UID:evt-2@test +SUMMARY:Importiert 2 +DTSTART;VALUE=DATE:20260621 +DTEND;VALUE=DATE:20260622 +END:VEVENT +END:VCALENDAR +""" + + +def test_ical_parser_roundtrip(): + import ical_io + parsed = ical_io.parse_ics(SAMPLE_ICS) + assert len(parsed["events"]) == 2 + ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test") + assert ev1["title"] == "Importiert 1" + assert ev1["location"] == "Buero" + assert ev1["organizer"] == "Max Mustermann" + assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO" + ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test") + assert ev2["all_day"] is True + + +def test_import_dedupes_by_uid(client): + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "Import-Ziel") + + files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")} + r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r.status_code == 200, r.text + body = r.json() + assert body["imported"] == 2 and body["skipped"] == 0 + + # Re-import -> all skipped (UID dedupe). + files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")} + r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2 + + # Imported events carry the external creator name. + events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"] + imported = [e for e in events if e["title"] == "Importiert 1"] + assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)" + + +def test_export_contains_organizer_and_rrule(client): + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "Export-Test") + _make_event(client, admin, cal_id, "Wöchentlich") + # Add a recurring rule via update. + events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"] + uid = next(e["id"] for e in events if e["title"] == "Wöchentlich") + client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"}) + + r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin)) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/calendar") + body = r.text + assert "BEGIN:VCALENDAR" in body + assert "ORGANIZER" in body and "admin" in body + assert "RRULE" in body + + +def test_import_export_only_local(client): + """Import/export endpoints reject non-existent / inaccessible calendars.""" + admin = register_admin(client) + _b, b_tok = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin, "Privat") + # Bob has no access -> 404 on export. + assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2f21eed --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +httpx>=0.27 -- 2.47.3 From 8d2a697f8b64e38184a5fc5331985c927dbb0573 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 16:30:47 +0200 Subject: [PATCH 086/114] =?UTF-8?q?feat:=20Web-Frontend=20=E2=80=93=20Shar?= =?UTF-8?q?ing,=20iCal=20Import/Export,=20Ersteller=20&=20Privat-Flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ersteller-Zeile im Event-Popup (nur wenn Ersteller != aktueller User). - Privat-Toggle im Event-Editor (nur lokale Kalender) + Sichtbarkeits- Auswahl (hidden|busy) in den Einstellungen. - Lokale Kalender in Settings & Sidebar: Teilen/Importieren/Exportieren- Aktionen (nur eigene; geteilte mit "geteilt von"-Badge, kein Loeschen). - Share-Modal: Benutzerverzeichnis mit Suche, read/read_write, Freigaben entfernen. - api.js: download()-Helper fuer iCal-Export (Blob). Co-Authored-By: Claude Opus 4.8 --- frontend/css/app.css | 37 ++++++++ frontend/index.html | 44 ++++++++++ frontend/js/api.js | 34 ++++++++ frontend/js/calendar.js | 189 +++++++++++++++++++++++++++++++++++++--- frontend/js/i18n.js | 46 ++++++++++ 5 files changed, 336 insertions(+), 14 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index d3a38f0..e519df7 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1764,3 +1764,40 @@ a { color: var(--primary); text-decoration: none; } } } + +/* ── Collaboration: sharing badges & user picker ───────────── */ +.cal-badge { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-surface); + color: var(--text-2); + border: 1px solid var(--border); + white-space: nowrap; +} +.cal-badge-shared { + background: rgba(66, 133, 244, 0.15); + color: var(--primary); + border-color: transparent; +} +.share-user-picker { + margin-top: 8px; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 10px; +} +.share-user-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.share-user-item:last-child { border-bottom: none; } +.share-user-item:hover { background: var(--bg-surface); } +.popup-creator { + margin-top: 6px; + font-size: 12px; + color: var(--text-2); + font-style: italic; +} diff --git a/frontend/index.html b/frontend/index.html index 5b030f8..df32297 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -319,6 +319,11 @@ +
@@ -344,6 +349,34 @@
+ + + @@ -672,6 +706,16 @@ +

Privatsphäre

+

Wie private Termine für andere Gruppenmitglieder erscheinen

+
+ + +
+

Stundenhöhe (Wochen- & Tagesansicht)

Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt

diff --git a/frontend/js/api.js b/frontend/js/api.js index b715277..cc12b4a 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -57,12 +57,46 @@ async function uploadRequest(path, formData) { return res.json(); } +async function downloadRequest(path, fallbackName) { + const token = localStorage.getItem('token'); + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${BASE}${path}`, { method: 'GET', headers }); + if (res.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.reload(); + return null; + } + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: t('unknown_error') })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + // Derive filename from Content-Disposition if present. + let filename = fallbackName || 'calendar.ics'; + const cd = res.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="?([^"]+)"?/); + if (m) filename = m[1]; + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + export const api = { get: (path) => request('GET', path), post: (path, body) => request('POST', path, body), put: (path, body) => request('PUT', path, body), delete: (path) => request('DELETE', path), upload: (path, form) => uploadRequest(path, form), + download: (path, name) => downloadRequest(path, name), login: (username, password, totp_code = null, remember_me = false) => request('POST', '/auth/login', { username, password, totp_code, remember_me }), diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 8c4449e..2d2f748 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -538,19 +538,28 @@ function renderCalendarList() { }).join(''); } - // ── Local calendars ──────────────────────────────────── + // ── Local calendars (own + shared with me) ───────────── if (state.localCalendars.length) { html += ``; - html += state.localCalendars.map(cal => - `
+ html += state.localCalendars.map(cal => { + const owned = cal.owned !== false; + // Shared calendars get an owner badge and no delete button (owner-only). + const sharedBadge = !owned + ? `${escHtml(cal.shared_by || '')}` + : ''; + const removeBtn = owned + ? `` + : ''; + return `
${escHtml(cal.name)} - -
` - ).join(''); + ${sharedBadge} + ${removeBtn} +
`; + }).join(''); } // ── iCal subscriptions ───────────────────────────────── @@ -1203,6 +1212,16 @@ function showEventPopup(ev, anchor) { document.getElementById('popup-description').style.display = ev.description ? '' : 'none'; document.getElementById('popup-calendar').textContent = ev.calendar_name || ''; + // Creator — only shown when it isn't the current user. + const creatorEl = document.getElementById('popup-creator'); + const me = JSON.parse(localStorage.getItem('user') || '{}'); + if (ev.creator && ev.creator.display_name && ev.creator.id !== me.id) { + creatorEl.textContent = t('created_by', { name: ev.creator.display_name }); + creatorEl.style.display = ''; + } else { + creatorEl.style.display = 'none'; + } + // Position near anchor const rect = anchor.getBoundingClientRect(); const pw = 300, ph = 200; @@ -1375,6 +1394,7 @@ function openNewEventModal(date) { toggleAlldayFields(false); populateCalendarSelect(null); + updatePrivateRow(false); resetColorPicker(''); resetRecurrenceUI(); document.getElementById('ev-delete').classList.add('hidden'); @@ -1410,6 +1430,7 @@ function openCopyEditModal(ev, targetCal) { if (targetCal.type === 'caldav') selectedId = targetCal.id; else selectedId = `${targetCal.type}-${targetCal.id}`; populateCalendarSelect(selectedId); + updatePrivateRow(ev.private); resetColorPicker(ev.color || ''); resetRecurrenceUI(); @@ -1442,6 +1463,7 @@ function openEditEventModal(ev) { } populateCalendarSelect(ev.calendar_id); + updatePrivateRow(ev.private); resetColorPicker(ev.color || ''); // Recurrence @@ -1469,6 +1491,18 @@ function toggleAlldayFields(allDay) { document.getElementById('ev-date-row').style.display = allDay ? '' : 'none'; } +// The "Privat" toggle only applies to local calendars; hide it otherwise. +function updatePrivateRow(isPrivate) { + const calVal = document.getElementById('ev-calendar').value || ''; + const isLocal = calVal.startsWith('local-'); + const row = document.getElementById('ev-private-row'); + row.style.display = isLocal ? '' : 'none'; + if (isPrivate !== undefined) { + document.getElementById('ev-private').checked = !!isPrivate; + } + if (!isLocal) document.getElementById('ev-private').checked = false; +} + function resetColorPicker(color) { state.selectedEventColor = color; const hex = document.getElementById('ev-color-hex'); @@ -1559,6 +1593,9 @@ function bindEventModal() { toggleAlldayFields(e.target.checked); }); + // The "Privat" toggle is only relevant for local calendars. + document.getElementById('ev-calendar').addEventListener('change', () => updatePrivateRow()); + // Date/time pickers with auto-adjustment logic [ { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' }, @@ -1684,6 +1721,7 @@ function bindEventModal() { const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; const rrule = buildRruleFromUI(); + const isPrivate = isLocal && document.getElementById('ev-private').checked; let start, end; if (allDay) { @@ -1711,7 +1749,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 || '' } + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate } ); } else if (ev.source === 'ical') { showToast(t('event_readonly'), true); @@ -1733,6 +1771,7 @@ function bindEventModal() { location: loc, description: desc, color: color || null, rrule: rrule || null, + private: ev.source === 'local' ? isPrivate : ev.private, }); showToast(t('event_updated')); } else if (isGoogle) { @@ -1747,7 +1786,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, + rrule: rrule || null, private: isPrivate, }); showToast(t('event_created')); } else if (isHA) { @@ -2025,6 +2064,100 @@ function bindICalSubModal() { }; } +// ── iCal Import ─────────────────────────────────────────── +// Open a file picker and import the chosen .ics into the given local calendar. +function triggerIcsImport(calendarId) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.ics,text/calendar'; + input.style.display = 'none'; + document.body.appendChild(input); + input.addEventListener('change', async () => { + const file = input.files && input.files[0]; + input.remove(); + if (!file) return; + const form = new FormData(); + form.append('file', file); + try { + showToast(t('importing')); + const res = await api.upload(`/local/calendars/${calendarId}/import`, form); + showToast(t('import_result', { imported: res.imported, skipped: res.skipped })); + fetchAndRender(true); + } catch (e) { showToast(e.message, true); } + }); + input.click(); +} + +// ── Sharing ─────────────────────────────────────────────── +async function openShareModal(calendarId) { + const modal = document.getElementById('modal-share'); + modal.dataset.calId = String(calendarId); + const search = document.getElementById('share-user-search'); + search.value = ''; + search.oninput = renderShareUserPicker; + document.getElementById('share-permission').value = 'read'; + openModal('modal-share'); + await refreshShareModal(calendarId); +} + +async function refreshShareModal(calendarId) { + // Load current shares + the user directory (for the picker). + let shares = [], users = []; + try { shares = await api.get(`/local/calendars/${calendarId}/shares`); } catch (e) { showToast(e.message, true); } + try { users = await api.get('/users/directory'); } catch (e) { /* ignore */ } + + const sharedIds = new Set(shares.map(s => s.user_id)); + const listEl = document.getElementById('share-current-list'); + listEl.innerHTML = shares.length + ? shares.map(s => + `
+ ${escHtml(s.display_name || '')} +
+ ${s.permission === 'read_write' ? t('perm_read_write') : t('perm_read')} + +
+
` + ).join('') + : `${t('share_none')}`; + + listEl.querySelectorAll('[data-share-remove]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.delete(`/local/calendars/${calendarId}/shares/${btn.dataset.shareRemove}`); + await refreshShareModal(calendarId); + } catch (e) { showToast(e.message, true); } + }); + }); + + // Store the directory (minus already-shared users) for the picker. + document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id)); + renderShareUserPicker(); +} + +function renderShareUserPicker() { + const modal = document.getElementById('modal-share'); + const users = modal.__users || []; + const q = (document.getElementById('share-user-search').value || '').toLowerCase(); + const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q)); + const picker = document.getElementById('share-user-picker'); + picker.innerHTML = filtered.length + ? filtered.map(u => + `` + ).join('') + : `${t('share_no_users')}`; + picker.querySelectorAll('.share-user-item').forEach(el => { + el.addEventListener('click', async () => { + const calId = parseInt(modal.dataset.calId); + const permission = document.getElementById('share-permission').value; + try { + await api.post(`/local/calendars/${calId}/shares`, + { user_id: parseInt(el.dataset.userId), permission }); + await refreshShareModal(calId); + } catch (e) { showToast(e.message, true); } + }); + }); +} + // ── Settings Modal ──────────────────────────────────────── function openSettingsModal() { const s = state.settings; @@ -2057,6 +2190,7 @@ function openSettingsModal() { }); document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-language').value = getLang(); + document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy'; // Set active contrast/hour-height buttons [ @@ -2189,14 +2323,40 @@ function renderAllAccounts() { if (!state.localCalendars.length) { localList.innerHTML = `${t('settings_no_local_cals')}`; } else { - localList.innerHTML = state.localCalendars.map(cal => - `
+ localList.innerHTML = state.localCalendars.map(cal => { + const owned = cal.owned !== false; + const sharedBadge = !owned + ? `${t('shared_by', { name: cal.shared_by || '' })}` + : ''; + const canWrite = owned || cal.permission === 'read_write'; + const actions = []; + if (owned) actions.push(``); + if (canWrite) actions.push(``); + actions.push(``); + return `
${escHtml(cal.name)} + ${sharedBadge}
-
` - ).join(''); +
${actions.join('')}
+
`; + }).join(''); + + localList.querySelectorAll('[data-local-export]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.download(`/local/calendars/${btn.dataset.localExport}/export`, + `${btn.dataset.localName || 'calendar'}.ics`); + } catch (e) { showToast(e.message, true); } + }); + }); + localList.querySelectorAll('[data-local-import]').forEach(btn => { + btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.localImport))); + }); + localList.querySelectorAll('[data-local-share]').forEach(btn => { + btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.localShare))); + }); } } @@ -2509,6 +2669,7 @@ function bindSettingsModal() { dim_past_events: document.getElementById('cfg-dim-past').checked, hour_height: getActive('cfg-hour-height') || 44, language: document.getElementById('cfg-language').value, + private_event_visibility: document.getElementById('cfg-private-visibility').value, }; try { await api.put('/settings/', settings); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index c443647..55e9071 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -84,6 +84,29 @@ const translations = { settings_week_start: 'Erster Wochentag', week_start_monday: 'Montag', week_start_sunday: 'Sonntag', settings_dim_past: 'Vergangene Termine ausgrauen', + settings_privacy: 'Privatsphäre', + settings_private_visibility: 'Private Termine für Gruppenmitglieder', + settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen', + private_visibility_busy: 'Als „Beschäftigt“ anzeigen', + private_visibility_hidden: 'Ausblenden', + created_by: 'Erstellt von: {name}', + event_private: 'Privat', + share: 'Teilen', + import: 'Importieren', + export: 'Exportieren', + importing: 'Importiere…', + import_result: '{imported} importiert, {skipped} übersprungen', + shared_by: 'geteilt von {name}', + share_title: 'Kalender teilen', + share_current: 'Aktuelle Freigaben', + share_add: 'Benutzer hinzufügen', + share_search: 'Benutzer suchen…', + share_none: 'Noch nicht geteilt', + share_no_users: 'Keine Benutzer gefunden', + perm_read: 'Nur lesen', + perm_read_write: 'Lesen & schreiben', + remove: 'Entfernen', + done: 'Fertig', settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)', settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt', hour_compact: 'Kompakt', hour_normal: 'Normal', @@ -299,6 +322,29 @@ const translations = { settings_week_start: 'First day of week', week_start_monday: 'Monday', week_start_sunday: 'Sunday', settings_dim_past: 'Dim past events', + settings_privacy: 'Privacy', + settings_private_visibility: 'Private events for group members', + settings_private_visibility_desc: 'How your private events appear to other group members', + private_visibility_busy: 'Show as "Busy"', + private_visibility_hidden: 'Hide completely', + created_by: 'Created by: {name}', + event_private: 'Private', + share: 'Share', + import: 'Import', + export: 'Export', + importing: 'Importing…', + import_result: '{imported} imported, {skipped} skipped', + shared_by: 'shared by {name}', + share_title: 'Share calendar', + share_current: 'Current shares', + share_add: 'Add user', + share_search: 'Search users…', + share_none: 'Not shared yet', + share_no_users: 'No users found', + perm_read: 'Read only', + perm_read_write: 'Read & write', + remove: 'Remove', + done: 'Done', settings_hour_height: 'Hour height (week & day view)', settings_hour_height_desc: 'How much space one hour takes in the time grid', hour_compact: 'Compact', hour_normal: 'Normal', -- 2.47.3 From e8a13ba33c6c2da1e12b25340673fd664b5856fc Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 16:37:08 +0200 Subject: [PATCH 087/114] feat: Gruppen im Web-Frontend + Gruppenkalender in /local/calendars - Sidebar-Sektion "Gruppen": Liste, Erstellen (Name + Mitglieder-Picker), Verwalten (Mitglieder hinzufuegen/entfernen), Loeschen. - Gruppenansicht: laedt /api/groups/{id}/combined fuer den sichtbaren Bereich; Event-Titel werden mit Besitzer-Initialen bzw. Gruppen-Icon praefixt; Banner mit "Gruppenansicht verlassen". - Server: GET /api/local/calendars liefert nun auch Gruppenkalender (group:true, read_write) fuer Mitglieder, damit sie im Editor waehlbar sind. Test ergaenzt (13 gruen). Co-Authored-By: Claude Opus 4.8 --- backend/routers/local_router.py | 22 +++- backend/tests/test_collaboration.py | 13 ++ frontend/css/app.css | 23 ++++ frontend/index.html | 41 +++++++ frontend/js/calendar.js | 183 ++++++++++++++++++++++++++++ frontend/js/i18n.js | 28 +++++ 6 files changed, 309 insertions(+), 1 deletion(-) diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 7144c8b..a4a9a5d 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -105,16 +105,36 @@ def list_calendars( .filter(models.CalendarShare.user_id == current_user.id) .all() ) + seen_ids = {c.id for c in own} for share in shares: cal = share.calendar - if cal is None: + if cal is None or cal.id in seen_ids: continue + seen_ids.add(cal.id) owner = db.query(models.User).filter(models.User.id == cal.user_id).first() result.append(_cal_dict( cal, owned=False, shared_by=owner.username if owner else None, permission=share.permission, )) + + # Group calendars the user can reach via membership (read_write), so members + # can select the group calendar in the editor and see it in their list. + group_cals = ( + db.query(models.LocalCalendar, models.Group.name) + .join(models.GroupCalendar, models.GroupCalendar.calendar_id == models.LocalCalendar.id) + .join(models.Group, models.Group.id == models.GroupCalendar.group_id) + .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id) + .filter(models.GroupMember.user_id == current_user.id) + .all() + ) + for cal, group_name in group_cals: + if cal.id in seen_ids: + continue + seen_ids.add(cal.id) + d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write") + d["group"] = True + result.append(d) return result diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index 740cc24..4f3eb45 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -134,6 +134,19 @@ def test_group_members_can_write_group_calendar(client): assert r.status_code == 200, r.text +def test_group_calendar_listed_for_member(client): + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gcal = group["group_calendar_id"] + # Bob (member, not owner) sees the group calendar in his local list, flagged. + cals = client.get("/api/local/calendars", headers=auth(b_tok)).json() + gc = [c for c in cals if c["id"] == gcal] + assert gc and gc[0].get("group") is True + assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False + + def test_combined_view_marks_owner_and_group_event(client): admin = register_admin(client) b_id, b_tok = create_user(client, admin, "bob") diff --git a/frontend/css/app.css b/frontend/css/app.css index e519df7..0072ec8 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1801,3 +1801,26 @@ a { color: var(--primary); text-decoration: none; } color: var(--text-2); font-style: italic; } + +/* ── Groups ─────────────────────────────────────────────────── */ +.group-view-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + background: rgba(66, 133, 244, 0.12); + border-bottom: 1px solid var(--border); + font-size: 14px; + color: var(--text-1); +} +.group-item-active { + background: rgba(66, 133, 244, 0.15); + border-radius: 8px; +} +.group-item .cal-item-name { cursor: pointer; flex: 1; } +.cal-list-empty { + padding: 6px 4px; + font-size: 13px; + color: var(--text-3); +} diff --git a/frontend/index.html b/frontend/index.html index df32297..a1f3bf8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -198,6 +198,17 @@
+ + +
+
+ Gruppen + +
+
+
@@ -205,6 +216,10 @@
+
@@ -349,6 +364,32 @@ + + +