From cd4879d5730029b0618dd5248a8f291ba049b24f Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 21:20:42 +0200
Subject: [PATCH 01/70] 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 = `` +
+ // Header
+ const headerHtml =
+ `` +
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 += `
`;
- }
+ });
+
+ bodyHtml += `
+
${kw}
+
+
${dayCellsHtml}
+
${eventsHtml}
+
+
`;
}
container.innerHTML = `
-
${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,''');
}
From 7f92e0423c799d3f1f96c407f3ab54c8960a109f Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 21:44:44 +0200
Subject: [PATCH 02/70] =?UTF-8?q?fix:=20Month=20grid=20lines,=20scroll=20t?=
=?UTF-8?q?hrottle,=20custom=20dark=20date/time=20picker=20-=20Month=20vie?=
=?UTF-8?q?w:=20Replaced=20day-strip+events-area=20with=20full-height=20co?=
=?UTF-8?q?lumn=20=20=20divs=20(.month-col)=20so=20borders=20extend=20the?=
=?UTF-8?q?=20full=20row=20height=20and=20clicking=20=20=20anywhere=20in?=
=?UTF-8?q?=20a=20day=20column=20(including=20below=20events)=20navigates?=
=?UTF-8?q?=20to=20day=20view.=20=20=20Events=20overlay=20uses=20pointer-e?=
=?UTF-8?q?vents:none=20(pass-through)=20while=20span=20bars=20=20=20and?=
=?UTF-8?q?=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 `
+
+ ${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 ``;
+ }
+
+ // ── 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 += `
`;
});
@@ -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);
}
From b268e88d84fd3487ab24df496dfc01a2b07c14e9 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 21:51:15 +0200
Subject: [PATCH 03/70] =?UTF-8?q?fix:=20Month=20scroll=20navigates=20by=20?=
=?UTF-8?q?full=20month,=20not=20by=20week=20Scrolling=20in=20month=20view?=
=?UTF-8?q?=20was=20moving=20currentDate=20by=207=20days,=20but=20the=20gr?=
=?UTF-8?q?id=20always=20renders=20the=20complete=20month=20=E2=80=94=20so?=
=?UTF-8?q?=204=20scrolls=20were=20needed=20before=20any=20visual=20change?=
=?UTF-8?q?.=20Now=20each=20scroll=20step=20advances/retreats=20by=20exact?=
=?UTF-8?q?ly=20one=20month=20(same=20as=20the=20prev/next=20buttons).?=
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);
From e9bc56e857f5c2bf5f7df38952f040cbb57a56bc Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 21:58:51 +0200
Subject: [PATCH 04/70] 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' : '';
From faada7359e16ba5f79483c0a1e358afa9eada971 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 22:05:03 +0200
Subject: [PATCH 05/70] =?UTF-8?q?perf:=20Event=20cache=20mit=20=C2=B18-Woc?=
=?UTF-8?q?hen-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); }
});
});
From d8ec22d573fbe2965971901ac525929b66b83dd0 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 22:09:11 +0200
Subject: [PATCH 06/70] =?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 {
From 0b4060beae5cc43545760786001fcfaac2826b54 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 7 Apr 2026 22:18:10 +0200
Subject: [PATCH 07/70] =?UTF-8?q?fix:=20Kalenderfarbe=20wird=20sofort=20oh?=
=?UTF-8?q?ne=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 ──────────────────────────────────────────
From ba73bde3536b4250948ecbe72daa4162f1995c65 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:10:50 +0200
Subject: [PATCH 08/70] =?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 ``;
+ }).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,''');
+}
From bda4a75a1118f301319d8bff3daa5374e88f1d48 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:15:48 +0200
Subject: [PATCH 09/70] =?UTF-8?q?Fix:=20Quartalsansicht=20=E2=80=93=20zuf?=
=?UTF-8?q?=C3=A4llige=20Today-Markierungen=20behoben,=20Button=20nach=20l?=
=?UTF-8?q?inks=20verschoben=20Selected-Klasse=20aus=20der=20Quartalsansic?=
=?UTF-8?q?ht=20entfernt=20(war=20visuell=20identisch=20mit=20Today).=20Bu?=
=?UTF-8?q?tton-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 @@
+
-
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',
From 77936b3b8dd3dbbef7803a51db811a35d80691f8 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:43:34 +0200
Subject: [PATCH 13/70] =?UTF-8?q?Fix:=20CalDAV=20delete/update,=20Copy-Men?=
=?UTF-8?q?=C3=BC-Reset,=20Timezone=20beim=20Kopieren=20-=20caldav=5Fclien?=
=?UTF-8?q?t:=20client.event()=20=E2=86=92=20caldav.Event()=20mit=20resour?=
=?UTF-8?q?ce.load()=20f=C3=BCr=20update/delete=20(DAVClient=20hat=20keine?=
=?UTF-8?q?=20event()-Methode)=20-=20Popup:=20Copy-Men=C3=BC=20wird=20beim?=
=?UTF-8?q?=20=C3=96ffnen=20eines=20neuen=20Events=20immer=20zur=C3=BCckge?=
=?UTF-8?q?setzt=20-=20copyEventToCalendar:=20start/end=20via=20new=20Date?=
=?UTF-8?q?().toISOString()=20normalisiert=20=E2=86=92=20verhindert=202h-V?=
=?UTF-8?q?erschiebung=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', {
From e317b799d00b6c91a85e9f6d97565598d89678f8 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:47:11 +0200
Subject: [PATCH 14/70] =?UTF-8?q?Feature:=20Mehrt=C3=A4gige=20Termine=20in?=
=?UTF-8?q?=20Wochen-/Tagesansicht=20vollst=C3=A4ndig=20anzeigen=20Timed-E?=
=?UTF-8?q?vents=20die=20mehrere=20Tage=20=C3=BCberspannen=20werden=20neu?=
=?UTF-8?q?=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}
`;
From fce162693cc9add0943d0ceca8a9b24ac0f3afd2 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:57:57 +0200
Subject: [PATCH 15/70] =?UTF-8?q?Feature:=20Dynamische=20Monatsansicht-Lan?=
=?UTF-8?q?es=20+=20spanning=20All-Day-Balken=20in=20Wochenansicht=20month?=
=?UTF-8?q?.js:=20MAX=5FLANES=20wird=20jetzt=20aus=20der=20tats=C3=A4chlic?=
=?UTF-8?q?hen=20Container-H=C3=B6he=20berechnet=20(kein=20hartes=20Limit?=
=?UTF-8?q?=20von=203=20mehr).=20week.js:=20All-Day-Zeile=20verwendet=20je?=
=?UTF-8?q?tzt=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 [];
From 8fc3472b1cd841b2f9fbd0a8d0b66f8fc7ea9df4 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:08:45 +0200
Subject: [PATCH 16/70] =?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 = `
-
-
-
${t('allday')}
-
${alldayCols}
+
+
+
+
${t('allday')}
+ ${alldayCols}
+
${timeLabels}
From f50f5fa1e1697ab702b6db5b63c1b503da424f0b Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:11:13 +0200
Subject: [PATCH 17/70] 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;
From 6a25607103bbcb82c5cfebea308a33fd2243b53d Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:13:00 +0200
Subject: [PATCH 18/70] =?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;
From 377a24eac6ff551dcc6cf89e076e3674dc264de7 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:15:37 +0200
Subject: [PATCH 19/70] 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);
From 804d6ac9eb4bc7a06bdebe0a467317282904c951 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:19:16 +0200
Subject: [PATCH 20/70] =?UTF-8?q?Fix:=20Scroll=20auf=20week-view=20verlege?=
=?UTF-8?q?n=20=E2=80=93=20Header=20und=20Zeitraster=20immer=20gleich=20br?=
=?UTF-8?q?eit?=
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}
-
`;
// 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');
From 240b7af1c8c5c477537b6157cc1afe2f133975be Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 21:40:01 +0200
Subject: [PATCH 21/70] =?UTF-8?q?fix:=20Google-Token-Fehler=20wird=20sicht?=
=?UTF-8?q?bar=20gemacht=20und=20dem=20User=20gemeldet=20Wenn=20der=20Acce?=
=?UTF-8?q?ss-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;
From 7070e23cc67009a2394782c53e0859fb9535fa73 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 21:49:24 +0200
Subject: [PATCH 22/70] =?UTF-8?q?fix:=20Wochenkalender-Filter=20und=20per-?=
=?UTF-8?q?Kalender=20Fehlerbehandlung=20Der=20Wochenkalender=20von=20Goog?=
=?UTF-8?q?le=20hat=20locale-spezifische=20IDs=20(z.B.=20de.german#weeknum?=
=?UTF-8?q?@...)=20die=20nicht=20im=20alten=20exakten=20Set-Filter=20gefan?=
=?UTF-8?q?gen=20wurden.=20Dadurch=20wurde=20er=20in=20die=20DB=20gespeich?=
=?UTF-8?q?ert=20und=20verursachte=20beim=20Event-Abruf=20einen=20API-Fehl?=
=?UTF-8?q?er.=20Da=20der=20try/except=20die=20gesamte=20Kalender-Schleife?=
=?UTF-8?q?=20umschloss,=20wurden=20bei=20einem=20einzigen=20fehlerhaften?=
=?UTF-8?q?=20Kalender=20alle=20anderen=20Events=20ebenfalls=20verloren=20?=
=?UTF-8?q?=E2=80=94=20Ursache=20f=C3=BCr=20keine=20Termine=20trotz=20korr?=
=?UTF-8?q?ektem=20Token.=20-=20=5Fis=5Fsystem=5Fcalendar():=20pr=C3=BCft?=
=?UTF-8?q?=20jetzt=20auch=20'weeknum'=20als=20Substring=20-=20=5Fsync=5Fg?=
=?UTF-8?q?oogle=5Fcalendars():=20bereinigt=20bereits=20gespeicherte=20Sys?=
=?UTF-8?q?tem-Kalender=20-=20get=5Fgoogle=5Fevents():=20try/except=20ist?=
=?UTF-8?q?=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
From 4c8face22aa95f4428d8a8234118028a2af1b322 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 21:59:41 +0200
Subject: [PATCH 23/70] =?UTF-8?q?fix:=20Kalender-Toggle=20sofort=20wirksam?=
=?UTF-8?q?=20+=20Tint=20f=C3=BCr=20mehrt=C3=A4gige=20Ganztags-Events=20-?=
=?UTF-8?q?=20fetchAndRender(true)=20beim=20Ein-/Ausblenden=20eines=20Kale?=
=?UTF-8?q?nders=20erzwingt=20=20=20einen=20Neu-Abruf=20statt=20Cache-Tref?=
=?UTF-8?q?fer,=20damit=20die=20=C3=84nderung=20sofort=20sichtbar=20ist=20?=
=?UTF-8?q?-=20Tint-Berechnung=20in=20der=20Wochenansicht=20ber=C3=BCcksic?=
=?UTF-8?q?htigt=20jetzt=20auch=20=20=20mehrt=C3=A4gige=20Ganztags-Events?=
=?UTF-8?q?=20(z.B.=20Urlaub),=20nicht=20nur=20mehrt=C3=A4gige=20=20=20Ter?=
=?UTF-8?q?min-Events=20=E2=80=94=20exclusive=20Enddaten=20werden=20dabei?=
=?UTF-8?q?=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('');
From d1d1135e32c73fb725c0ac4e62a55b37217beb3d Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 22:14:08 +0200
Subject: [PATCH 24/70] =?UTF-8?q?perf/fix:=20Kalender-Toggle=20ohne=20Lade?=
=?UTF-8?q?screen=20+=20Mehrfach-Tint=20als=20Verlauf=20Ausblenden:=20Even?=
=?UTF-8?q?ts=20werden=20sofort=20client-seitig=20aus=20dem=20Cache=20gefi?=
=?UTF-8?q?ltert=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}
From 5a7d8ad362f718facf59cb6636dc60f8af8a4869 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 22:24:05 +0200
Subject: [PATCH 25/70] =?UTF-8?q?fix:=20Tint=20f=C3=BCr=20mehrt=C3=A4gige?=
=?UTF-8?q?=20Ganztags-Events=20korrekt=20via=20alldayLayout=20Der=20bishe?=
=?UTF-8?q?rige=20multiDayAllDayEvs-Filter=20hatte=20einen=20Timezone-Fehl?=
=?UTF-8?q?er=20bei=20der=20Datumsberechnung=20(UTC-Parsing=20vs.=20lokale?=
=?UTF-8?q?=20Zeit=20in=20UTC+2).=20Neue=20L=C3=B6sung:=20das=20bereits=20?=
=?UTF-8?q?korrekt=20arbeitende=20alldayLayout=20wird=20direkt=20als=20Que?=
=?UTF-8?q?lle=20verwendet.=20Items=20mit=20colEnd=20>=20colStart=20sind?=
=?UTF-8?q?=20mehrt=C3=A4gig=20=E2=80=94=20die=20Spaltenindizes=20aus=20de?=
=?UTF-8?q?m=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');
From f28aa706e7bb9ce8be825a14f5146286e016416a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 08:46:43 +0200
Subject: [PATCH 26/70] =?UTF-8?q?feat:=20Home=20Assistant=20Kalender-Integ?=
=?UTF-8?q?ration=20+=20Bugfix=20ausgeblendete=20Kalender=20-=20Neue=20Int?=
=?UTF-8?q?egration:=20Home=20Assistant=20als=20Kalenderquelle=20via=20RES?=
=?UTF-8?q?T-API=20=20=20(GET=20/api/calendars=20+=20GET=20/api/calendars/?=
=?UTF-8?q?{entity=5Fid})=20-=20Authentifizierung=20per=20Long-Lived=20Acc?=
=?UTF-8?q?ess=20Token=20-=20Neues=20Modal=20zum=20Verbinden=20(Name,=20UR?=
=?UTF-8?q?L,=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 @@
CalDAV-Konto
iCal-URL abonnieren
Google Kalender
+ Home Assistant
@@ -399,6 +400,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -531,6 +561,11 @@
Google-Konten
Keine Google-Konten
+
+
+
Home Assistant
+
Keine HA-Konten
+
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 `${escHtml(acc.name)}
`;
+ return `${escHtml(acc.name)}
` +
+ 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 || '')}
+
+
+ ${t('sync')}
+ ${t('disconnect')}
+
+
`
+ ).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) {
From 5c7a74e221f709590af4d82ec653416662a89d1a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:03:40 +0200
Subject: [PATCH 27/70] 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();
});
});
}
From d6e67a97c8b85a4209b6f2ed29c08d2df6122d77 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:18:03 +0200
Subject: [PATCH 28/70] =?UTF-8?q?fix:=20Color-Picker-Cursor=20erreicht=20j?=
=?UTF-8?q?etzt=20den=20rechten=20und=20unteren=20Rand=20updateUI=20verwen?=
=?UTF-8?q?dete=20svCanvas.width=20(HTML-Attribut,=20220px)=20statt=20der?=
=?UTF-8?q?=20tats=C3=A4chlich=20gerenderten=20Breite.=20Wenn=20CSS=20den?=
=?UTF-8?q?=20Canvas=20gr=C3=B6=C3=9Fer=20rendert,=20stoppte=20der=20Curso?=
=?UTF-8?q?r=20vor=20dem=20rechten=20Rand.=20Jetzt=20wird=20getBoundingCli?=
=?UTF-8?q?entRect()=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();
From 978ad55af41dbbadd22ff1a12b74424d7f2aaee4 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:22:42 +0200
Subject: [PATCH 29/70] 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();
From 69f5789e2dc665d40c2ad6c7cd79dddb42633e06 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 21 Apr 2026 11:02:32 +0200
Subject: [PATCH 30/70] =?UTF-8?q?feat:=20Home=20Assistant=20Benutzername/P?=
=?UTF-8?q?asswort-Authentifizierung=20Erg=C3=A4nzt=20die=20HA-Integration?=
=?UTF-8?q?=20um=20Password-Grant=20OAuth2:=20Nutzer=20k=C3=B6nnen=20sich?=
=?UTF-8?q?=20nun=20wahlweise=20mit=20einem=20Long-Lived=20Token=20oder=20?=
=?UTF-8?q?mit=20Benutzername/Passwort=20anmelden.=20Access=20Tokens=20wer?=
=?UTF-8?q?den=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 @@
+
+
-
- © 2026 Scarriffleservices
+ © 2026 Scarriffleservices · v1
@@ -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();
From 58c7cbc38c74c3f1630053717979129e3d503fc9 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Fri, 24 Apr 2026 12:57:38 +0200
Subject: [PATCH 34/70] =?UTF-8?q?feat(ha):=20OAuth=20Authorization-Code-Fl?=
=?UTF-8?q?ow=20statt=20kaputtem=20Password-Grant=20Home=20Assistant=20unt?=
=?UTF-8?q?erst=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 @@
Anmelden
- © 2026 Scarriffleservices · v1
+ © 2026 Scarriffleservices · v2
@@ -118,7 +118,7 @@
Profil
-
+
Abmelden
@@ -173,7 +173,7 @@
-
+
@@ -208,7 +208,7 @@
@@ -226,7 +226,7 @@
@@ -276,10 +276,10 @@
@@ -421,32 +421,25 @@
Anmeldemethode
-
@@ -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';
From e3984eb5cfa62c816a6353077a3a5ba571b12d09 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 29 Apr 2026 17:49:03 +0200
Subject: [PATCH 35/70] =?UTF-8?q?feat:=20Datum-Validierung,=20Monatsauswah?=
=?UTF-8?q?l,=20CalDAV-Fix,=20wiederkehrende=20Termine=20-=20End-Datum=20p?=
=?UTF-8?q?asst=20sich=20automatisch=20an=20wenn=20Start=20ge=C3=A4ndert?=
=?UTF-8?q?=20wird=20(Duration=20bleibt=20erhalten)=20-=20Erstellen-Button?=
=?UTF-8?q?=20nutzt=20den=20aktuell=20angesehenen=20Tag=20statt=20immer=20?=
=?UTF-8?q?heute=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 += `
`;
});
@@ -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
From d4ea097831b990cec43efcf3c103bb5f9d376ecb Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 29 Apr 2026 18:13:12 +0200
Subject: [PATCH 36/70] =?UTF-8?q?fix:=20Runde-2-Fixes=20=E2=80=93=20Monats?=
=?UTF-8?q?auswahl,=20CalDAV-Update,=20L=C3=B6sch-Dialog,=20EXDATE=20-=20M?=
=?UTF-8?q?onatsansicht:=20selectedDate=20von=20currentDate=20getrennt,=20?=
=?UTF-8?q?Klick=20verschiebt=20View=20nicht=20mehr=20-=20Selected-Day=20S?=
=?UTF-8?q?tyling:=20wei=C3=9Fer=20Text=20auf=20Primary-Hintergrund=20stat?=
=?UTF-8?q?t=20nur=20Textfarbe=20-=20Kontextmen=C3=BC:=20--bg-surface=20st?=
=?UTF-8?q?att=20fehlendem=20--bg-card=20-=20CalDAV=20Update/Delete:=20par?=
=?UTF-8?q?ent=20Calendar-Objekt=20=C3=BCbergeben=20(behebt=20NoneType-Feh?=
=?UTF-8?q?ler)=20-=20HA-Kalender=20im=20Kalender-Selektor=20erg=C3=A4nzt?=
=?UTF-8?q?=20-=20Browser-confirm()=20durch=20styled=20Modal-Dialog=20erse?=
=?UTF-8?q?tzt=20mit=20Serie/Einzeln-Option=20-=20EXDATE-Support:=20einzel?=
=?UTF-8?q?ne=20Vorkommen=20wiederkehrender=20Termine=20l=C3=B6schen=20(lo?=
=?UTF-8?q?kal=20+=20CalDAV)=20-=20Fehlende=20i18n-Keys=20f=C3=BCr=20L?=
=?UTF-8?q?=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 });
+ });
+ })
+ );
+});
From 3d7779ae832eb1dadbf90d9bf9ef15fa18a5cfb0 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 18:52:51 +0200
Subject: [PATCH 57/70] 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)
---
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 @@
Anmelden
- © 2026 Scarriffleservices · v3
+ © 2026 Scarriffleservices · v4
@@ -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',
From 49b1935a282a4e7aa822c9d288cdb197faafc165 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:08:20 +0200
Subject: [PATCH 58/70] 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 @@
Anmelden
- © 2026 Scarriffleservices · v4
+ © 2026 Scarriffleservices · v5
@@ -112,6 +112,19 @@
Tag
Termine
+
+
+
+
+
+ Quartal
+ Monat
+ Woche
+ Tag
+ Termine
+ Heute
+
+
@@ -123,6 +136,10 @@
Profil
+
+
+ Einstellungen
+
Abmelden
@@ -179,7 +196,7 @@
-
+
@@ -534,10 +551,14 @@
+
+
+
Einstellungen
Speichern
+
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',
From b9691ea20943be2b10ce1fb93260087d8cafae16 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:17:26 +0200
Subject: [PATCH 59/70] 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 @@
+
Anmelden
- © 2026 Scarriffleservices · v5
+ © 2026 Scarriffleservices · v6
@@ -196,7 +199,7 @@
-
+
@@ -232,7 +235,7 @@
@@ -250,7 +253,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',
From 7cabfb10deac9ba7dbc8331b9c0c5c09311168cc Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:23:35 +0200
Subject: [PATCH 60/70] =?UTF-8?q?perf:=20Event-Cache=20von=20=C2=B18=20Woc?=
=?UTF-8?q?hen=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 @@
Anmelden
- © 2026 Scarriffleservices · v6
+ © 2026 Scarriffleservices · v7
@@ -159,7 +159,7 @@