From 94cbe4e7fb3ac005ba1d27fb969bc6e331266fa9 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 21:20:42 +0200
Subject: [PATCH 001/114] feat: Spanning event bars, wheel nav, dark datetime
picker, segmented settings UI - Month view: Multi-day events render as
continuous Google Calendar-style spanning bars across days/weeks using a
greedy lane-packing algorithm. Timed multi-day events no longer repeat per
day. - Mouse wheel / trackpad scrolls week-by-week in month view, day/week in
other views (debounced, prevents default page scroll). -
datetime-local/date inputs now use color-scheme:dark so the native browser
picker opens in dark mode; calendar icon styled to match. -
Contrast/hour-height selectors redesigned as connected segmented pill
controls instead of individual tiles. - Hidden calendars list gains proper
padding and separator lines. - "Google Konten" settings panel renamed
"Konten" and expanded to show CalDAV, local calendars, iCal subscriptions,
and Google accounts in one unified panel with sync/disconnect actions. -
New i18n keys added for accounts panel in both de and en.
---
frontend/css/app.css | 150 +++++++++++++++++++------
frontend/index.html | 29 ++++-
frontend/js/calendar.js | 118 +++++++++++++++++++-
frontend/js/i18n.js | 22 ++++
frontend/js/views/month.js | 223 ++++++++++++++++++++++++-------------
5 files changed, 418 insertions(+), 124 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index e664e34..4f9797f 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -151,6 +151,19 @@ a { color: var(--primary); text-decoration: none; }
border-color: var(--primary);
}
.form-group textarea { resize: vertical; }
+
+/* ── Date/time input dark mode ──────────────────────────── */
+.form-group input[type="datetime-local"],
+.form-group input[type="date"] {
+ color-scheme: dark;
+}
+.form-group input[type="datetime-local"]::-webkit-calendar-picker-indicator,
+.form-group input[type="date"]::-webkit-calendar-picker-indicator {
+ filter: invert(0.8);
+ opacity: 0.7;
+ cursor: pointer;
+}
+
.form-row {
display: flex; gap: 12px; margin-bottom: 16px; align-items: center;
}
@@ -455,31 +468,25 @@ a { color: var(--primary); text-decoration: none; }
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2);
}
-.month-grid {
- display: grid; grid-template-columns: 38px repeat(7, 1fr);
- grid-template-rows: repeat(6, 1fr);
- flex: 1; overflow: hidden;
-}
.month-kw-cell {
- border-right: 1px solid var(--border-light);
- border-bottom: 1px solid var(--border);
+ position: absolute; left: 0; top: 0; bottom: 0;
+ width: 38px;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px;
font-size: 13px; color: var(--text-3); font-weight: 700;
- cursor: default; user-select: none;
- min-height: 0;
+ border-right: 1px solid var(--border-light);
+ cursor: default; user-select: none; z-index: 1;
+ background: var(--bg-app);
}
.month-cell {
+ flex: 1;
border-right: 1px solid var(--border);
- border-bottom: 1px solid var(--border);
- padding: 4px;
- overflow: hidden;
+ padding: 4px 4px 0;
cursor: pointer;
transition: background var(--transition);
- min-height: 0;
+ min-width: 0;
}
-/* every 8th child is the last day column (after KW cell) */
-.month-cell:nth-child(8n) { border-right: none; }
+.month-cell:last-child { border-right: none; }
.month-cell:hover { background: var(--bg-hover); }
.month-cell.today { background: rgba(66,133,244,.08); }
.month-cell.other-month .cell-day { color: var(--text-3); }
@@ -487,23 +494,15 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; font-weight: 500; color: var(--text-2);
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
- border-radius: 50%; margin-bottom: 2px; flex-shrink: 0;
+ border-radius: 50%; flex-shrink: 0;
}
.cell-day.today {
background: var(--today-color);
color: #fff; font-weight: 700;
}
-.month-event {
- font-size: 11px; font-weight: 500;
- padding: 1px 6px; border-radius: 3px;
- margin-bottom: 1px; cursor: pointer;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
- transition: filter var(--transition);
-}
-.month-event:hover { filter: brightness(1.15); }
-.month-event.past { opacity: .45; }
.month-more {
- font-size: 11px; color: var(--text-2); padding: 1px 6px;
+ position: absolute;
+ font-size: 11px; color: var(--text-2); padding: 0 4px;
cursor: pointer; font-weight: 500;
}
.month-more:hover { color: var(--primary); }
@@ -747,29 +746,38 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; color: var(--text-3); margin: 0 0 12px;
}
-/* Contrast / option selectors */
+/* Contrast / option selectors — segmented pill */
.contrast-selector {
- display: flex; gap: 8px; flex-wrap: wrap;
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ overflow: hidden;
+ background: var(--bg-surface);
}
.contrast-btn {
- display: flex; flex-direction: column; align-items: center; gap: 6px;
- padding: 10px 16px; border-radius: var(--radius);
- border: 1px solid var(--border); background: var(--bg-surface);
- cursor: pointer; transition: border-color .15s, background .15s;
- min-width: 70px;
+ display: flex; flex-direction: column; align-items: center; gap: 4px;
+ padding: 8px 14px; min-width: 64px;
+ background: transparent; border: none;
+ border-right: 1px solid var(--border);
+ cursor: pointer; color: var(--text-2);
+ transition: background var(--transition), color var(--transition);
}
-.contrast-btn:hover { border-color: var(--primary); }
-.contrast-btn.active { border-color: var(--primary); background: var(--primary-dim); }
+.contrast-btn:last-child { border-right: none; }
+.contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); }
+.contrast-btn.active { background: var(--primary); color: #fff; }
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
-.contrast-lbl { font-size: 11px; color: var(--text-2); white-space: nowrap; }
+.contrast-btn.active span { color: #fff !important; }
+.contrast-lbl { font-size: 11px; white-space: nowrap; }
+.contrast-btn.active .contrast-lbl { color: #fff; }
.line-preview {
display: block; width: 36px; height: 0;
border-top: 2px solid; border-radius: 1px;
- margin: 6px 0;
+ margin: 4px 0;
}
.hour-preview {
font-size: 14px; line-height: 1; color: var(--text-2);
}
+.contrast-btn.active .hour-preview { color: #fff; }
/* ── Settings (legacy) ──────────────────────────────────── */
.settings-section { margin-bottom: 28px; }
@@ -861,6 +869,74 @@ a { color: var(--primary); text-decoration: none; }
.loading-view { display: flex; justify-content: center; align-items: center; height: 200px; }
+/* ── Accounts Panel ──────────────────────────────────────── */
+.accounts-section { margin-bottom: 24px; }
+.accounts-section-heading {
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: .5px; color: var(--text-3);
+ padding: 0 0 8px;
+ border-bottom: 1px solid var(--border-light);
+ margin-bottom: 10px;
+}
+.accounts-section-empty {
+ font-size: 13px; color: var(--text-3); padding: 4px 0; display: block;
+}
+.accounts-row {
+ display: flex; align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border-light);
+}
+.accounts-row:last-child { border-bottom: none; }
+.accounts-row-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.accounts-row-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.accounts-row-sub { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.accounts-row-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: 8px; }
+.accounts-local-dot {
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
+}
+
+/* ── Month View (spanning bars) ─────────────────────────── */
+.month-body {
+ display: flex; flex-direction: column; flex: 1; overflow: hidden;
+}
+.month-row {
+ display: flex; flex: 1; position: relative; min-height: 0;
+ border-bottom: 1px solid var(--border);
+}
+.month-row:last-child { border-bottom: none; }
+.month-row-right {
+ margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0;
+}
+.month-day-strip {
+ display: flex; flex-shrink: 0;
+}
+.month-events-area {
+ position: relative; flex: 1;
+ min-height: 72px; /* 3 lanes × 22px + 6px padding */
+ overflow: hidden;
+}
+.month-span-event {
+ position: absolute;
+ height: 18px; line-height: 18px;
+ border-radius: 3px;
+ padding: 0 6px;
+ font-size: 11px; font-weight: 500;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ cursor: pointer; color: #fff;
+ transition: filter var(--transition);
+ box-sizing: border-box;
+ z-index: 2;
+}
+.month-span-event:hover { filter: brightness(1.15); }
+.month-span-event.past { opacity: .45; }
+.month-span-event.continues-left {
+ border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px;
+}
+.month-span-event.continues-right {
+ border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px;
+}
+
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
:root { --sidebar-w: 0px; }
diff --git a/frontend/index.html b/frontend/index.html
index d62a7c5..a489f43 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -391,7 +391,7 @@
@@ -486,10 +486,29 @@
Keine ausgeblendeten Kalender
-
-
-
Google Konten
-
Keine Google-Konten verbunden
+
+
+
Konten
+
+
+
CalDAV-Konten
+
Keine CalDAV-Konten
+
+
+
+
Lokale Kalender
+
Keine lokalen Kalender
+
+
+
+
iCal-Abonnements
+
Keine Abonnements
+
+
+
+
Google-Konten
+
Keine Google-Konten
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 0c1fe33..27f6fb4 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -539,6 +539,23 @@ function bindTopbar() {
document.getElementById('btn-settings').onclick = openSettingsModal;
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
+
+ // Mouse wheel / trackpad scroll navigation
+ let _wheelTimer = null;
+ document.getElementById('view-container').addEventListener('wheel', e => {
+ e.preventDefault();
+ if (_wheelTimer) return;
+ _wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
+ const dir = e.deltaY > 0 ? 1 : -1;
+ if (state.currentView === 'agenda') return;
+ if (state.currentView === 'month') {
+ state.currentDate = new Date(state.currentDate);
+ state.currentDate.setDate(state.currentDate.getDate() + dir * 7);
+ fetchAndRender();
+ } else {
+ navigate(dir);
+ }
+ }, { passive: false });
}
// ── Sidebar toggle ────────────────────────────────────────
@@ -1071,8 +1088,8 @@ function openSettingsModal() {
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
- // Render Google accounts and hidden calendars
- renderGoogleAccounts();
+ // Render all accounts and hidden calendars
+ renderAllAccounts();
renderHiddenCalendars();
openModal('modal-settings');
@@ -1129,6 +1146,101 @@ function renderGoogleAccounts() {
});
}
+function renderAllAccounts() {
+ // CalDAV section
+ const caldavList = document.getElementById('accounts-caldav-list');
+ if (caldavList) {
+ if (!state.accounts.length) {
+ caldavList.innerHTML = `
${t('settings_no_caldav_accounts')}`;
+ } else {
+ caldavList.innerHTML = state.accounts.map(acc =>
+ `
+
+ ${escHtml(acc.name)}
+ ${escHtml(acc.url || '')}
+
+
+
+
+
+
`
+ ).join('');
+ caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ btn.disabled = true; btn.textContent = '…';
+ try {
+ await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`);
+ renderCalendarList(); fetchAndRender();
+ showToast(t('google_synced'));
+ } catch (e) { showToast(e.message, true); }
+ finally { btn.disabled = false; btn.textContent = t('sync'); }
+ });
+ });
+ caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ if (!confirm(t('confirm_caldav_disconnect'))) return;
+ try {
+ await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
+ state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
+ renderAllAccounts(); renderCalendarList(); fetchAndRender();
+ showToast(t('caldav_disconnected'));
+ } catch (e) { showToast(e.message, true); }
+ });
+ });
+ }
+ }
+
+ // Local calendars section
+ const localList = document.getElementById('accounts-local-list');
+ if (localList) {
+ if (!state.localCalendars.length) {
+ localList.innerHTML = `
${t('settings_no_local_cals')}`;
+ } else {
+ localList.innerHTML = state.localCalendars.map(cal =>
+ `
+
+
+ ${escHtml(cal.name)}
+
+
`
+ ).join('');
+ }
+ }
+
+ // iCal subscriptions section
+ const icalList = document.getElementById('accounts-ical-list');
+ if (icalList) {
+ if (!state.icalSubscriptions.length) {
+ icalList.innerHTML = `
${t('settings_no_ical_subs')}`;
+ } else {
+ icalList.innerHTML = state.icalSubscriptions.map(sub =>
+ `
+
+ ${escHtml(sub.name)}
+ ${escHtml(sub.url || '')}
+
+
+
+
+
`
+ ).join('');
+ icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ if (!confirm(t('confirm_remove_ical'))) return;
+ try {
+ await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
+ state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
+ renderAllAccounts(); renderCalendarList(); fetchAndRender();
+ } catch (e) { showToast(e.message, true); }
+ });
+ });
+ }
+ }
+
+ // Google accounts section — delegate to existing function
+ renderGoogleAccounts();
+}
+
function renderHiddenCalendars() {
const list = document.getElementById('hidden-cals-list');
const hidden = [];
@@ -1147,7 +1259,7 @@ function renderHiddenCalendars() {
return;
}
list.innerHTML = hidden.map(c =>
- `
+ `
${escHtml(c.acc)} / ${escHtml(c.name)}
`
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 4a51fa2..283b3d3 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -86,6 +86,17 @@ const translations = {
settings_hidden_cals: 'Ausgeblendete Kalender',
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
settings_no_google: 'Keine Google-Konten verbunden',
+ settings_nav_accounts: 'Konten',
+ settings_accounts_caldav: 'CalDAV-Konten',
+ settings_accounts_local: 'Lokale Kalender',
+ settings_accounts_ical: 'iCal-Abonnements',
+ settings_accounts_google: 'Google-Konten',
+ settings_no_caldav_accounts: 'Keine CalDAV-Konten',
+ settings_no_local_cals: 'Keine lokalen Kalender',
+ settings_no_ical_subs: 'Keine Abonnements',
+ settings_no_google_accounts: 'Keine Google-Konten',
+ confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?',
+ caldav_disconnected: 'CalDAV-Konto getrennt',
// User management
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
@@ -269,6 +280,17 @@ const translations = {
settings_hidden_cals: 'Hidden calendars',
settings_no_hidden_cals: 'No hidden calendars',
settings_no_google: 'No Google accounts connected',
+ settings_nav_accounts: 'Accounts',
+ settings_accounts_caldav: 'CalDAV Accounts',
+ settings_accounts_local: 'Local Calendars',
+ settings_accounts_ical: 'iCal Subscriptions',
+ settings_accounts_google: 'Google Accounts',
+ settings_no_caldav_accounts: 'No CalDAV accounts',
+ settings_no_local_cals: 'No local calendars',
+ settings_no_ical_subs: 'No subscriptions',
+ settings_no_google_accounts: 'No Google accounts',
+ confirm_caldav_disconnect: 'Really disconnect CalDAV account?',
+ caldav_disconnected: 'CalDAV account disconnected',
// User management
users_add: 'Add user', users_is_admin: 'Administrator',
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index 1f87125..9775537 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -1,121 +1,185 @@
-import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
+import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
+const LANE_H = 20; // px per lane (height 18px + 2px gap)
+const MAX_LANES = 3; // max visible event lanes per row
+
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
- const year = currentDate.getFullYear();
+ const year = currentDate.getFullYear();
const month = currentDate.getMonth();
- const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
+ const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
const firstDay = new Date(year, month, 1);
- const lastDay = new Date(year, month + 1, 0);
- // Start grid on the correct weekday
+ // Build 42-cell grid
+ const cells = [];
const gridStart = new Date(firstDay);
const offset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - offset);
-
- const cells = [];
const d = new Date(gridStart);
for (let i = 0; i < 42; i++) {
cells.push(new Date(d));
d.setDate(d.getDate() + 1);
}
- // Build event map keyed by date string
- const evMap = {};
- events.forEach(ev => {
+ // Normalize each event's date range once
+ const normed = events.map(ev => {
const s = new Date(ev.start);
- const e = ev.allDay ? new Date(ev.end) : new Date(ev.end);
- // Spread multi-day events across cells
- const cur = new Date(s);
- cur.setHours(0, 0, 0, 0);
- const endNorm = new Date(e);
- endNorm.setHours(0, 0, 0, 0);
- if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
- while (cur <= endNorm) {
- const key = dateKey(cur);
- if (!evMap[key]) evMap[key] = [];
- evMap[key].push(ev);
- cur.setDate(cur.getDate() + 1);
- }
+ s.setHours(0, 0, 0, 0);
+ const e = new Date(ev.end);
+ e.setHours(0, 0, 0, 0);
+ if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
+ return { ev, ns: s, ne: e };
});
- // Header: KW-Spalte + Wochentage
- const headerHtml = `` +
+ // 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,''');
}
--
2.47.3
From e2f98520e2e3305b026c8e2873e0b50c5fb34a55 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 21:44:44 +0200
Subject: [PATCH 002/114] =?UTF-8?q?fix:=20Month=20grid=20lines,=20scroll?=
=?UTF-8?q?=20throttle,=20custom=20dark=20date/time=20picker=20-=20Month?=
=?UTF-8?q?=20view:=20Replaced=20day-strip+events-area=20with=20full-heigh?=
=?UTF-8?q?t=20column=20=20=20divs=20(.month-col)=20so=20borders=20extend?=
=?UTF-8?q?=20the=20full=20row=20height=20and=20clicking=20=20=20anywhere?=
=?UTF-8?q?=20in=20a=20day=20column=20(including=20below=20events)=20navig?=
=?UTF-8?q?ates=20to=20day=20view.=20=20=20Events=20overlay=20uses=20point?=
=?UTF-8?q?er-events:none=20(pass-through)=20while=20span=20bars=20=20=20a?=
=?UTF-8?q?nd=20+N-more=20labels=20stay=20pointer-events:all.=20-=20Scroll?=
=?UTF-8?q?=20navigation:=20Changed=20wheel=20handler=20from=2080ms=20debo?=
=?UTF-8?q?unce=20to=20500ms=20=20=20leading-edge=20throttle=20=E2=80=94?=
=?UTF-8?q?=20one=20navigation=20per=20trackpad=20gesture.=20-=20Custom=20?=
=?UTF-8?q?date/time=20picker=20(date-picker.js):=20Dark=20calendar=20grid?=
=?UTF-8?q?=20with=20=20=20prev/next=20navigation,=20today/selected=20high?=
=?UTF-8?q?lighting,=20and=20a=20CSS=20=20=20scroll-snap=20time=20scroller?=
=?UTF-8?q?=20(hours=200-23,=20minutes=200-59)=20matching=20the=20=20=20ap?=
=?UTF-8?q?p's=20primary=20color.=20Language-aware=20(month=20names,=20day?=
=?UTF-8?q?=20headers=20via=20t()).=20-=20Event=20modal=20datetime=20input?=
=?UTF-8?q?s=20replaced=20with=20hidden=20inputs=20+=20.dt-display=20=20?=
=?UTF-8?q?=20click=20targets=20that=20open=20the=20custom=20picker.=20set?=
=?UTF-8?q?DtValue()=20helper=20keeps=20=20=20hidden=20input=20and=20displ?=
=?UTF-8?q?ay=20label=20in=20sync.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 166 +++++++++++++++++++----
frontend/index.html | 24 +++-
frontend/js/calendar.js | 55 ++++++--
frontend/js/date-picker.js | 271 +++++++++++++++++++++++++++++++++++++
frontend/js/views/month.js | 46 +++----
5 files changed, 491 insertions(+), 71 deletions(-)
create mode 100644 frontend/js/date-picker.js
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 4f9797f..89e567b 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -469,8 +469,7 @@ a { color: var(--primary); text-decoration: none; }
letter-spacing: .5px; color: var(--text-2);
}
.month-kw-cell {
- position: absolute; left: 0; top: 0; bottom: 0;
- width: 38px;
+ position: absolute; left: 0; top: 0; bottom: 0; width: 38px;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px;
font-size: 13px; color: var(--text-3); font-weight: 700;
@@ -478,32 +477,34 @@ a { color: var(--primary); text-decoration: none; }
cursor: default; user-select: none; z-index: 1;
background: var(--bg-app);
}
-.month-cell {
- flex: 1;
- border-right: 1px solid var(--border);
- padding: 4px 4px 0;
- cursor: pointer;
- transition: background var(--transition);
- min-width: 0;
+/* Full-height column divs — click target + border */
+.month-col {
+ flex: 1; border-right: 1px solid var(--border);
+ cursor: pointer; transition: background var(--transition);
+ padding: 4px 4px 0; min-width: 0;
}
-.month-cell:last-child { border-right: none; }
-.month-cell:hover { background: var(--bg-hover); }
-.month-cell.today { background: rgba(66,133,244,.08); }
-.month-cell.other-month .cell-day { color: var(--text-3); }
+.month-col:last-child { border-right: none; }
+.month-col:hover { background: var(--bg-hover); }
+.month-col.today { background: rgba(66,133,244,.08); }
+.month-col.other-month .cell-day { color: var(--text-3); }
.cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2);
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; flex-shrink: 0;
}
-.cell-day.today {
- background: var(--today-color);
- color: #fff; font-weight: 700;
+.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
+/* Events overlay — pointer-events:none so clicks pass to columns */
+.month-events-overlay {
+ position: absolute; top: 30px; left: 0; right: 0; bottom: 0;
+ pointer-events: none; overflow: hidden; z-index: 2;
}
.month-more {
position: absolute;
font-size: 11px; color: var(--text-2); padding: 0 4px;
cursor: pointer; font-weight: 500;
+ pointer-events: all;
+ white-space: nowrap; overflow: hidden;
}
.month-more:hover { color: var(--primary); }
@@ -901,32 +902,24 @@ a { color: var(--primary); text-decoration: none; }
display: flex; flex-direction: column; flex: 1; overflow: hidden;
}
.month-row {
- display: flex; flex: 1; position: relative; min-height: 0;
+ display: flex; flex: 1; position: relative; min-height: 100px;
border-bottom: 1px solid var(--border);
}
.month-row:last-child { border-bottom: none; }
+/* row-right: flex row containing 7 full-height column divs + events overlay */
.month-row-right {
- margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0;
-}
-.month-day-strip {
- display: flex; flex-shrink: 0;
-}
-.month-events-area {
- position: relative; flex: 1;
- min-height: 72px; /* 3 lanes × 22px + 6px padding */
- overflow: hidden;
+ margin-left: 38px; display: flex; flex: 1; position: relative; min-width: 0;
}
.month-span-event {
position: absolute;
height: 18px; line-height: 18px;
- border-radius: 3px;
- padding: 0 6px;
+ border-radius: 3px; padding: 0 6px;
font-size: 11px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer; color: #fff;
transition: filter var(--transition);
box-sizing: border-box;
- z-index: 2;
+ pointer-events: all;
}
.month-span-event:hover { filter: brightness(1.15); }
.month-span-event.past { opacity: .45; }
@@ -947,3 +940,118 @@ a { color: var(--primary); text-decoration: none; }
.logo-text { display: none; }
.view-title { font-size: 16px; }
}
+
+/* ── Custom Date/Time Display Field ─────────────────────── */
+.dt-display {
+ display: flex; align-items: center; justify-content: space-between;
+ background: var(--bg-app);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+ color: var(--text-1);
+ cursor: pointer;
+ transition: border-color var(--transition);
+ user-select: none;
+ min-height: 42px;
+}
+.dt-display:hover, .dt-display:focus { border-color: var(--primary); outline: none; }
+.dt-display-text { font-size: 14px; }
+.dt-display-icon { color: var(--text-3); flex-shrink: 0; margin-left: 6px; }
+
+/* ── Date/Time Picker Card ───────────────────────────────── */
+.dtp-overlay {
+ position: fixed; inset: 0; z-index: 700;
+}
+.dtp-card {
+ position: fixed; z-index: 701;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-lg);
+ padding: 16px;
+ width: 272px;
+ user-select: none;
+}
+/* Calendar header */
+.dtp-cal-header {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 10px;
+}
+.dtp-month-label {
+ font-size: 14px; font-weight: 600; color: var(--text-1);
+}
+.dtp-nav-btn {
+ background: none; border: none; cursor: pointer;
+ color: var(--text-2); font-size: 22px; line-height: 1;
+ padding: 2px 6px; border-radius: var(--radius-sm);
+ transition: background var(--transition), color var(--transition);
+}
+.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
+/* Day-of-week headers */
+.dtp-grid {
+ display: grid; grid-template-columns: repeat(7, 1fr);
+ gap: 2px; margin-bottom: 4px;
+}
+.dtp-dow {
+ text-align: center; font-size: 11px; font-weight: 600;
+ color: var(--text-3); padding: 4px 0; text-transform: uppercase;
+}
+/* Day cells */
+.dtp-day {
+ display: flex; align-items: center; justify-content: center;
+ height: 34px; border-radius: 6px;
+ font-size: 13px; font-weight: 500; color: var(--text-1);
+ cursor: pointer; transition: background var(--transition);
+}
+.dtp-day:hover { background: var(--bg-hover); }
+.dtp-day.other { color: var(--text-3); }
+.dtp-day.other:hover { background: var(--bg-hover); }
+.dtp-day.today { color: var(--primary); font-weight: 700; }
+.dtp-day.selected {
+ background: var(--primary) !important;
+ color: #fff !important; font-weight: 700;
+}
+/* Time picker */
+.dtp-time-row {
+ display: flex; align-items: center; justify-content: center;
+ gap: 4px; margin: 12px 0 8px;
+ border-top: 1px solid var(--border-light);
+ padding-top: 12px;
+}
+.dtp-colon {
+ font-size: 22px; font-weight: 600; color: var(--text-2);
+ padding: 0 2px; line-height: 1;
+ align-self: center;
+}
+.dtp-tc-wrap {
+ position: relative; width: 64px;
+}
+.dtp-tc {
+ height: calc(3 * 40px); /* 120px = 3 visible items */
+ overflow-y: scroll;
+ scroll-snap-type: y mandatory;
+ scrollbar-width: none;
+ padding: 40px 0; /* top/bottom padding so first/last can center */
+ box-sizing: content-box;
+}
+.dtp-tc::-webkit-scrollbar { display: none; }
+.dtp-ti {
+ height: 40px; line-height: 40px;
+ text-align: center; font-size: 20px; font-weight: 500;
+ scroll-snap-align: center;
+ border-radius: 8px;
+ cursor: pointer;
+ color: var(--text-2);
+ transition: background .1s, color .1s;
+}
+.dtp-ti:hover { color: var(--text-1); }
+.dtp-ti.selected {
+ background: var(--primary);
+ color: #fff;
+}
+/* Actions */
+.dtp-actions {
+ display: flex; justify-content: flex-end; gap: 8px;
+ margin-top: 12px; padding-top: 10px;
+ border-top: 1px solid var(--border-light);
+}
diff --git a/frontend/index.html b/frontend/index.html
index a489f43..480ebe7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -202,21 +202,37 @@
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 27f6fb4..4ddb9da 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
import { openColorPicker } from './color-picker.js';
+import { openDatePicker, formatDtDisplay } from './date-picker.js';
import { t, setLang, getLang } from './i18n.js';
// Fetch avatar image as blob URL (with auth header)
@@ -540,12 +541,13 @@ function bindTopbar() {
document.getElementById('btn-settings').onclick = openSettingsModal;
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
- // Mouse wheel / trackpad scroll navigation
- let _wheelTimer = null;
+ // Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture)
+ let _wheelLast = 0;
document.getElementById('view-container').addEventListener('wheel', e => {
e.preventDefault();
- if (_wheelTimer) return;
- _wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
+ const now = Date.now();
+ if (now - _wheelLast < 500) return;
+ _wheelLast = now;
const dir = e.deltaY > 0 ? 1 : -1;
if (state.currentView === 'agenda') return;
if (state.currentView === 'month') {
@@ -710,6 +712,17 @@ function populateCalendarSelect(selectedId) {
});
}
+// ── Date field helpers ────────────────────────────────────
+function setDtValue(id, isoStr, mode) {
+ const input = document.getElementById(id);
+ if (input) input.value = isoStr || '';
+ const display = document.getElementById(id + '-display');
+ if (display) {
+ display.querySelector('.dt-display-text').textContent =
+ formatDtDisplay(isoStr, mode, getLang());
+ }
+}
+
function openNewEventModal(date) {
state.editingEvent = null;
state.selectedEventColor = '';
@@ -723,10 +736,10 @@ function openNewEventModal(date) {
const start = new Date(date);
const end = new Date(date);
end.setHours(end.getHours() + 1);
- document.getElementById('ev-start').value = toLocalDatetimeInput(start);
- document.getElementById('ev-end').value = toLocalDatetimeInput(end);
- document.getElementById('ev-start-date').value = toDateInput(start);
- document.getElementById('ev-end-date').value = toDateInput(start);
+ setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
+ setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
+ setDtValue('ev-start-date', toDateInput(start), 'date');
+ setDtValue('ev-end-date', toDateInput(start), 'date');
toggleAlldayFields(false);
populateCalendarSelect(null);
@@ -746,14 +759,14 @@ function openEditEventModal(ev) {
document.getElementById('ev-allday').checked = ev.allDay;
if (ev.allDay) {
- document.getElementById('ev-start-date').value = ev.start.slice(0, 10);
- document.getElementById('ev-end-date').value = ev.end.slice(0, 10);
+ setDtValue('ev-start-date', ev.start.slice(0, 10), 'date');
+ setDtValue('ev-end-date', ev.end.slice(0, 10), 'date');
toggleAlldayFields(true);
} else {
const s = new Date(ev.start);
const e = new Date(ev.end);
- document.getElementById('ev-start').value = toLocalDatetimeInput(s);
- document.getElementById('ev-end').value = toLocalDatetimeInput(e);
+ setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime');
+ setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime');
toggleAlldayFields(false);
}
@@ -781,6 +794,24 @@ function bindEventModal() {
toggleAlldayFields(e.target.checked);
});
+ // Date/time pickers
+ [
+ { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' },
+ { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' },
+ { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' },
+ { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' },
+ ].forEach(({ displayId, inputId, mode }) => {
+ const disp = document.getElementById(displayId);
+ if (!disp) return;
+ const open = async () => {
+ const current = document.getElementById(inputId)?.value || '';
+ const result = await openDatePicker(disp, current, mode);
+ if (result !== null) setDtValue(inputId, result, mode);
+ };
+ disp.addEventListener('click', open);
+ disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); });
+ });
+
// Color picker: click preview to open gradient picker
const evColorPreview = document.getElementById('ev-color-preview');
const evColorHex = document.getElementById('ev-color-hex');
diff --git a/frontend/js/date-picker.js b/frontend/js/date-picker.js
new file mode 100644
index 0000000..80678dd
--- /dev/null
+++ b/frontend/js/date-picker.js
@@ -0,0 +1,271 @@
+/**
+ * Custom dark date/time picker
+ * openDatePicker(anchor, value, mode) → Promise
+ * anchor : DOM element to position near
+ * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
+ * mode : 'datetime' | 'date'
+ */
+import { t } from './i18n.js';
+
+const ITEM_H = 40; // px per scroll item
+const VISIBLE = 3; // visible items in time scroller
+
+export function openDatePicker(anchor, value, mode = 'datetime') {
+ return new Promise(resolve => {
+ // ── Parse initial value ───────────────────────────────
+ let selDate = new Date();
+ selDate.setHours(0, 0, 0, 0);
+ let selHour = selDate.getHours();
+ let selMin = 0;
+
+ if (value) {
+ try {
+ const raw = mode === 'datetime'
+ ? value.replace(' ', 'T')
+ : value + 'T00:00:00';
+ const d = new Date(raw);
+ if (!isNaN(d)) {
+ selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+ if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); }
+ }
+ } catch (_) {}
+ }
+
+ let viewYear = selDate.getFullYear();
+ let viewMonth = selDate.getMonth();
+
+ // ── Build DOM ─────────────────────────────────────────
+ const overlay = document.createElement('div');
+ overlay.className = 'dtp-overlay';
+ document.body.appendChild(overlay);
+
+ const card = document.createElement('div');
+ card.className = 'dtp-card';
+ overlay.appendChild(card);
+
+ function done(result) {
+ overlay.remove();
+ resolve(result);
+ }
+
+ // Click outside → cancel
+ overlay.addEventListener('mousedown', e => {
+ if (e.target === overlay) done(null);
+ });
+
+ // ── Calendar builder ──────────────────────────────────
+ function buildCalendar() {
+ const months = t('months');
+ const dowKeys = t('dow_monday'); // always Monday-first in calendar
+
+ const firstDay = new Date(viewYear, viewMonth, 1);
+ const gridStart = new Date(firstDay);
+ let dow = firstDay.getDay();
+ dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun
+ gridStart.setDate(gridStart.getDate() - dow);
+
+ const cells = [];
+ const iter = new Date(gridStart);
+ for (let i = 0; i < 42; i++) {
+ cells.push(new Date(iter));
+ iter.setDate(iter.getDate() + 1);
+ }
+
+ const today = new Date(); today.setHours(0, 0, 0, 0);
+
+ const dowHtml = dowKeys.map(d => `${d}
`).join('');
+ const daysHtml = cells.map(cell => {
+ const isOther = cell.getMonth() !== viewMonth;
+ const isToday = cell.getTime() === today.getTime();
+ const isSelected = cell.getTime() === selDate.getTime();
+ let cls = 'dtp-day';
+ if (isOther) cls += ' other';
+ if (isToday) cls += ' today';
+ if (isSelected) cls += ' selected';
+ return `${cell.getDate()}
`;
+ }).join('');
+
+ return `
+
+ ${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);
}
--
2.47.3
From 46f6765087e080d10959146fdf9b3f1bea1e3205 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 21:51:15 +0200
Subject: [PATCH 003/114] =?UTF-8?q?fix:=20Month=20scroll=20navigates=20by?=
=?UTF-8?q?=20full=20month,=20not=20by=20week=20Scrolling=20in=20month=20v?=
=?UTF-8?q?iew=20was=20moving=20currentDate=20by=207=20days,=20but=20the?=
=?UTF-8?q?=20grid=20always=20renders=20the=20complete=20month=20=E2=80=94?=
=?UTF-8?q?=20so=204=20scrolls=20were=20needed=20before=20any=20visual=20c?=
=?UTF-8?q?hange.=20Now=20each=20scroll=20step=20advances/retreats=20by=20?=
=?UTF-8?q?exactly=20one=20month=20(same=20as=20the=20prev/next=20buttons)?=
=?UTF-8?q?.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 4ddb9da..a51930a 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -551,8 +551,8 @@ function bindTopbar() {
const dir = e.deltaY > 0 ? 1 : -1;
if (state.currentView === 'agenda') return;
if (state.currentView === 'month') {
- state.currentDate = new Date(state.currentDate);
- state.currentDate.setDate(state.currentDate.getDate() + dir * 7);
+ const d = state.currentDate;
+ state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1);
fetchAndRender();
} else {
navigate(dir);
--
2.47.3
From 62ac0162eb33b83c43bc2811fb3e0314fbc35851 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 21:58:51 +0200
Subject: [PATCH 004/114] feat: Rolling 5-week month view with week-by-week
scroll Month view now shows 5 weeks starting from the week containing
currentDate (not fixed to month boundaries), enabling views like "mid-April
to mid-May". Prev/Next buttons jump 4 weeks; mouse wheel scrolls 1 week at a
time with 500ms debounce.
---
frontend/js/calendar.js | 29 +++++++++++++++++++++--------
frontend/js/views/month.js | 24 ++++++++++++------------
2 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index a51930a..6658980 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -93,10 +93,11 @@ function getViewRange() {
let start, end;
if (state.currentView === 'month') {
- start = new Date(d.getFullYear(), d.getMonth(), 1);
- start.setDate(start.getDate() - dayOfWeek(start, weekStartDay) - 1);
- end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
- end.setDate(end.getDate() + (6 - dayOfWeek(end, weekStartDay)) + 1);
+ // Rolling view: 5 weeks from the start of currentDate's week
+ start = weekStart(d, weekStartDay);
+ start.setDate(start.getDate() - 1); // 1-day buffer
+ end = new Date(start);
+ end.setDate(start.getDate() + 37); // 5 weeks + buffer
} else if (state.currentView === 'week') {
start = weekStart(d, weekStartDay);
end = new Date(start);
@@ -165,7 +166,17 @@ function updateTitle() {
let title = '';
const M = t('months');
if (state.currentView === 'month') {
- title = `${M[d.getMonth()]} ${d.getFullYear()}`;
+ // Show date range of the rolling 5-week window
+ const ws = weekStart(d, weekStartDay);
+ const we = new Date(ws); we.setDate(we.getDate() + 34); // last day of 5th week
+ const Ms = t('months_short');
+ if (ws.getFullYear() !== we.getFullYear()) {
+ title = `${Ms[ws.getMonth()]} ${ws.getFullYear()} – ${Ms[we.getMonth()]} ${we.getFullYear()}`;
+ } else if (ws.getMonth() !== we.getMonth()) {
+ title = `${Ms[ws.getMonth()]} – ${Ms[we.getMonth()]} ${we.getFullYear()}`;
+ } else {
+ title = `${M[ws.getMonth()]} ${ws.getFullYear()}`;
+ }
} else if (state.currentView === 'week') {
const mon = weekStart(d, weekStartDay);
const sun = new Date(mon);
@@ -507,7 +518,9 @@ function renderCalendarList() {
function navigate(dir) {
const d = state.currentDate;
if (state.currentView === 'month') {
- state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1);
+ // Buttons jump 4 weeks (one screenful)
+ state.currentDate = new Date(d);
+ state.currentDate.setDate(d.getDate() + dir * 28);
} else if (state.currentView === 'week') {
state.currentDate = new Date(d);
state.currentDate.setDate(d.getDate() + dir * 7);
@@ -551,8 +564,8 @@ function bindTopbar() {
const dir = e.deltaY > 0 ? 1 : -1;
if (state.currentView === 'agenda') return;
if (state.currentView === 'month') {
- const d = state.currentDate;
- state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir, 1);
+ state.currentDate = new Date(state.currentDate);
+ state.currentDate.setDate(state.currentDate.getDate() + dir * 7);
fetchAndRender();
} else {
navigate(dir);
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index 1d9de77..0d4db29 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -1,23 +1,23 @@
-import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
+import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const MAX_LANES = 3; // max visible lanes per row
+const NUM_ROWS = 5; // rolling view: always 5 weeks
+
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
- const year = currentDate.getFullYear();
- const month = currentDate.getMonth();
- const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
+ // "Primary month" = currentDate's month (used for muting other-month days)
+ const primaryMonth = currentDate.getMonth();
+ const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
- const firstDay = new Date(year, month, 1);
+ // Rolling grid: start at the week that contains currentDate
+ const gridStart = weekStart(currentDate, weekStartDay);
- // Build 42-cell grid
+ // Build NUM_ROWS × 7 cells
const cells = [];
- const gridStart = new Date(firstDay);
- const offset = dayOfWeek(firstDay, weekStartDay);
- gridStart.setDate(gridStart.getDate() - offset);
const d = new Date(gridStart);
- for (let i = 0; i < 42; i++) {
+ for (let i = 0; i < NUM_ROWS * 7; i++) {
cells.push(new Date(d));
d.setDate(d.getDate() + 1);
}
@@ -37,7 +37,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
// Build rows
let bodyHtml = '';
- for (let row = 0; row < 6; row++) {
+ for (let row = 0; row < NUM_ROWS; row++) {
const rowCells = cells.slice(row * 7, row * 7 + 7);
const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
@@ -119,7 +119,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
let colsHtml = '';
rowCells.forEach(cell => {
const key = dateKey(cell);
- const isOther = cell.getMonth() !== month;
+ const isOther = cell.getMonth() !== primaryMonth;
const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : '';
const numCls = isToday(cell) ? 'today' : '';
--
2.47.3
From bc93474f49ece28837403a19969139b46e504c03 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 22:05:03 +0200
Subject: [PATCH 005/114] =?UTF-8?q?perf:=20Event=20cache=20mit=20=C2=B18-W?=
=?UTF-8?q?ochen-Puffer=20f=C3=BCr=20schnelle=20Navigation=20Beim=20ersten?=
=?UTF-8?q?=20Laden=20wird=20ein=20Fenster=20von=20=C2=B18=20Wochen=20um?=
=?UTF-8?q?=20die=20aktuelle=20Ansicht=20geholt.=20Wochenweise=20Navigatio?=
=?UTF-8?q?n=20trifft=20danach=20den=20Cache=20sofort=20(kein=20Spinner,?=
=?UTF-8?q?=20kein=20Netzwerk).=20Nach=20echten=20Daten=C3=A4nderungen=20(?=
=?UTF-8?q?Event=20speichern/l=C3=B6schen,=20Sync,=20Konto-=C3=84nderungen?=
=?UTF-8?q?)=20wird=20der=20Cache=20invalidiert=20und=20neu=20geladen.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 52 +++++++++++++++++++++++++++++++----------
1 file changed, 40 insertions(+), 12 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 6658980..af88aaa 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -72,12 +72,40 @@ export async function initCalendar() {
bindProfileModal();
}
+// ── Event cache ───────────────────────────────────────────
+const eventCache = { start: null, end: null, events: [] };
+
+function invalidateCache() {
+ eventCache.start = null;
+ eventCache.end = null;
+ eventCache.events = [];
+}
+
// ── Data fetching ─────────────────────────────────────────
-async function fetchAndRender() {
+async function fetchAndRender(force = false) {
const { start, end } = getViewRange();
+
+ // Cache hit: requested range is fully within what we already have
+ if (!force && eventCache.start && eventCache.end &&
+ start >= eventCache.start && end <= eventCache.end) {
+ state.events = eventCache.events;
+ renderView();
+ updateTitle();
+ renderMiniCal();
+ return;
+ }
+
+ // Cache miss: fetch a wider window (±8 weeks) so subsequent navigation is instant
+ const BUF = 56 * 86400000; // 8 weeks in ms
+ const fetchStart = new Date(start.getTime() - BUF);
+ const fetchEnd = new Date(end.getTime() + BUF);
+
showLoading();
try {
- const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`);
+ const events = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
+ eventCache.start = fetchStart;
+ eventCache.end = fetchEnd;
+ eventCache.events = events;
state.events = events;
} catch (e) {
showToast(t('error_load_events') + ': ' + e.message, true);
@@ -676,7 +704,7 @@ function showEventPopup(ev, anchor) {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
}
showToast(t('event_deleted'));
- fetchAndRender();
+ fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
};
document.getElementById('popup-close').onclick = () => popup.classList.add('hidden');
@@ -916,7 +944,7 @@ function bindEventModal() {
showToast(t('event_created'));
}
closeModal('modal-event');
- fetchAndRender();
+ fetchAndRender(true);
} catch (e) {
showToast(e.message, true);
}
@@ -940,7 +968,7 @@ function bindEventModal() {
}
showToast(t('event_deleted'));
closeModal('modal-event');
- fetchAndRender();
+ fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
};
}
@@ -993,7 +1021,7 @@ function bindAccountModal() {
renderCalendarList();
closeModal('modal-account');
showToast(t("account_added", {name}));
- fetchAndRender();
+ fetchAndRender(true);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
@@ -1081,7 +1109,7 @@ function bindICalSubModal() {
renderCalendarList();
closeModal('modal-ical-sub');
showToast(t("ical_subscribed", {name}));
- fetchAndRender();
+ fetchAndRender(true);
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
@@ -1170,7 +1198,7 @@ function renderGoogleAccounts() {
if (idx !== -1) state.googleAccounts[idx] = updated;
renderGoogleAccounts();
renderCalendarList();
- fetchAndRender();
+ fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
});
@@ -1183,7 +1211,7 @@ function renderGoogleAccounts() {
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
renderGoogleAccounts();
renderCalendarList();
- fetchAndRender();
+ fetchAndRender(true);
showToast(t('google_disconnected'));
} catch (e) { showToast(e.message, true); }
});
@@ -1214,7 +1242,7 @@ function renderAllAccounts() {
btn.disabled = true; btn.textContent = '…';
try {
await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`);
- renderCalendarList(); fetchAndRender();
+ renderCalendarList(); fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = t('sync'); }
@@ -1226,7 +1254,7 @@ function renderAllAccounts() {
try {
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
- renderAllAccounts(); renderCalendarList(); fetchAndRender();
+ renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
showToast(t('caldav_disconnected'));
} catch (e) { showToast(e.message, true); }
});
@@ -1274,7 +1302,7 @@ function renderAllAccounts() {
try {
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
- renderAllAccounts(); renderCalendarList(); fetchAndRender();
+ renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
});
--
2.47.3
From 59751349b751db05ac89b94d0f8098fb63714958 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 22:09:11 +0200
Subject: [PATCH 006/114] =?UTF-8?q?perf:=20Sliding-window=20Cache=20?=
=?UTF-8?q?=E2=80=94=20Hintergrund-Prefetch=20bei=20Cache-Randn=C3=A4he=20?=
=?UTF-8?q?Wenn=20die=20aktuelle=20Ansicht=20weniger=20als=204=20Wochen=20?=
=?UTF-8?q?vom=20Cache-Rand=20entfernt=20ist,=20werden=20im=20Hintergrund?=
=?UTF-8?q?=208=20weitere=20Wochen=20in=20diese=20Richtung=20geladen=20und?=
=?UTF-8?q?=20in=20den=20Cache=20gemergt.=20Der=20Cache=20w=C3=A4chst=20da?=
=?UTF-8?q?mit=20automatisch=20mit=20der=20Navigation=20mit=20=E2=80=94=20?=
=?UTF-8?q?kein=20sichtbarer=20Ladevorgang=20auch=20bei=20langen=20Spr?=
=?UTF-8?q?=C3=BCngen.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 56 +++++++++++++++++++++++++++++++++++------
1 file changed, 49 insertions(+), 7 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index af88aaa..3dca0ac 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -73,12 +73,54 @@ export async function initCalendar() {
}
// ── Event cache ───────────────────────────────────────────
-const eventCache = { start: null, end: null, events: [] };
+const CACHE_BUF = 56 * 86400000; // initial ±8 weeks
+const PREFETCH_EXT = 56 * 86400000; // extend by 8 weeks when triggered
+const PREFETCH_EDGE = 28 * 86400000; // trigger when within 4 weeks of cache edge
+
+const eventCache = {
+ start: null, end: null, events: [],
+ _fwdPending: false, _bwdPending: false,
+};
function invalidateCache() {
- eventCache.start = null;
- eventCache.end = null;
- eventCache.events = [];
+ eventCache.start = null;
+ eventCache.end = null;
+ eventCache.events = [];
+ eventCache._fwdPending = false;
+ eventCache._bwdPending = false;
+}
+
+function _mergeEvents(newEvents) {
+ const seen = new Set(eventCache.events.map(e => e.id + '@@' + (e.url || '')));
+ for (const e of newEvents) {
+ const k = e.id + '@@' + (e.url || '');
+ if (!seen.has(k)) { seen.add(k); eventCache.events.push(e); }
+ }
+}
+
+// Fire-and-forget: extend the cache toward whichever edge the view is approaching
+function prefetchIfNeeded(viewStart, viewEnd) {
+ if (!eventCache.start || !eventCache.end) return;
+
+ if (!eventCache._fwdPending && (eventCache.end - viewEnd) < PREFETCH_EDGE) {
+ eventCache._fwdPending = true;
+ const from = new Date(eventCache.end);
+ const to = new Date(eventCache.end.getTime() + PREFETCH_EXT);
+ api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`)
+ .then(evs => { _mergeEvents(evs); eventCache.end = to; })
+ .catch(() => {})
+ .finally(() => { eventCache._fwdPending = false; });
+ }
+
+ if (!eventCache._bwdPending && (viewStart - eventCache.start) < PREFETCH_EDGE) {
+ eventCache._bwdPending = true;
+ const from = new Date(eventCache.start.getTime() - PREFETCH_EXT);
+ const to = new Date(eventCache.start);
+ api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`)
+ .then(evs => { _mergeEvents(evs); eventCache.start = from; })
+ .catch(() => {})
+ .finally(() => { eventCache._bwdPending = false; });
+ }
}
// ── Data fetching ─────────────────────────────────────────
@@ -92,13 +134,13 @@ async function fetchAndRender(force = false) {
renderView();
updateTitle();
renderMiniCal();
+ prefetchIfNeeded(start, end); // extend cache in background if approaching an edge
return;
}
// Cache miss: fetch a wider window (±8 weeks) so subsequent navigation is instant
- const BUF = 56 * 86400000; // 8 weeks in ms
- const fetchStart = new Date(start.getTime() - BUF);
- const fetchEnd = new Date(end.getTime() + BUF);
+ const fetchStart = new Date(start.getTime() - CACHE_BUF);
+ const fetchEnd = new Date(end.getTime() + CACHE_BUF);
showLoading();
try {
--
2.47.3
From 3846af527a2617ffc2542f913f323e061cbb55c1 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Tue, 7 Apr 2026 22:18:10 +0200
Subject: [PATCH 007/114] =?UTF-8?q?fix:=20Kalenderfarbe=20wird=20sofort=20?=
=?UTF-8?q?ohne=20Reload=20aktualisiert=20Statt=20nach=20Farb=C3=A4nderung?=
=?UTF-8?q?=20den=20Cache=20zu=20invalidieren=20und=20neu=20zu=20laden,=20?=
=?UTF-8?q?wird=20calendarColor=20direkt=20in-place=20auf=20allen=20gecach?=
=?UTF-8?q?ten=20Events=20gepatcht=20und=20dann=20nur=20renderView()=20auf?=
=?UTF-8?q?gerufen.=20Kein=20Netzwerk-Request,=20sofortige=20Darstellung?=
=?UTF-8?q?=20der=20neuen=20Farbe.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 28 ++++++++++++++++++++--------
1 file changed, 20 insertions(+), 8 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 3dca0ac..e177cb7 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -98,6 +98,22 @@ function _mergeEvents(newEvents) {
}
}
+// Patch calendarColor in-place for all cached events belonging to a calendar,
+// then re-render immediately without a network round-trip.
+function applyCalendarColor(source, calId, color) {
+ const id = String(calId);
+ eventCache.events.forEach(ev => {
+ if (ev.source !== source) return;
+ const cid = String(ev.calendar_id);
+ if (cid === id || cid === source + '-' + id || cid.replace(source + '-', '') === id) {
+ ev.calendarColor = color;
+ }
+ });
+ state.events = eventCache.events;
+ renderCalendarList();
+ renderView();
+}
+
// Fire-and-forget: extend the cache toward whichever edge the view is approaching
function prefetchIfNeeded(viewStart, viewEnd) {
if (!eventCache.start || !eventCache.end) return;
@@ -468,8 +484,7 @@ function renderCalendarList() {
if (picked) {
await api.put(`/local/calendars/${calId}`, { color: picked });
if (cal) cal.color = picked;
- renderCalendarList();
- fetchAndRender();
+ applyCalendarColor('local', calId, picked);
}
} else if (source === 'ical') {
const subId = parseInt(dot.dataset.subId);
@@ -478,8 +493,7 @@ function renderCalendarList() {
if (picked) {
await api.put(`/ical/subscriptions/${subId}`, { color: picked });
if (sub) sub.color = picked;
- renderCalendarList();
- fetchAndRender();
+ applyCalendarColor('ical', subId, picked);
}
} else if (source === 'google') {
const calId = parseInt(dot.dataset.calId);
@@ -492,8 +506,7 @@ function renderCalendarList() {
if (picked) {
await api.put(`/google/calendars/${calId}`, { color: picked });
if (gcal) gcal.color = picked;
- renderCalendarList();
- fetchAndRender();
+ applyCalendarColor('google', calId, picked);
}
}
});
@@ -1732,8 +1745,7 @@ async function openCalColorPicker(anchor, calId) {
if (cal.id === calId) cal.color = picked;
}
}
- renderCalendarList();
- fetchAndRender();
+ applyCalendarColor('caldav', calId, picked);
}
// ── Avatar Crop ──────────────────────────────────────────
--
2.47.3
From e8b5bb3a40bb89c31cb853db610f355374cf698b Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:10:50 +0200
Subject: [PATCH 008/114] =?UTF-8?q?Feature:=20Quartalsansicht=20hinzugef?=
=?UTF-8?q?=C3=BCgt=20Neue=20Ansicht=20zeigt=203=20Monate=20eines=20Quarta?=
=?UTF-8?q?ls=20nebeneinander=20mit=20farbigen=20Event-Dots,=20Quartal-Nav?=
=?UTF-8?q?igation=20und=20Titelanzeige=20(z.B.=20Q2=202026).=20Klick=20au?=
=?UTF-8?q?f=20Tag=20wechselt=20in=20Tagesansicht.=20Zweisprachig=20(DE/EN?=
=?UTF-8?q?).?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 98 ++++++++++++++++++++++++++++++
frontend/index.html | 2 +
frontend/js/calendar.js | 16 +++++
frontend/js/i18n.js | 4 +-
frontend/js/views/quarter.js | 113 +++++++++++++++++++++++++++++++++++
5 files changed, 231 insertions(+), 2 deletions(-)
create mode 100644 frontend/js/views/quarter.js
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 89e567b..cd1214b 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -616,6 +616,104 @@ a { color: var(--primary); text-decoration: none; }
/* Day view specifics */
.day-view .week-day-col { flex: 1; }
+/* ── Quarter View ───────────────────────────────────────── */
+.quarter-view {
+ display: flex;
+ gap: 16px;
+ padding: 16px;
+ height: 100%;
+ box-sizing: border-box;
+ overflow-y: auto;
+}
+.qtr-month {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--bg-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.qtr-month-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-1);
+ padding: 10px 12px 8px;
+ border-bottom: 1px solid var(--border);
+ letter-spacing: .3px;
+}
+.qtr-month-grid { padding: 6px 8px 8px; }
+.qtr-header {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ margin-bottom: 2px;
+}
+.qtr-dow {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-3);
+ text-align: center;
+ padding: 3px 0;
+}
+.qtr-cells {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ grid-template-rows: repeat(6, auto);
+}
+.qtr-cell {
+ padding: 3px 2px;
+ text-align: center;
+ cursor: pointer;
+ border-radius: 4px;
+ min-height: 36px;
+}
+.qtr-cell:hover { background: var(--bg-hover); }
+.qtr-cell.today .qtr-day-num {
+ background: var(--today-color, var(--primary));
+ color: #fff;
+ border-radius: 50%;
+}
+.qtr-cell.selected .qtr-day-num {
+ background: var(--primary);
+ color: #fff;
+ border-radius: 50%;
+ opacity: .55;
+}
+.qtr-day-num {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-2);
+ width: 22px;
+ height: 22px;
+ line-height: 22px;
+ margin: 0 auto 2px;
+}
+.qtr-cell.other-month .qtr-day-num { color: var(--text-3); opacity: .45; }
+.qtr-cell.today .qtr-day-num { color: #fff; }
+.qtr-dots {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 2px;
+ min-height: 6px;
+}
+.qtr-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ cursor: pointer;
+}
+.qtr-dot.past { opacity: .45; }
+.qtr-dot-more {
+ font-size: 9px;
+ color: var(--text-3);
+ line-height: 6px;
+}
+
/* ── Agenda View ────────────────────────────────────────── */
.agenda-view { padding: 16px; }
.agenda-day { margin-bottom: 16px; }
diff --git a/frontend/index.html b/frontend/index.html
index 480ebe7..291fad8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -102,6 +102,7 @@
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index e177cb7..081145c 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -3,6 +3,7 @@ import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, date
import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
+import { renderQuarter } from './views/quarter.js';
import { openColorPicker } from './color-picker.js';
import { openDatePicker, formatDtDisplay } from './date-picker.js';
import { t, setLang, getLang } from './i18n.js';
@@ -193,6 +194,10 @@ function getViewRange() {
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(end.getDate() + 1);
+ } else if (state.currentView === 'quarter') {
+ const q = Math.floor(d.getMonth() / 3);
+ start = new Date(d.getFullYear(), q * 3, 1);
+ end = new Date(d.getFullYear(), q * 3 + 3, 1);
} else { // agenda
start = new Date(d);
start.setHours(0, 0, 0, 0);
@@ -232,6 +237,12 @@ function renderView() {
weekStartDay,
state.settings.hour_height || 60
);
+ } else if (state.currentView === 'quarter') {
+ renderQuarter(container, state.currentDate, evs,
+ date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
+ showEventPopup,
+ weekStartDay
+ );
} else {
renderAgenda(container, state.currentDate, evs, showEventPopup);
}
@@ -273,6 +284,9 @@ function updateTitle() {
: `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`;
} else if (state.currentView === 'day') {
title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
+ } else if (state.currentView === 'quarter') {
+ const q = Math.floor(d.getMonth() / 3) + 1;
+ title = `Q${q} ${d.getFullYear()}`;
} else {
title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
}
@@ -610,6 +624,8 @@ function navigate(dir) {
} else if (state.currentView === 'day') {
state.currentDate = new Date(d);
state.currentDate.setDate(d.getDate() + dir);
+ } else if (state.currentView === 'quarter') {
+ state.currentDate = new Date(d.getFullYear(), d.getMonth() + dir * 3, 1);
} else {
state.currentDate = new Date(d);
state.currentDate.setDate(d.getDate() + dir * 30);
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 283b3d3..b57c5dc 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -24,7 +24,7 @@ const translations = {
// Topbar
btn_today: 'Heute',
- view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine',
+ view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_quarter: 'Quartal', view_agenda: 'Termine',
btn_profile: 'Profil', btn_logout: 'Abmelden',
// Sidebar
@@ -218,7 +218,7 @@ const translations = {
// Topbar
btn_today: 'Today',
- view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events',
+ view_month: 'Month', view_week: 'Week', view_day: 'Day', view_quarter: 'Quarter', view_agenda: 'Events',
btn_profile: 'Profile', btn_logout: 'Log out',
// Sidebar
diff --git a/frontend/js/views/quarter.js b/frontend/js/views/quarter.js
new file mode 100644
index 0000000..ffb0bcb
--- /dev/null
+++ b/frontend/js/views/quarter.js
@@ -0,0 +1,113 @@
+import { isToday, isSameDay, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
+import { t } from '../i18n.js';
+
+export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
+ const year = currentDate.getFullYear();
+ // Quarter: Q1=0, Q2=1, Q3=2, Q4=3
+ const quarter = Math.floor(currentDate.getMonth() / 3);
+ const firstMonthOfQ = quarter * 3;
+
+ // Build event map keyed by date string
+ const evMap = {};
+ events.forEach(ev => {
+ const s = new Date(ev.start);
+ const e = new Date(ev.end);
+ const cur = new Date(s);
+ cur.setHours(0, 0, 0, 0);
+ const endNorm = new Date(e);
+ endNorm.setHours(0, 0, 0, 0);
+ if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
+ while (cur <= endNorm) {
+ const key = dateKey(cur);
+ if (!evMap[key]) evMap[key] = [];
+ evMap[key].push(ev);
+ cur.setDate(cur.getDate() + 1);
+ }
+ });
+
+ const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
+ const MONTHS = t('months');
+
+ const monthsHtml = [0, 1, 2].map(offset => {
+ const month = firstMonthOfQ + offset;
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+
+ // Start grid on correct weekday
+ const gridStart = new Date(firstDay);
+ const startOffset = dayOfWeek(firstDay, weekStartDay);
+ gridStart.setDate(gridStart.getDate() - startOffset);
+
+ const cells = [];
+ const d = new Date(gridStart);
+ for (let i = 0; i < 42; i++) {
+ cells.push(new Date(d));
+ d.setDate(d.getDate() + 1);
+ }
+
+ // DOW header
+ const dowHeader = DOW.map(d => `${d}
`).join('');
+
+ // Rows
+ let rowsHtml = '';
+ for (let row = 0; row < 6; row++) {
+ for (let col = 0; col < 7; col++) {
+ const cell = cells[row * 7 + col];
+ const key = dateKey(cell);
+ const cellEvs = evMap[key] || [];
+
+ const isOther = cell.getMonth() !== month;
+ const todayCls = isToday(cell) ? 'today' : '';
+ const otherCls = isOther ? 'other-month' : '';
+ const selCls = isSameDay(cell, currentDate) && !isToday(cell) ? 'selected' : '';
+
+ // Up to 3 event dots
+ const dots = cellEvs.slice(0, 3).map(ev => {
+ const color = ev.color || ev.calendarColor || '#4285f4';
+ const pastCls = isPast(ev) ? 'past' : '';
+ return ``;
+ }).join('');
+ const moreDot = cellEvs.length > 3
+ ? `+${cellEvs.length - 3}`
+ : '';
+
+ rowsHtml += `
+
${cell.getDate()}
+
${dots}${moreDot}
+
`;
+ }
+ }
+
+ return ``;
+ }).join('');
+
+ container.innerHTML = `${monthsHtml}
`;
+
+ // Click handlers
+ container.querySelectorAll('.qtr-cell').forEach(cell => {
+ cell.addEventListener('click', e => {
+ // Check if a dot was clicked
+ const dot = e.target.closest('.qtr-dot');
+ if (dot) {
+ e.stopPropagation();
+ const ev = events.find(ev => ev.id === dot.dataset.id && ev.url === dot.dataset.url);
+ if (ev) { onEventClick(ev, dot); return; }
+ }
+ onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
+ });
+ });
+}
+
+function dateKey(d) {
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
+}
+
+function escAttr(s) {
+ return String(s).replace(/"/g,'"').replace(/'/g,''');
+}
--
2.47.3
From e9a307a20ded3d30568cc88a5c8cfd98eb8b5332 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:15:48 +0200
Subject: [PATCH 009/114] =?UTF-8?q?Fix:=20Quartalsansicht=20=E2=80=93=20zu?=
=?UTF-8?q?f=C3=A4llige=20Today-Markierungen=20behoben,=20Button=20nach=20?=
=?UTF-8?q?links=20verschoben=20Selected-Klasse=20aus=20der=20Quartalsansi?=
=?UTF-8?q?cht=20entfernt=20(war=20visuell=20identisch=20mit=20Today).=20B?=
=?UTF-8?q?utton-Reihenfolge:=20Quartal=20>=20Monat=20>=20Woche=20>=20Tag?=
=?UTF-8?q?=20>=20Termine.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 6 ------
frontend/index.html | 2 +-
frontend/js/views/quarter.js | 11 +++++------
3 files changed, 6 insertions(+), 13 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index cd1214b..01dfafa 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -675,12 +675,6 @@ a { color: var(--primary); text-decoration: none; }
color: #fff;
border-radius: 50%;
}
-.qtr-cell.selected .qtr-day-num {
- background: var(--primary);
- color: #fff;
- border-radius: 50%;
- opacity: .55;
-}
.qtr-day-num {
font-size: 12px;
font-weight: 500;
diff --git a/frontend/index.html b/frontend/index.html
index 291fad8..7bc3514 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -99,10 +99,10 @@
+
-
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 38e0488..8e2ae6a 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -731,12 +731,16 @@ function showEventPopup(ev, anchor) {
// Time
if (ev.allDay) {
- document.getElementById('popup-time').textContent = 'Ganztägig';
+ document.getElementById('popup-time').textContent = t('allday_cap');
} else {
const s = new Date(ev.start);
const e = new Date(ev.end);
- document.getElementById('popup-time').textContent =
- `${fmtDatetime(s)} – ${fmtTime(e)}`;
+ const sameDay = s.getFullYear() === e.getFullYear() &&
+ s.getMonth() === e.getMonth() &&
+ s.getDate() === e.getDate();
+ document.getElementById('popup-time').textContent = sameDay
+ ? `${fmtDatetime(s)} – ${fmtTime(e)}`
+ : `${fmtDatetime(s)} – ${fmtDatetime(e)}`;
}
document.getElementById('popup-location').textContent = ev.location || '';
@@ -759,6 +763,33 @@ function showEventPopup(ev, anchor) {
popup.classList.add('hidden');
openEditEventModal(ev);
};
+
+ // Copy to calendar
+ document.getElementById('popup-copy').onclick = e => {
+ e.stopPropagation();
+ const menu = document.getElementById('popup-copy-menu');
+ if (!menu.classList.contains('hidden')) { menu.classList.add('hidden'); return; }
+ const targets = buildWritableCalendars(ev);
+ if (!targets.length) { showToast('Keine Zielkalender verfügbar', true); return; }
+ menu.innerHTML = `` +
+ targets.map(c =>
+ ``
+ ).join('');
+ menu.classList.remove('hidden');
+ menu.querySelectorAll('.popup-copy-item').forEach(el => {
+ el.addEventListener('click', async ev2 => {
+ ev2.stopPropagation();
+ menu.classList.add('hidden');
+ popup.classList.add('hidden');
+ const cal = targets[parseInt(el.dataset.calIdx)];
+ await copyEventToCalendar(ev, cal);
+ });
+ });
+ };
+
document.getElementById('popup-delete').onclick = async () => {
if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
popup.classList.add('hidden');
@@ -1881,3 +1912,48 @@ function fmtDatetime(d) {
function escHtml(s) {
return String(s).replace(/&/g,'&').replace(//g,'>');
}
+
+function buildWritableCalendars(_excludeEv) {
+ const list = [];
+ let idx = 0;
+ for (const acc of state.accounts) {
+ for (const cal of acc.calendars) {
+ if (cal.sidebar_hidden) continue;
+ list.push({ _idx: idx++, id: cal.id, name: `${acc.name} / ${cal.name}`, color: cal.color || '#4285f4', type: 'caldav' });
+ }
+ }
+ for (const cal of state.localCalendars) {
+ list.push({ _idx: idx++, id: cal.id, name: cal.name, color: cal.color || '#34a853', type: 'local' });
+ }
+ for (const acc of state.googleAccounts) {
+ for (const cal of acc.calendars) {
+ if (cal.sidebar_hidden) continue;
+ list.push({ _idx: idx++, id: cal.id, name: `${acc.email} / ${cal.name}`, color: cal.color || '#4285f4', type: 'google' });
+ }
+ }
+ return list;
+}
+
+async function copyEventToCalendar(ev, cal) {
+ const { title, start, end, allDay, location, description, color } = ev;
+ try {
+ if (cal.type === 'google') {
+ await api.post('/google/events', {
+ calendar_db_id: cal.id, title, start, end, allDay,
+ location: location || '', description: description || '',
+ });
+ } else if (cal.type === 'local') {
+ await api.post('/local/events', {
+ calendar_id: cal.id, title, start, end, allDay,
+ location: location || '', description: description || '', color: color || null,
+ });
+ } else {
+ await api.post('/caldav/events', {
+ calendar_id: cal.id, title, start, end, allDay,
+ location: location || '', description: description || '', color: color || null,
+ });
+ }
+ showToast(t('event_copied'));
+ fetchAndRender(true);
+ } catch (e) { showToast(e.message, true); }
+}
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index b57c5dc..48de8ae 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -142,6 +142,7 @@ const translations = {
error_enter_title: 'Bitte Titel eingeben',
error_enter_date: 'Bitte Datum eingeben',
error_enter_start: 'Bitte Start-Zeit eingeben',
+ copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
event_deleted: 'Termin gelöscht',
@@ -336,6 +337,7 @@ const translations = {
error_enter_title: 'Please enter a title',
error_enter_date: 'Please enter a date',
error_enter_start: 'Please enter a start time',
+ copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
event_deleted: 'Event deleted',
--
2.47.3
From eea150373edb990b2fb59b2fc2477ad19b194717 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:43:34 +0200
Subject: [PATCH 013/114] =?UTF-8?q?Fix:=20CalDAV=20delete/update,=20Copy-M?=
=?UTF-8?q?en=C3=BC-Reset,=20Timezone=20beim=20Kopieren=20-=20caldav=5Fcli?=
=?UTF-8?q?ent:=20client.event()=20=E2=86=92=20caldav.Event()=20mit=20reso?=
=?UTF-8?q?urce.load()=20f=C3=BCr=20update/delete=20(DAVClient=20hat=20kei?=
=?UTF-8?q?ne=20event()-Methode)=20-=20Popup:=20Copy-Men=C3=BC=20wird=20be?=
=?UTF-8?q?im=20=C3=96ffnen=20eines=20neuen=20Events=20immer=20zur=C3=BCck?=
=?UTF-8?q?gesetzt=20-=20copyEventToCalendar:=20start/end=20via=20new=20Da?=
=?UTF-8?q?te().toISOString()=20normalisiert=20=E2=86=92=20verhindert=202h?=
=?UTF-8?q?-Verschiebung=20bei=20Terminen=20ohne=20Timezone-Info?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/caldav_client.py | 5 +++--
frontend/js/calendar.js | 7 ++++++-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/backend/caldav_client.py b/backend/caldav_client.py
index 23c9bdd..49f9ca6 100644
--- a/backend/caldav_client.py
+++ b/backend/caldav_client.py
@@ -211,7 +211,8 @@ def update_event(
url: str, username: str, password: str, event_url: str, data: Dict
):
client = _client(url, username, password)
- resource = client.event(url=event_url)
+ resource = caldav.Event(client=client, url=event_url)
+ resource.load()
raw = resource.data
cal = Calendar.from_ical(raw)
@@ -255,7 +256,7 @@ def update_event(
def delete_event(url: str, username: str, password: str, event_url: str):
client = _client(url, username, password)
- resource = client.event(url=event_url)
+ resource = caldav.Event(client=client, url=event_url)
resource.delete()
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 8e2ae6a..bfa22a3 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -723,6 +723,7 @@ function bindSidebar() {
// ── Event Popup ───────────────────────────────────────────
function showEventPopup(ev, anchor) {
const popup = document.getElementById('popup-event');
+ document.getElementById('popup-copy-menu').classList.add('hidden');
popup.classList.remove('hidden');
const color = ev.color || ev.calendarColor || '#4285f4';
@@ -1935,7 +1936,11 @@ function buildWritableCalendars(_excludeEv) {
}
async function copyEventToCalendar(ev, cal) {
- const { title, start, end, allDay, location, description, color } = ev;
+ const { title, allDay, location, description, color } = ev;
+ // Normalize to UTC ISO string so the backend doesn't misinterpret bare local times
+ const toISO = s => (s && s.length > 10) ? new Date(s).toISOString() : s;
+ const start = allDay ? ev.start : toISO(ev.start);
+ const end = allDay ? ev.end : toISO(ev.end);
try {
if (cal.type === 'google') {
await api.post('/google/events', {
--
2.47.3
From f98ff69a9bf5e291ef874c50d19794b20e3b0be3 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:47:11 +0200
Subject: [PATCH 014/114] =?UTF-8?q?Feature:=20Mehrt=C3=A4gige=20Termine=20?=
=?UTF-8?q?in=20Wochen-/Tagesansicht=20vollst=C3=A4ndig=20anzeigen=20Timed?=
=?UTF-8?q?-Events=20die=20mehrere=20Tage=20=C3=BCberspannen=20werden=20ne?=
=?UTF-8?q?u=20in=20der=20Ganztags-Zeile=20f=C3=BCr=20jeden=20betroffenen?=
=?UTF-8?q?=20Tag=20als=20Bar=20angezeigt=20(am=20Starttag=20mit=20Uhrzeit?=
=?UTF-8?q?).=20Die=20Tagesspalten=20erhalten=20einen=2015%-Farbhintergrun?=
=?UTF-8?q?d=20(col-span-tint)=20um=20die=20Abdeckung=20zu=20visualisieren?=
=?UTF-8?q?.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 7 +++++++
frontend/js/views/week.js | 36 ++++++++++++++++++++++++++++++++----
2 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 939f50b..d1e7920 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -554,11 +554,18 @@ a { color: var(--primary); text-decoration: none; }
.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); }
.allday-cols { display: flex; flex: 1; }
.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; }
+.col-span-tint {
+ position: absolute; inset: 0; pointer-events: none; z-index: 0;
+}
.allday-event {
font-size: 11px; font-weight: 500; padding: 2px 6px;
border-radius: 3px; margin-bottom: 2px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
+.allday-event.multiday-timed {
+ opacity: .88;
+ border-left: 3px solid rgba(255,255,255,.45);
+}
/* Time grid */
.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; }
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index eed4c3f..b62a68e 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -16,8 +16,19 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
}
// Separate all-day and timed events
- const allDayEvs = events.filter(ev => ev.allDay);
- const timedEvs = events.filter(ev => !ev.allDay);
+ const allDayEvs = events.filter(ev => ev.allDay);
+ const timedEvs = events.filter(ev => !ev.allDay);
+ // Multi-day timed events: timed but spanning more than one calendar day
+ const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
+
+ // Returns true if event overlaps any part of the given day
+ function spansDay(ev, day) {
+ const evStart = new Date(ev.start);
+ const evEnd = new Date(ev.end);
+ const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
+ const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0);
+ return evStart < dayEnd && evEnd > dayStart;
+ }
// ── KW Badge ──────────────────────────────────────────
const kwNum = getISOWeekNumber(days[0]);
@@ -43,12 +54,22 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const d = new Date(day); d.setHours(0,0,0,0);
return d >= s && d < e || isSameDay(d, s);
});
- const inner = dayEvs.map(ev => {
+ const allDayHtml = dayEvs.map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
return `${escHtml(ev.title)}
`;
}).join('');
- return `${inner}
`;
+
+ // Multi-day timed events: show in all-day row for every day they span
+ const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => {
+ const color = ev.color || ev.calendarColor || '#4285f4';
+ const isStart = isSameDay(new Date(ev.start), day);
+ const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title;
+ return `${escHtml(label)}
`;
+ }).join('');
+
+ return `${allDayHtml}${spanHtml}
`;
}).join('');
// ── Time column labels ────────────────────────────────
@@ -92,7 +113,14 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`;
}).join('');
+ // Background tint for days covered by multi-day timed events
+ const tintHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => {
+ const color = ev.color || ev.calendarColor || '#4285f4';
+ return ``;
+ }).join('');
+
return `
+ ${tintHtml}
${hourLines}
${evHtml}
`;
--
2.47.3
From 4156bc4413cc9ea9df19bf9ee43f20ef1a067137 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 14:57:57 +0200
Subject: [PATCH 015/114] =?UTF-8?q?Feature:=20Dynamische=20Monatsansicht-L?=
=?UTF-8?q?anes=20+=20spanning=20All-Day-Balken=20in=20Wochenansicht=20mon?=
=?UTF-8?q?th.js:=20MAX=5FLANES=20wird=20jetzt=20aus=20der=20tats=C3=A4chl?=
=?UTF-8?q?ichen=20Container-H=C3=B6he=20berechnet=20(kein=20hartes=20Limi?=
=?UTF-8?q?t=20von=203=20mehr).=20week.js:=20All-Day-Zeile=20verwendet=20j?=
=?UTF-8?q?etzt=20dieselbe=20Overlay-Logik=20wie=20die=20Monatsansicht=20?=
=?UTF-8?q?=E2=80=93=20Termine=20spannen=20als=20einzelner=20Balken=20?=
=?UTF-8?q?=C3=BCber=20mehrere=20Tage.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 31 +++++++------
frontend/js/views/month.js | 12 +++--
frontend/js/views/week.js | 93 +++++++++++++++++++++++++++-----------
3 files changed, 92 insertions(+), 44 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index d1e7920..d5e12aa 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -550,22 +550,25 @@ a { color: var(--primary); text-decoration: none; }
.week-day-header.today .day-name { color: var(--today-color); }
/* All-day row */
-.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; }
-.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); }
-.allday-cols { display: flex; flex: 1; }
-.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; }
-.col-span-tint {
- position: absolute; inset: 0; pointer-events: none; z-index: 0;
-}
-.allday-event {
- font-size: 11px; font-weight: 500; padding: 2px 6px;
- border-radius: 3px; margin-bottom: 2px; cursor: pointer;
+.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
+.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px 6px 0; font-size: 10px; color: var(--text-3); }
+.allday-cols-wrap { flex: 1; position: relative; display: flex; }
+.allday-col-bg { flex: 1; border-left: 1px solid var(--border); }
+.allday-spans-layer { position: absolute; inset: 0; pointer-events: none; }
+.allday-span {
+ position: absolute;
+ height: 18px; line-height: 18px;
+ font-size: 11px; font-weight: 500;
+ padding: 0 6px; border-radius: 3px;
+ cursor: pointer; pointer-events: all;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
-.allday-event.multiday-timed {
- opacity: .88;
- border-left: 3px solid rgba(255,255,255,.45);
-}
+.allday-span:hover { filter: brightness(1.12); }
+.allday-span.past { opacity: .45; }
+.allday-span.continues-left { border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px; }
+.allday-span.continues-right { border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px; }
+.allday-span.multiday-timed { opacity: .88; border-left: 3px solid rgba(255,255,255,.4); }
+.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */
.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; }
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index 0d4db29..201636e 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -1,12 +1,16 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
-const LANE_H = 20; // px per lane (event height 18px + 2px gap)
-const MAX_LANES = 3; // max visible lanes per row
-
-const NUM_ROWS = 5; // rolling view: always 5 weeks
+const LANE_H = 20; // px per lane (event height 18px + 2px gap)
+const DAY_H = 30; // day-number row height
+const NUM_ROWS = 5; // rolling view: always 5 weeks
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
+ // Dynamic lane limit: how many events fit in the actual row height
+ const containerH = container.clientHeight || 600;
+ const headerH = 34; // month-header DOW row
+ const rowH = (containerH - headerH) / NUM_ROWS;
+ const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1);
// "Primary month" = currentDate's month (used for muting other-month days)
const primaryMonth = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index b62a68e..8923671 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -45,33 +45,41 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`;
}).join('');
- // ── All-day row ───────────────────────────────────────
- const alldayCols = days.map(day => {
- const key = dayKey(day);
- const dayEvs = allDayEvs.filter(ev => {
- const s = new Date(ev.start); s.setHours(0,0,0,0);
- const e = new Date(ev.end); e.setHours(0,0,0,0);
- const d = new Date(day); d.setHours(0,0,0,0);
- return d >= s && d < e || isSameDay(d, s);
- });
- const allDayHtml = dayEvs.map(ev => {
- const color = ev.color || ev.calendarColor || '#4285f4';
- return `${escHtml(ev.title)}
`;
- }).join('');
+ // ── All-day row (spanning bars, same logic as month view) ──
+ const ALLDAY_LANE_H = 22;
+ const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
+ const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
+ const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
+ const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
- // Multi-day timed events: show in all-day row for every day they span
- const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => {
- const color = ev.color || ev.calendarColor || '#4285f4';
- const isStart = isSameDay(new Date(ev.start), day);
- const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title;
- return `${escHtml(label)}
`;
- }).join('');
-
- return `${allDayHtml}${spanHtml}
`;
+ const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => {
+ const isMultiTimed = multiDayTimedEvs.includes(ev);
+ const n = days.length;
+ const left = (colStart / n) * 100;
+ const width = ((colEnd - colStart + 1) / n) * 100;
+ const top = lane * ALLDAY_LANE_H + 2;
+ const color = ev.color || ev.calendarColor || '#4285f4';
+ const pastCls = isPast(ev) ? 'past' : '';
+ const multiCls = isMultiTimed ? 'multiday-timed' : '';
+ const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
+ const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
+ const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
+ ? `${fmtTime(new Date(ev.start))} ${ev.title}`
+ : ev.title;
+ return `${escHtml(label)}
`;
}).join('');
+ const alldayBgCols = days.map(day =>
+ ``
+ ).join('');
+
+ const alldayCols = `
+ ${alldayBgCols}
+
${alldaySpanHtml}
+
`;
+
// ── Time column labels ────────────────────────────────
const timeLabels = Array.from({length: 24}, (_, h) =>
`${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}
`
@@ -180,8 +188,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
});
});
- // Click: all-day event
- container.querySelectorAll('.allday-event').forEach(el => {
+ // Click: all-day span
+ container.querySelectorAll('.allday-span').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
@@ -205,6 +213,39 @@ function renderNowLine(container, days, hourH = 60) {
setTimeout(() => renderNowLine(container, days, hourH), 60000);
}
+function layoutWeekAllDay(evs, days) {
+ const items = [];
+ evs.forEach(ev => {
+ let colStart = -1, colEnd = -1;
+ days.forEach((day, i) => {
+ const ds = new Date(day); ds.setHours(0, 0, 0, 0);
+ const de = new Date(day); de.setHours(24, 0, 0, 0);
+ if (new Date(ev.start) < de && new Date(ev.end) > ds) {
+ if (colStart === -1) colStart = i;
+ colEnd = i;
+ }
+ });
+ if (colStart === -1) return;
+ items.push({ ev, colStart, colEnd });
+ });
+
+ // Sort: longer spans first, then by start column
+ items.sort((a, b) =>
+ (b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart
+ );
+
+ // Greedy lane assignment
+ const laneEnds = [];
+ items.forEach(item => {
+ let lane = laneEnds.findIndex(end => item.colStart > end);
+ if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); }
+ item.lane = lane;
+ laneEnds[lane] = item.colEnd;
+ });
+
+ return items;
+}
+
function layoutEvents(events) {
if (!events.length) return [];
--
2.47.3
From 4a2f094a4046c52cc8785773028083e4928b6990 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:08:45 +0200
Subject: [PATCH 016/114] =?UTF-8?q?Fix:=20Ganzt=C3=A4gig-Zeile=20sticky=20?=
=?UTF-8?q?+=20korrekte=20Ausrichtung=20in=20Wochen-/Tagesansicht?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 21 ++++++++++++---------
frontend/js/views/week.js | 16 +++++++++-------
2 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index d5e12aa..ee53534 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -373,8 +373,8 @@ a { color: var(--primary); text-decoration: none; }
background: var(--bg-sidebar);
}
.sidebar-copyright:hover { color: var(--text-1); }
-.main-view { flex: 1; overflow: auto; display: flex; flex-direction: column; }
-#view-container { flex: 1; display: flex; flex-direction: column; }
+.main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
+#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* ── Mini Calendar ──────────────────────────────────────── */
.mini-cal { padding: 12px 16px; }
@@ -452,7 +452,7 @@ a { color: var(--primary); text-decoration: none; }
.cal-item:hover .cal-item-remove { opacity: 1; }
/* ── Month View ─────────────────────────────────────────── */
-.month-view { display: flex; flex-direction: column; height: 100%; }
+.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.month-header {
display: grid; grid-template-columns: 38px repeat(7, 1fr);
border-bottom: 1px solid var(--border); flex-shrink: 0;
@@ -509,11 +509,13 @@ a { color: var(--primary); text-decoration: none; }
.month-more:hover { color: var(--primary); }
/* ── Week / Day Views ───────────────────────────────────── */
-.week-view, .day-view { display: flex; flex-direction: column; height: 100%; }
+.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
+.week-head-sticky {
+ flex-shrink: 0; position: sticky; top: 0; z-index: 10;
+ background: var(--bg-app);
+}
.week-header-row {
display: flex; border-bottom: 1px solid var(--border);
- flex-shrink: 0; background: var(--bg-app);
- position: sticky; top: 0; z-index: 10;
}
/* KW badge in week view header gutter */
.week-kw-badge {
@@ -571,7 +573,7 @@ a { color: var(--primary); text-decoration: none; }
.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */
-.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; }
+.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; }
.week-time-col { width: 56px; flex-shrink: 0; position: relative; }
.time-label {
height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end;
@@ -631,7 +633,8 @@ a { color: var(--primary); text-decoration: none; }
display: flex;
gap: 16px;
padding: 16px;
- height: 100%;
+ flex: 1;
+ min-height: 0;
box-sizing: border-box;
overflow-y: auto;
}
@@ -719,7 +722,7 @@ a { color: var(--primary); text-decoration: none; }
}
/* ── Agenda View ────────────────────────────────────────── */
-.agenda-view { padding: 16px; }
+.agenda-view { padding: 16px; flex: 1; overflow-y: auto; min-height: 0; }
.agenda-day { margin-bottom: 16px; }
.agenda-date {
font-size: 13px; font-weight: 600; color: var(--text-2);
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 8923671..212574a 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -137,13 +137,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const viewClass = isSingleDay ? 'day-view' : 'week-view';
container.innerHTML = `
-
-
-
${t('allday')}
-
${alldayCols}
+
+
+
+
${t('allday')}
+ ${alldayCols}
+
${timeLabels}
--
2.47.3
From 77462263e104c4f99f53f04167fd66f50f8672fd Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:11:13 +0200
Subject: [PATCH 017/114] Fix: Spaltenbreite Zeitraster und Kopfzeile durch
scrollbar-gutter angleichen
---
frontend/css/app.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index ee53534..ccff3e9 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; }
.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */
-.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; }
+.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; scrollbar-gutter: stable; }
.week-time-col { width: 56px; flex-shrink: 0; position: relative; }
.time-label {
height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end;
--
2.47.3
From ecdf8917d6e4c90deb73f47f4a3f348272508a9c Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:13:00 +0200
Subject: [PATCH 018/114] =?UTF-8?q?Fix:=20overflow-y:scroll=20statt=20auto?=
=?UTF-8?q?=20f=C3=BCr=20konsistente=20Spaltenbreite?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index ccff3e9..9cac5c3 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; }
.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */
-.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; min-height: 0; scrollbar-gutter: stable; }
+.week-body { display: flex; flex: 1; overflow-y: scroll; position: relative; min-height: 0; }
.week-time-col { width: 56px; flex-shrink: 0; position: relative; }
.time-label {
height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end;
--
2.47.3
From 0cce4fc721442ca74d954c3e43b88aed2644160a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:15:37 +0200
Subject: [PATCH 019/114] Fix: Kopfzeilen-Breite per JS an Scrollbar-Breite
anpassen
---
frontend/js/views/week.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 212574a..3861d06 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -157,6 +157,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const body = container.querySelector('.week-body');
if (body) body.scrollTop = 8 * hourH - 20;
+ // Align sticky header width to body content width (account for scrollbar)
+ const stickyTop = container.querySelector('.week-head-sticky');
+ if (body && stickyTop) {
+ stickyTop.style.paddingRight = (body.offsetWidth - body.clientWidth) + 'px';
+ }
+
// Render current-time line
renderNowLine(container, days, hourH);
--
2.47.3
From 307ee3c6a9c7e9960a4fb77264796571be95bea9 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Wed, 8 Apr 2026 15:19:16 +0200
Subject: [PATCH 020/114] =?UTF-8?q?Fix:=20Scroll=20auf=20week-view=20verle?=
=?UTF-8?q?gen=20=E2=80=93=20Header=20und=20Zeitraster=20immer=20gleich=20?=
=?UTF-8?q?breit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/css/app.css | 4 ++--
frontend/js/views/week.js | 14 ++++----------
2 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 9cac5c3..ee6ec83 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -509,7 +509,7 @@ a { color: var(--primary); text-decoration: none; }
.month-more:hover { color: var(--primary); }
/* ── Week / Day Views ───────────────────────────────────── */
-.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
+.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: scroll; }
.week-head-sticky {
flex-shrink: 0; position: sticky; top: 0; z-index: 10;
background: var(--bg-app);
@@ -573,7 +573,7 @@ a { color: var(--primary); text-decoration: none; }
.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */
-.week-body { display: flex; flex: 1; overflow-y: scroll; position: relative; min-height: 0; }
+.week-time-area { display: flex; flex-shrink: 0; }
.week-time-col { width: 56px; flex-shrink: 0; position: relative; }
.time-label {
height: var(--hour-h, 60px); display: flex; align-items: flex-start; justify-content: flex-end;
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 3861d06..98de4c4 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -147,21 +147,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
${alldayCols}
-
`;
// Scroll to ~8:00
- const body = container.querySelector('.week-body');
- if (body) body.scrollTop = 8 * hourH - 20;
-
- // Align sticky header width to body content width (account for scrollbar)
- const stickyTop = container.querySelector('.week-head-sticky');
- if (body && stickyTop) {
- stickyTop.style.paddingRight = (body.offsetWidth - body.clientWidth) + 'px';
- }
+ const scrollEl = container.querySelector(`.${viewClass}`);
+ if (scrollEl) scrollEl.scrollTop = 8 * hourH - 20;
// Render current-time line
renderNowLine(container, days, hourH);
@@ -171,7 +165,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
col.addEventListener('click', e => {
if (e.target.closest('.timed-event')) return;
const rect = col.getBoundingClientRect();
- const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0);
+ const y = e.clientY - rect.top + (scrollEl?.scrollTop || 0);
const h = Math.floor(y / hourH);
const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15;
const date = new Date(col.dataset.date + 'T00:00:00');
--
2.47.3
From 62e7fa8be1ec1bbba05c2520a17a4461b4eae3f1 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 8 Apr 2026 21:40:01 +0200
Subject: [PATCH 021/114] =?UTF-8?q?fix:=20Google-Token-Fehler=20wird=20sic?=
=?UTF-8?q?htbar=20gemacht=20und=20dem=20User=20gemeldet=20Wenn=20der=20Ac?=
=?UTF-8?q?cess-Token=20eines=20Google-Accounts=20abl=C3=A4uft=20und=20der?=
=?UTF-8?q?=20Refresh=20fehlschl=C3=A4gt,=20wurde=20die=20leere=20Terminli?=
=?UTF-8?q?ste=20bisher=20still=20zur=C3=BCckgegeben=20(kein=20Log,=20kein?=
=?UTF-8?q?e=20UI-Meldung).=20Jetzt=20wird=20der=20Fehler=20geloggt,=20an?=
=?UTF-8?q?=20den=20Aufrufer=20weitergegeben=20und=20als=20Toast-Meldung?=
=?UTF-8?q?=20im=20Frontend=20angezeigt=20("Token=20abgelaufen=20=E2=80=93?=
=?UTF-8?q?=20bitte=20Konto=20trennen=20und=20neu=20verbinden").=20Das=20E?=
=?UTF-8?q?vents-Endpoint=20gibt=20nun=20{events,=20errors}=20statt=20ein?=
=?UTF-8?q?=20reines=20Array=20zur=C3=BCck;=20das=20Frontend=20extrahiert?=
=?UTF-8?q?=20die=20Events=20entsprechend.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/routers/caldav_router.py | 4 +++-
backend/routers/google_router.py | 5 +++--
frontend/js/calendar.js | 12 +++++++++---
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index a0d61aa..1aa38e4 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -347,13 +347,15 @@ def get_events(
.filter(models.GoogleAccount.user_id == current_user.id)
.all()
)
+ google_errors = []
for g_acc in google_accounts:
try:
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
+ google_errors.append({"email": g_acc.email})
- return all_events
+ return {"events": all_events, "errors": google_errors}
@router.post("/events")
diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py
index d771d61..0a1c443 100644
--- a/backend/routers/google_router.py
+++ b/backend/routers/google_router.py
@@ -373,8 +373,9 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
"""Fetch events from all enabled Google calendars for an account."""
try:
token = _refresh_access_token(account, db)
- except Exception:
- return []
+ except Exception as exc:
+ logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
+ raise
all_events = []
try:
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index bfa22a3..631d012 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -124,7 +124,7 @@ function prefetchIfNeeded(viewStart, viewEnd) {
const from = new Date(eventCache.end);
const to = new Date(eventCache.end.getTime() + PREFETCH_EXT);
api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`)
- .then(evs => { _mergeEvents(evs); eventCache.end = to; })
+ .then(r => { _mergeEvents(r.events || r); eventCache.end = to; })
.catch(() => {})
.finally(() => { eventCache._fwdPending = false; });
}
@@ -134,7 +134,7 @@ function prefetchIfNeeded(viewStart, viewEnd) {
const from = new Date(eventCache.start.getTime() - PREFETCH_EXT);
const to = new Date(eventCache.start);
api.get(`/caldav/events?start=${from.toISOString()}&end=${to.toISOString()}`)
- .then(evs => { _mergeEvents(evs); eventCache.start = from; })
+ .then(r => { _mergeEvents(r.events || r); eventCache.start = from; })
.catch(() => {})
.finally(() => { eventCache._bwdPending = false; });
}
@@ -161,7 +161,13 @@ async function fetchAndRender(force = false) {
showLoading();
try {
- const events = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
+ const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
+ const events = resp.events || resp;
+ if (resp.errors && resp.errors.length) {
+ for (const err of resp.errors) {
+ showToast(`Google (${err.email}): Token abgelaufen – bitte Konto trennen und neu verbinden`, true);
+ }
+ }
eventCache.start = fetchStart;
eventCache.end = fetchEnd;
eventCache.events = events;
--
2.47.3
From f2da15784bc93afd718af2fdb1c6d46eb9dca9d9 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 8 Apr 2026 21:49:24 +0200
Subject: [PATCH 022/114] =?UTF-8?q?fix:=20Wochenkalender-Filter=20und=20pe?=
=?UTF-8?q?r-Kalender=20Fehlerbehandlung=20Der=20Wochenkalender=20von=20Go?=
=?UTF-8?q?ogle=20hat=20locale-spezifische=20IDs=20(z.B.=20de.german#weekn?=
=?UTF-8?q?um@...)=20die=20nicht=20im=20alten=20exakten=20Set-Filter=20gef?=
=?UTF-8?q?angen=20wurden.=20Dadurch=20wurde=20er=20in=20die=20DB=20gespei?=
=?UTF-8?q?chert=20und=20verursachte=20beim=20Event-Abruf=20einen=20API-Fe?=
=?UTF-8?q?hler.=20Da=20der=20try/except=20die=20gesamte=20Kalender-Schlei?=
=?UTF-8?q?fe=20umschloss,=20wurden=20bei=20einem=20einzigen=20fehlerhafte?=
=?UTF-8?q?n=20Kalender=20alle=20anderen=20Events=20ebenfalls=20verloren?=
=?UTF-8?q?=20=E2=80=94=20Ursache=20f=C3=BCr=20keine=20Termine=20trotz=20k?=
=?UTF-8?q?orrektem=20Token.=20-=20=5Fis=5Fsystem=5Fcalendar():=20pr=C3=BC?=
=?UTF-8?q?ft=20jetzt=20auch=20'weeknum'=20als=20Substring=20-=20=5Fsync?=
=?UTF-8?q?=5Fgoogle=5Fcalendars():=20bereinigt=20bereits=20gespeicherte?=
=?UTF-8?q?=20System-Kalender=20-=20get=5Fgoogle=5Fevents():=20try/except?=
=?UTF-8?q?=20ist=20jetzt=20pro=20Kalender,=20nicht=20global?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/routers/google_router.py | 31 ++++++++++++++++++++-----------
1 file changed, 20 insertions(+), 11 deletions(-)
diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py
index 0a1c443..dbcf9c8 100644
--- a/backend/routers/google_router.py
+++ b/backend/routers/google_router.py
@@ -32,6 +32,11 @@ SKIP_GOOGLE_CALENDAR_IDS = {
}
+def _is_system_calendar(cal_id: str) -> bool:
+ """Return True for virtual/system calendars that should be hidden."""
+ return cal_id in SKIP_GOOGLE_CALENDAR_IDS or "weeknum" in cal_id.lower()
+
+
def _google_configured() -> bool:
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
@@ -139,7 +144,7 @@ def _account_dict(a: models.GoogleAccount) -> dict:
"sidebar_hidden": bool(c.sidebar_hidden),
}
for c in a.calendars
- if c.cal_id not in SKIP_GOOGLE_CALENDAR_IDS
+ if not _is_system_calendar(c.cal_id)
],
}
@@ -149,12 +154,16 @@ def _sync_google_calendars(account: models.GoogleAccount, db: Session):
try:
token = _refresh_access_token(account, db)
cal_list = _google_api(token, "/users/me/calendarList")
- existing = {c.cal_id: c for c in account.calendars}
+ # Remove any previously stored system calendars (e.g. locale-specific weeknum variants)
+ for c in list(account.calendars):
+ if _is_system_calendar(c.cal_id):
+ db.delete(c)
+ existing = {c.cal_id: c for c in account.calendars if not _is_system_calendar(c.cal_id)}
for cal in cal_list.get("items", []):
if cal.get("deleted"):
continue
cal_id = cal["id"]
- if cal_id in SKIP_GOOGLE_CALENDAR_IDS:
+ if _is_system_calendar(cal_id):
continue
if cal_id not in existing:
db.add(models.GoogleCalendar(
@@ -378,12 +387,12 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
raise
all_events = []
- try:
- for gcal in account.calendars:
- if not gcal.enabled:
- continue
- if gcal.cal_id in SKIP_GOOGLE_CALENDAR_IDS:
- continue
+ for gcal in account.calendars:
+ if not gcal.enabled:
+ continue
+ if _is_system_calendar(gcal.cal_id):
+ continue
+ try:
events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={
"timeMin": start_dt.isoformat(),
"timeMax": end_dt.isoformat(),
@@ -394,8 +403,8 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
if ev.get("status") == "cancelled":
continue
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
- except Exception as exc:
- logger.error("Error fetching Google Calendar for %s: %s", account.email, exc)
+ except Exception as exc:
+ logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
return all_events
--
2.47.3
From 15c540bd25d020d45fde20cab66e15275ec3af70 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 8 Apr 2026 21:59:41 +0200
Subject: [PATCH 023/114] =?UTF-8?q?fix:=20Kalender-Toggle=20sofort=20wirks?=
=?UTF-8?q?am=20+=20Tint=20f=C3=BCr=20mehrt=C3=A4gige=20Ganztags-Events=20?=
=?UTF-8?q?-=20fetchAndRender(true)=20beim=20Ein-/Ausblenden=20eines=20Kal?=
=?UTF-8?q?enders=20erzwingt=20=20=20einen=20Neu-Abruf=20statt=20Cache-Tre?=
=?UTF-8?q?ffer,=20damit=20die=20=C3=84nderung=20sofort=20sichtbar=20ist?=
=?UTF-8?q?=20-=20Tint-Berechnung=20in=20der=20Wochenansicht=20ber=C3=BCck?=
=?UTF-8?q?sichtigt=20jetzt=20auch=20=20=20mehrt=C3=A4gige=20Ganztags-Even?=
=?UTF-8?q?ts=20(z.B.=20Urlaub),=20nicht=20nur=20mehrt=C3=A4gige=20=20=20T?=
=?UTF-8?q?ermin-Events=20=E2=80=94=20exclusive=20Enddaten=20werden=20dabe?=
=?UTF-8?q?i=20korrekt=20normalisiert?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 2 +-
frontend/js/views/week.js | 13 +++++++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 631d012..6a8c6fb 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -486,7 +486,7 @@ function renderCalendarList() {
if (cal) cal.enabled = cb.checked;
}
}
- fetchAndRender();
+ fetchAndRender(true);
});
});
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 98de4c4..087ed6d 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -20,6 +20,15 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const timedEvs = events.filter(ev => !ev.allDay);
// Multi-day timed events: timed but spanning more than one calendar day
const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
+ // Multi-day all-day events (exclusive end → subtract 1 day before comparing)
+ const multiDayAllDayEvs = allDayEvs.filter(ev => {
+ const s = new Date(ev.start);
+ const e = new Date(ev.end);
+ if (e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
+ return !isSameDay(s, e);
+ });
+ // All events that should generate a column background tint
+ const tintEvs = [...multiDayTimedEvs, ...multiDayAllDayEvs];
// Returns true if event overlaps any part of the given day
function spansDay(ev, day) {
@@ -121,8 +130,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`;
}).join('');
- // Background tint for days covered by multi-day timed events
- const tintHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => {
+ // Background tint for days covered by multi-day events (timed or all-day)
+ const tintHtml = tintEvs.filter(ev => spansDay(ev, day)).map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
return ``;
}).join('');
--
2.47.3
From a362ab21ae3959a08a91b48fe9f7ec463c32cea3 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 8 Apr 2026 22:14:08 +0200
Subject: [PATCH 024/114] =?UTF-8?q?perf/fix:=20Kalender-Toggle=20ohne=20La?=
=?UTF-8?q?descreen=20+=20Mehrfach-Tint=20als=20Verlauf=20Ausblenden:=20Ev?=
=?UTF-8?q?ents=20werden=20sofort=20client-seitig=20aus=20dem=20Cache=20ge?=
=?UTF-8?q?filtert=20(calendar=5Fid-Match),=20kein=20Netzwerkaufruf=20f?=
=?UTF-8?q?=C3=BCr=20die=20Ansicht=20n=C3=B6tig.=20Einblenden:=20fetchAndR?=
=?UTF-8?q?ender(force,=20silent=3Dtrue)=20=C3=BCberspringt=20showLoading(?=
=?UTF-8?q?),=20die=20aktuelle=20Ansicht=20bleibt=20sichtbar=20und=20wird?=
=?UTF-8?q?=20nach=20dem=20Fetch=20aktualisiert.=20Mehrere=20mehrt=C3=A4gi?=
=?UTF-8?q?ge=20Events=20am=20selben=20Tag=20erzeugen=20jetzt=20einen=20ve?=
=?UTF-8?q?rtikalen=20Farbverlauf=20(linear-gradient)=20statt=20gestapelte?=
=?UTF-8?q?r=20Ebenen,=20bei=20denen=20nur=20die=20letzte=20Farbe=20sichtb?=
=?UTF-8?q?ar=20war.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/calendar.js | 23 ++++++++++++++++++++---
frontend/js/views/week.js | 22 ++++++++++++++++++----
2 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 6a8c6fb..5e924d1 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -141,7 +141,7 @@ function prefetchIfNeeded(viewStart, viewEnd) {
}
// ── Data fetching ─────────────────────────────────────────
-async function fetchAndRender(force = false) {
+async function fetchAndRender(force = false, silent = false) {
const { start, end } = getViewRange();
// Cache hit: requested range is fully within what we already have
@@ -159,7 +159,7 @@ async function fetchAndRender(force = false) {
const fetchStart = new Date(start.getTime() - CACHE_BUF);
const fetchEnd = new Date(end.getTime() + CACHE_BUF);
- showLoading();
+ if (!silent) showLoading();
try {
const resp = await api.get(`/caldav/events?start=${fetchStart.toISOString()}&end=${fetchEnd.toISOString()}`);
const events = resp.events || resp;
@@ -460,6 +460,8 @@ function renderCalendarList() {
container.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', async () => {
const source = cb.dataset.source;
+ let cacheCalId = null; // calendar_id value used in cached events
+
if (source === 'caldav') {
const calId = parseInt(cb.dataset.calId);
await api.put(`/caldav/calendars/${calId}`, { enabled: cb.checked });
@@ -468,16 +470,19 @@ function renderCalendarList() {
if (cal.id === calId) cal.enabled = cb.checked;
}
}
+ cacheCalId = calId; // numeric integer in cached events
} else if (source === 'local') {
const calId = parseInt(cb.dataset.calId);
await api.put(`/local/calendars/${calId}`, { enabled: cb.checked });
const cal = state.localCalendars.find(c => c.id === calId);
if (cal) cal.enabled = cb.checked;
+ cacheCalId = `local-${calId}`;
} else if (source === 'ical') {
const subId = parseInt(cb.dataset.subId);
await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked });
const sub = state.icalSubscriptions.find(s => s.id === subId);
if (sub) sub.enabled = cb.checked;
+ cacheCalId = `ical-${subId}`;
} else if (source === 'google') {
const calId = parseInt(cb.dataset.calId);
await api.put(`/google/calendars/${calId}`, { enabled: cb.checked });
@@ -485,8 +490,20 @@ function renderCalendarList() {
const cal = acc.calendars.find(c => c.id === calId);
if (cal) cal.enabled = cb.checked;
}
+ cacheCalId = `google-${calId}`;
+ }
+
+ if (!cb.checked && cacheCalId !== null) {
+ // Hiding: filter from cache instantly, no network call needed
+ eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId);
+ state.events = eventCache.events;
+ renderView();
+ updateTitle();
+ renderMiniCal();
+ } else {
+ // Showing: refetch silently — view stays visible, updates when done
+ fetchAndRender(true, true);
}
- fetchAndRender(true);
});
});
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 087ed6d..d16efe4 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -131,10 +131,24 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
}).join('');
// Background tint for days covered by multi-day events (timed or all-day)
- const tintHtml = tintEvs.filter(ev => spansDay(ev, day)).map(ev => {
- const color = ev.color || ev.calendarColor || '#4285f4';
- return ``;
- }).join('');
+ const dayTintEvs = tintEvs.filter(ev => spansDay(ev, day));
+ const tintHtml = (() => {
+ if (!dayTintEvs.length) return '';
+ const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4');
+ let bg;
+ if (colors.length === 1) {
+ bg = colors[0] + '26';
+ } else {
+ // Vertical gradient bands for multiple overlapping multi-day events
+ const stops = colors.flatMap((c, i) => {
+ const p1 = ((i / colors.length) * 100).toFixed(1);
+ const p2 = (((i + 1) / colors.length) * 100).toFixed(1);
+ return [`${c}26 ${p1}%`, `${c}26 ${p2}%`];
+ }).join(',');
+ bg = `linear-gradient(to bottom,${stops})`;
+ }
+ return ``;
+ })();
return `
${tintHtml}
--
2.47.3
From 7f123de1485ca48ca8f8a7dc84b13e12f7d040eb Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 8 Apr 2026 22:24:05 +0200
Subject: [PATCH 025/114] =?UTF-8?q?fix:=20Tint=20f=C3=BCr=20mehrt=C3=A4gig?=
=?UTF-8?q?e=20Ganztags-Events=20korrekt=20via=20alldayLayout=20Der=20bish?=
=?UTF-8?q?erige=20multiDayAllDayEvs-Filter=20hatte=20einen=20Timezone-Feh?=
=?UTF-8?q?ler=20bei=20der=20Datumsberechnung=20(UTC-Parsing=20vs.=20lokal?=
=?UTF-8?q?e=20Zeit=20in=20UTC+2).=20Neue=20L=C3=B6sung:=20das=20bereits?=
=?UTF-8?q?=20korrekt=20arbeitende=20alldayLayout=20wird=20direkt=20als=20?=
=?UTF-8?q?Quelle=20verwendet.=20Items=20mit=20colEnd=20>=20colStart=20sin?=
=?UTF-8?q?d=20mehrt=C3=A4gig=20=E2=80=94=20die=20Spaltenindizes=20aus=20d?=
=?UTF-8?q?em=20Layout=20ergeben=20den=20Tint-Bereich=20exakt.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/views/week.js | 19 +++++++------------
1 file changed, 7 insertions(+), 12 deletions(-)
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index d16efe4..bc440bc 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -20,15 +20,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const timedEvs = events.filter(ev => !ev.allDay);
// Multi-day timed events: timed but spanning more than one calendar day
const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
- // Multi-day all-day events (exclusive end → subtract 1 day before comparing)
- const multiDayAllDayEvs = allDayEvs.filter(ev => {
- const s = new Date(ev.start);
- const e = new Date(ev.end);
- if (e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
- return !isSameDay(s, e);
- });
- // All events that should generate a column background tint
- const tintEvs = [...multiDayTimedEvs, ...multiDayAllDayEvs];
// Returns true if event overlaps any part of the given day
function spansDay(ev, day) {
@@ -58,6 +49,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const ALLDAY_LANE_H = 22;
const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
+ // Items that span more than one column → used for column background tint
+ const multiDayLayoutItems = alldayLayout.filter(item => item.colEnd > item.colStart);
const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
@@ -95,7 +88,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
).join('');
// ── Day columns ───────────────────────────────────────
- const dayCols = days.map(day => {
+ const dayCols = days.map((day, dayIdx) => {
const key = dayKey(day);
const dayEvs = timedEvs.filter(ev => {
const s = new Date(ev.start);
@@ -130,8 +123,10 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
`;
}).join('');
- // Background tint for days covered by multi-day events (timed or all-day)
- const dayTintEvs = tintEvs.filter(ev => spansDay(ev, day));
+ // Background tint: reuse alldayLayout (proven correct) — colEnd > colStart = multi-day
+ const dayTintEvs = multiDayLayoutItems
+ .filter(item => dayIdx >= item.colStart && dayIdx <= item.colEnd)
+ .map(item => item.ev);
const tintHtml = (() => {
if (!dayTintEvs.length) return '';
const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4');
--
2.47.3
From f9f305b213f47a5f84e42d0bce16e676fdd0185b Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 08:46:43 +0200
Subject: [PATCH 026/114] =?UTF-8?q?feat:=20Home=20Assistant=20Kalender-Int?=
=?UTF-8?q?egration=20+=20Bugfix=20ausgeblendete=20Kalender=20-=20Neue=20I?=
=?UTF-8?q?ntegration:=20Home=20Assistant=20als=20Kalenderquelle=20via=20R?=
=?UTF-8?q?EST-API=20=20=20(GET=20/api/calendars=20+=20GET=20/api/calendar?=
=?UTF-8?q?s/{entity=5Fid})=20-=20Authentifizierung=20per=20Long-Lived=20A?=
=?UTF-8?q?ccess=20Token=20-=20Neues=20Modal=20zum=20Verbinden=20(Name,=20?=
=?UTF-8?q?URL,=20Token)=20mit=20Fehlerbehandlung=20-=20Kalender=20einzeln?=
=?UTF-8?q?=20aktivierbar/deaktivierbar,=20Farbe=20=C3=A4nderbar=20-=20Aus?=
=?UTF-8?q?geblendete=20HA-Kalender=20in=20Einstellungen=20wiederherstellb?=
=?UTF-8?q?ar=20-=20Sync-=20und=20Trennen-Buttons=20in=20den=20Einstellung?=
=?UTF-8?q?en=20-=20Bugfix:=20CalDAV-=20und=20Google-Kalender=20mit=20side?=
=?UTF-8?q?bar=5Fhidden=3Dtrue=20=20=20liefern=20nun=20keine=20Events=20me?=
=?UTF-8?q?hr=20im=20Kalender?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 3 +-
backend/models.py | 32 +++
backend/routers/caldav_router.py | 15 +-
backend/routers/google_router.py | 2 +-
backend/routers/homeassistant_router.py | 268 ++++++++++++++++++++++++
frontend/index.html | 35 ++++
frontend/js/calendar.js | 157 +++++++++++++-
7 files changed, 508 insertions(+), 4 deletions(-)
create mode 100644 backend/routers/homeassistant_router.py
diff --git a/backend/main.py b/backend/main.py
index 534665d..ef673d0 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -12,7 +12,7 @@ from sqlalchemy import text
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
-from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router
+from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
@@ -76,6 +76,7 @@ app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
+app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
diff --git a/backend/models.py b/backend/models.py
index 38cf548..4f85b31 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -30,6 +30,9 @@ class User(Base):
google_accounts = relationship(
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
)
+ homeassistant_accounts = relationship(
+ "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
+ )
class CalDAVAccount(Base):
@@ -176,3 +179,32 @@ class GoogleCalendar(Base):
sidebar_hidden = Column(Boolean, default=False)
account = relationship("GoogleAccount", back_populates="calendars")
+
+
+class HomeAssistantAccount(Base):
+ __tablename__ = "homeassistant_accounts"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ name = Column(String(100), nullable=False)
+ url = Column(String(500), nullable=False)
+ token = Column(Text, nullable=False)
+
+ user = relationship("User", back_populates="homeassistant_accounts")
+ calendars = relationship(
+ "HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan"
+ )
+
+
+class HomeAssistantCalendar(Base):
+ __tablename__ = "homeassistant_calendars"
+
+ id = Column(Integer, primary_key=True, index=True)
+ account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False)
+ entity_id = Column(String(255), nullable=False)
+ name = Column(String(255), nullable=False)
+ color = Column(String(7), nullable=True)
+ enabled = Column(Boolean, default=True)
+ sidebar_hidden = Column(Boolean, default=False)
+
+ account = relationship("HomeAssistantAccount", back_populates="calendars")
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index 1aa38e4..d555487 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -266,7 +266,7 @@ def get_events(
for account in accounts:
for calendar in account.calendars:
- if not calendar.enabled:
+ if not calendar.enabled or calendar.sidebar_hidden:
continue
try:
events = caldav_client.fetch_events(
@@ -355,6 +355,19 @@ def get_events(
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
google_errors.append({"email": g_acc.email})
+ # ── Home Assistant events ─────────────────────────────
+ from routers.homeassistant_router import get_ha_events
+ ha_accounts = (
+ db.query(models.HomeAssistantAccount)
+ .filter(models.HomeAssistantAccount.user_id == current_user.id)
+ .all()
+ )
+ for ha_acc in ha_accounts:
+ try:
+ all_events.extend(get_ha_events(ha_acc, start_dt, end_dt))
+ except Exception as exc:
+ logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
+
return {"events": all_events, "errors": google_errors}
diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py
index dbcf9c8..510090b 100644
--- a/backend/routers/google_router.py
+++ b/backend/routers/google_router.py
@@ -388,7 +388,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
all_events = []
for gcal in account.calendars:
- if not gcal.enabled:
+ if not gcal.enabled or gcal.sidebar_hidden:
continue
if _is_system_calendar(gcal.cal_id):
continue
diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py
new file mode 100644
index 0000000..3bffdf2
--- /dev/null
+++ b/backend/routers/homeassistant_router.py
@@ -0,0 +1,268 @@
+import logging
+from datetime import datetime, timezone
+from typing import Optional
+
+import requests as http_requests
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+import models
+from auth import get_current_user
+from database import get_db
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+HA_DEFAULT_COLOR = "#03a9f4"
+
+
+# ── HA API helpers ────────────────────────────────────────
+
+def _ha_get_calendars(url: str, token: str) -> list:
+ try:
+ resp = http_requests.get(
+ f"{url.rstrip('/')}/api/calendars",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=10,
+ verify=False,
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except http_requests.exceptions.ConnectionError:
+ raise HTTPException(503, "Home Assistant nicht erreichbar")
+ except http_requests.exceptions.Timeout:
+ raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
+ except http_requests.exceptions.HTTPError as e:
+ if e.response is not None and e.response.status_code == 401:
+ raise HTTPException(401, "Ungültiger Access Token")
+ raise HTTPException(502, f"Home Assistant Fehler: {e}")
+
+
+def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list:
+ try:
+ resp = http_requests.get(
+ f"{url.rstrip('/')}/api/calendars/{entity_id}",
+ headers={"Authorization": f"Bearer {token}"},
+ params={"start": start_dt.isoformat(), "end": end_dt.isoformat()},
+ timeout=15,
+ verify=False,
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except http_requests.exceptions.ConnectionError:
+ raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}")
+ except http_requests.exceptions.Timeout:
+ raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}")
+
+
+def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict:
+ start = ev.get("start", {})
+ end = ev.get("end", {})
+ all_day = "date" in start and "dateTime" not in start
+ return {
+ "id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}",
+ "url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}",
+ "title": ev.get("summary", "(Kein Titel)"),
+ "start": start.get("dateTime") or start.get("date", ""),
+ "end": end.get("dateTime") or end.get("date", ""),
+ "allDay": all_day,
+ "location": ev.get("location", ""),
+ "description": ev.get("description", ""),
+ "color": None,
+ "calendar_id": f"homeassistant-{cal_db_id}",
+ "calendar_name": cal_name,
+ "calendarColor": cal_color,
+ "source": "homeassistant",
+ }
+
+
+def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list:
+ all_events = []
+ for cal in account.calendars:
+ if not cal.enabled or cal.sidebar_hidden:
+ continue
+ try:
+ raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt)
+ color = cal.color or HA_DEFAULT_COLOR
+ for ev in raw:
+ all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
+ except Exception as exc:
+ logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
+ return all_events
+
+
+# ── Serialization ─────────────────────────────────────────
+
+def _account_dict(a: models.HomeAssistantAccount) -> dict:
+ return {
+ "id": a.id,
+ "name": a.name,
+ "url": a.url,
+ "calendars": [
+ {
+ "id": c.id,
+ "name": c.name,
+ "entity_id": c.entity_id,
+ "color": c.color or HA_DEFAULT_COLOR,
+ "enabled": c.enabled,
+ "sidebar_hidden": bool(c.sidebar_hidden),
+ }
+ for c in a.calendars
+ ],
+ }
+
+
+# ── Pydantic models ───────────────────────────────────────
+
+class HAAccountCreate(BaseModel):
+ name: str
+ url: str
+ token: str
+
+
+class HACalendarUpdate(BaseModel):
+ enabled: Optional[bool] = None
+ color: Optional[str] = None
+ name: Optional[str] = None
+ sidebar_hidden: Optional[bool] = None
+
+
+# ── Endpoints ─────────────────────────────────────────────
+
+@router.get("/accounts")
+def list_accounts(
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ accounts = (
+ db.query(models.HomeAssistantAccount)
+ .filter(models.HomeAssistantAccount.user_id == current_user.id)
+ .all()
+ )
+ return [_account_dict(a) for a in accounts]
+
+
+@router.post("/accounts")
+def add_account(
+ data: HAAccountCreate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ remote_cals = _ha_get_calendars(data.url, data.token)
+
+ account = models.HomeAssistantAccount(
+ user_id=current_user.id,
+ name=data.name,
+ url=data.url,
+ token=data.token,
+ )
+ db.add(account)
+ db.flush()
+
+ for cal in remote_cals:
+ entity_id = cal.get("entity_id", "")
+ if not entity_id:
+ continue
+ db.add(models.HomeAssistantCalendar(
+ account_id=account.id,
+ entity_id=entity_id,
+ name=cal.get("name") or entity_id,
+ color=None,
+ enabled=True,
+ ))
+
+ db.commit()
+ db.refresh(account)
+ return _account_dict(account)
+
+
+@router.delete("/accounts/{account_id}")
+def delete_account(
+ account_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ acc = (
+ db.query(models.HomeAssistantAccount)
+ .filter(
+ models.HomeAssistantAccount.id == account_id,
+ models.HomeAssistantAccount.user_id == current_user.id,
+ )
+ .first()
+ )
+ if not acc:
+ raise HTTPException(404, "Account not found")
+ db.delete(acc)
+ db.commit()
+ return {"ok": True}
+
+
+@router.post("/accounts/{account_id}/sync")
+def sync_account(
+ account_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ acc = (
+ db.query(models.HomeAssistantAccount)
+ .filter(
+ models.HomeAssistantAccount.id == account_id,
+ models.HomeAssistantAccount.user_id == current_user.id,
+ )
+ .first()
+ )
+ if not acc:
+ raise HTTPException(404, "Account not found")
+
+ remote_cals = _ha_get_calendars(acc.url, acc.token)
+ existing = {c.entity_id: c for c in acc.calendars}
+
+ for cal in remote_cals:
+ entity_id = cal.get("entity_id", "")
+ if not entity_id:
+ continue
+ if entity_id not in existing:
+ db.add(models.HomeAssistantCalendar(
+ account_id=acc.id,
+ entity_id=entity_id,
+ name=cal.get("name") or entity_id,
+ color=None,
+ enabled=True,
+ ))
+ else:
+ existing[entity_id].name = cal.get("name") or entity_id
+
+ db.commit()
+ db.refresh(acc)
+ return _account_dict(acc)
+
+
+@router.put("/calendars/{calendar_id}")
+def update_calendar(
+ calendar_id: int,
+ data: HACalendarUpdate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ cal = (
+ db.query(models.HomeAssistantCalendar)
+ .join(models.HomeAssistantAccount)
+ .filter(
+ models.HomeAssistantCalendar.id == calendar_id,
+ models.HomeAssistantAccount.user_id == current_user.id,
+ )
+ .first()
+ )
+ if not cal:
+ raise HTTPException(404, "Calendar not found")
+ if data.enabled is not None:
+ cal.enabled = data.enabled
+ if data.color is not None:
+ cal.color = data.color
+ if data.name is not None:
+ cal.name = data.name
+ if data.sidebar_hidden is not None:
+ cal.sidebar_hidden = data.sidebar_hidden
+ db.commit()
+ return {"ok": True}
diff --git a/frontend/index.html b/frontend/index.html
index 8b759f3..8497872 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -165,6 +165,7 @@
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) {
--
2.47.3
From e70433a61c6a95f6ff7644770d0c7f9595a96681 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:03:40 +0200
Subject: [PATCH 027/114] fix: Ausgeblendete Kalender sofort aus Event-Cache
entfernen Beim Ausblenden eines Kalenders (sidebar_hidden) wurde
fetchAndRender() ohne force=true aufgerufen, wodurch der Cache nie
invalidiert wurde und die Events weiterhin angezeigt wurden. Jetzt wird der
Cache sofort gefiltert (wie beim Checkbox-Deaktivieren), ohne einen neuen
Netzwerkaufruf.
---
frontend/js/calendar.js | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index b1fa8d5..b33b54a 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -645,6 +645,7 @@ function renderCalendarList() {
btn.addEventListener('click', async e => {
e.stopPropagation();
const source = btn.dataset.source;
+ let cacheCalId = null;
if (source === 'caldav') {
const calId = parseInt(btn.dataset.calId);
await api.put(`/caldav/calendars/${calId}`, { enabled: false, sidebar_hidden: true });
@@ -653,16 +654,19 @@ function renderCalendarList() {
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
}
}
+ cacheCalId = calId;
} else if (source === 'local') {
if (!confirm(t('confirm_delete_local_cal'))) return;
const calId = parseInt(btn.dataset.calId);
await api.delete(`/local/calendars/${calId}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
+ cacheCalId = `local-${calId}`;
} else if (source === 'ical') {
if (!confirm(t('confirm_remove_ical'))) return;
const subId = parseInt(btn.dataset.subId);
await api.delete(`/ical/subscriptions/${subId}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
+ cacheCalId = `ical-${subId}`;
} else if (source === 'google') {
const calId = parseInt(btn.dataset.calId);
await api.put(`/google/calendars/${calId}`, { enabled: false, sidebar_hidden: true });
@@ -671,6 +675,7 @@ function renderCalendarList() {
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
}
}
+ cacheCalId = `google-${calId}`;
} else if (source === 'homeassistant') {
const calId = parseInt(btn.dataset.calId);
await api.put(`/homeassistant/calendars/${calId}`, { enabled: false, sidebar_hidden: true });
@@ -679,9 +684,16 @@ function renderCalendarList() {
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
}
}
+ cacheCalId = `homeassistant-${calId}`;
+ }
+ if (cacheCalId !== null) {
+ eventCache.events = eventCache.events.filter(ev => ev.calendar_id !== cacheCalId);
+ state.events = eventCache.events;
}
renderCalendarList();
- fetchAndRender();
+ renderView();
+ updateTitle();
+ renderMiniCal();
});
});
}
--
2.47.3
From e172386850815373552b7c7f80b9865ad39d7158 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:18:03 +0200
Subject: [PATCH 028/114] =?UTF-8?q?fix:=20Color-Picker-Cursor=20erreicht?=
=?UTF-8?q?=20jetzt=20den=20rechten=20und=20unteren=20Rand=20updateUI=20ve?=
=?UTF-8?q?rwendete=20svCanvas.width=20(HTML-Attribut,=20220px)=20statt=20?=
=?UTF-8?q?der=20tats=C3=A4chlich=20gerenderten=20Breite.=20Wenn=20CSS=20d?=
=?UTF-8?q?en=20Canvas=20gr=C3=B6=C3=9Fer=20rendert,=20stoppte=20der=20Cur?=
=?UTF-8?q?sor=20vor=20dem=20rechten=20Rand.=20Jetzt=20wird=20getBoundingC?=
=?UTF-8?q?lientRect()=20verwendet,=20konsistent=20mit=20handleSV.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/js/color-picker.js | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js
index b41418c..e4ec37b 100644
--- a/frontend/js/color-picker.js
+++ b/frontend/js/color-picker.js
@@ -128,11 +128,14 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
function updateUI() {
const [r, g, b] = hsvToRgb(h, s, v);
const hex = rgbToHex(r, g, b);
+ // Use rendered size so cursor matches the visible palette area
+ const svRect = svCanvas.getBoundingClientRect();
+ const hueRect = hueCanvas.getBoundingClientRect();
// SV cursor position
- svCursor.style.left = (s * svCanvas.width) + 'px';
- svCursor.style.top = ((1 - v) * svCanvas.height) + 'px';
+ svCursor.style.left = (s * svRect.width) + 'px';
+ svCursor.style.top = ((1 - v) * svRect.height) + 'px';
// Hue thumb position
- hueThumb.style.left = (h / 360 * hueCanvas.width) + 'px';
+ hueThumb.style.left = (h / 360 * hueRect.width) + 'px';
// Preview + hex
preview.style.background = hex;
hexInput.value = hex.toUpperCase();
--
2.47.3
From 4ffcd2628e324f11584df6581008c86345e9df9b Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 13 Apr 2026 09:22:42 +0200
Subject: [PATCH 029/114] fix: Color-Picker-Cursor korrekt auf Palette
ausgerichtet Der Cursor war relativ zum .gcp-Container positioniert, aber
ohne den Offset des Canvas innerhalb des Containers (Padding). Jetzt wird die
Canvas-Position via getBoundingClientRect() eingerechnet, sodass der Cursor
exakt auf der Farbpalette bleibt.
---
frontend/js/color-picker.js | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js
index e4ec37b..ce7f88f 100644
--- a/frontend/js/color-picker.js
+++ b/frontend/js/color-picker.js
@@ -128,14 +128,16 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
function updateUI() {
const [r, g, b] = hsvToRgb(h, s, v);
const hex = rgbToHex(r, g, b);
- // Use rendered size so cursor matches the visible palette area
+ // Use rendered rects to position cursor relative to the picker container
const svRect = svCanvas.getBoundingClientRect();
+ const pickerRect = picker.getBoundingClientRect();
const hueRect = hueCanvas.getBoundingClientRect();
- // SV cursor position
- svCursor.style.left = (s * svRect.width) + 'px';
- svCursor.style.top = ((1 - v) * svRect.height) + 'px';
- // Hue thumb position
- hueThumb.style.left = (h / 360 * hueRect.width) + 'px';
+ const hueTrackRect = hueTrack.getBoundingClientRect();
+ // SV cursor: offset canvas position within picker + position within canvas
+ svCursor.style.left = (svRect.left - pickerRect.left + s * svRect.width) + 'px';
+ svCursor.style.top = (svRect.top - pickerRect.top + (1 - v) * svRect.height) + 'px';
+ // Hue thumb: offset canvas position within track + position within canvas
+ hueThumb.style.left = (hueRect.left - hueTrackRect.left + (h / 360) * hueRect.width) + 'px';
// Preview + hex
preview.style.background = hex;
hexInput.value = hex.toUpperCase();
--
2.47.3
From 7c55a6043d59bdd4ea5de4f67e7167d8cbf5082a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 21 Apr 2026 11:02:32 +0200
Subject: [PATCH 030/114] =?UTF-8?q?feat:=20Home=20Assistant=20Benutzername?=
=?UTF-8?q?/Passwort-Authentifizierung=20Erg=C3=A4nzt=20die=20HA-Integrati?=
=?UTF-8?q?on=20um=20Password-Grant=20OAuth2:=20Nutzer=20k=C3=B6nnen=20sic?=
=?UTF-8?q?h=20nun=20wahlweise=20mit=20einem=20Long-Lived=20Token=20oder?=
=?UTF-8?q?=20mit=20Benutzername/Passwort=20anmelden.=20Access=20Tokens=20?=
=?UTF-8?q?werden=20automatisch=20per=20Refresh-Token=20erneuert.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 15 ++++
backend/models.py | 3 +
backend/routers/caldav_router.py | 2 +-
backend/routers/homeassistant_router.py | 103 ++++++++++++++++++++++--
frontend/index.html | 21 +++++
frontend/js/calendar.js | 45 ++++++++++-
6 files changed, 178 insertions(+), 11 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index ef673d0..ca24b29 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -63,6 +63,21 @@ def _migrate():
conn.commit()
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'"))
+ conn.commit()
+ except Exception:
+ pass
+ try:
+ conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT"))
+ conn.commit()
+ except Exception:
+ pass
+ try:
+ conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME"))
+ conn.commit()
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index 4f85b31..4772ef0 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -189,6 +189,9 @@ class HomeAssistantAccount(Base):
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
token = Column(Text, nullable=False)
+ auth_method = Column(String(20), default="token")
+ refresh_token = Column(Text, nullable=True)
+ token_expiry = Column(DateTime, nullable=True)
user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship(
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index d555487..c5f3167 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -364,7 +364,7 @@ def get_events(
)
for ha_acc in ha_accounts:
try:
- all_events.extend(get_ha_events(ha_acc, start_dt, end_dt))
+ all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py
index 3bffdf2..28a3fee 100644
--- a/backend/routers/homeassistant_router.py
+++ b/backend/routers/homeassistant_router.py
@@ -15,6 +15,70 @@ logger = logging.getLogger(__name__)
router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4"
+HA_CLIENT_ID = "http://localhost/"
+
+
+# ── Auth helpers ──────────────────────────────────────────
+
+def _ha_login(url: str, username: str, password: str) -> tuple:
+ """Password grant → (access_token, refresh_token, expires_in)"""
+ try:
+ resp = http_requests.post(
+ f"{url.rstrip('/')}/auth/token",
+ data={
+ "grant_type": "password",
+ "username": username,
+ "password": password,
+ "client_id": HA_CLIENT_ID,
+ },
+ timeout=10,
+ verify=False,
+ )
+ except http_requests.exceptions.ConnectionError:
+ raise HTTPException(503, "Home Assistant nicht erreichbar")
+ except http_requests.exceptions.Timeout:
+ raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
+ if resp.status_code in (400, 401):
+ raise HTTPException(401, "Ungültige Anmeldedaten")
+ resp.raise_for_status()
+ data = resp.json()
+ return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800)
+
+
+def _ha_refresh(url: str, refresh_token: str) -> tuple:
+ """Refresh grant → (access_token, expires_in)"""
+ resp = http_requests.post(
+ f"{url.rstrip('/')}/auth/token",
+ data={
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": HA_CLIENT_ID,
+ },
+ timeout=10,
+ verify=False,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data["access_token"], data.get("expires_in", 1800)
+
+
+def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
+ """Return a valid access token, refreshing if necessary."""
+ if account.auth_method != "password":
+ return account.token # Long-Lived Token läuft nicht ab
+ now = datetime.now(timezone.utc)
+ if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
+ return account.token
+ # Needs refresh
+ try:
+ access_token, expires_in = _ha_refresh(account.url, account.refresh_token)
+ except Exception as exc:
+ logger.error("HA token refresh failed for %s: %s", account.name, exc)
+ raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
+ account.token = access_token
+ account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
+ db.commit()
+ return access_token
# ── HA API helpers ────────────────────────────────────────
@@ -77,13 +141,18 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) ->
}
-def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list:
+def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
all_events = []
+ try:
+ token = _get_valid_token(account, db)
+ except Exception as exc:
+ logger.error("HA token error for %s: %s", account.name, exc)
+ raise
for cal in account.calendars:
if not cal.enabled or cal.sidebar_hidden:
continue
try:
- raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt)
+ raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt)
color = cal.color or HA_DEFAULT_COLOR
for ev in raw:
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
@@ -99,6 +168,7 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
"id": a.id,
"name": a.name,
"url": a.url,
+ "auth_method": a.auth_method or "token",
"calendars": [
{
"id": c.id,
@@ -118,7 +188,9 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
class HAAccountCreate(BaseModel):
name: str
url: str
- token: str
+ token: Optional[str] = None
+ username: Optional[str] = None
+ password: Optional[str] = None
class HACalendarUpdate(BaseModel):
@@ -149,13 +221,31 @@ def add_account(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- remote_cals = _ha_get_calendars(data.url, data.token)
+ now = datetime.now(timezone.utc)
+
+ if data.username and data.password:
+ access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password)
+ auth_method = "password"
+ stored_refresh = refresh_tok
+ token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
+ elif data.token:
+ access_token = data.token
+ auth_method = "token"
+ stored_refresh = None
+ token_expiry = None
+ else:
+ raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich")
+
+ remote_cals = _ha_get_calendars(data.url, access_token)
account = models.HomeAssistantAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
- token=data.token,
+ token=access_token,
+ auth_method=auth_method,
+ refresh_token=stored_refresh,
+ token_expiry=token_expiry,
)
db.add(account)
db.flush()
@@ -215,7 +305,8 @@ def sync_account(
if not acc:
raise HTTPException(404, "Account not found")
- remote_cals = _ha_get_calendars(acc.url, acc.token)
+ token = _get_valid_token(acc, db)
+ remote_cals = _ha_get_calendars(acc.url, token)
existing = {c.entity_id: c for c in acc.calendars}
for cal in remote_cals:
diff --git a/frontend/index.html b/frontend/index.html
index 8497872..b9685bf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -417,9 +417,30 @@
+
+
-
- © 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();
--
2.47.3
From 9a59911156b78ed976a36bb6c2bab8e1859cff2a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Fri, 24 Apr 2026 12:57:38 +0200
Subject: [PATCH 034/114] =?UTF-8?q?feat(ha):=20OAuth=20Authorization-Code-?=
=?UTF-8?q?Flow=20statt=20kaputtem=20Password-Grant=20Home=20Assistant=20u?=
=?UTF-8?q?nterst=C3=BCtzt=20keinen=20Password-Grant=20=E2=80=94=20deshalb?=
=?UTF-8?q?=20kam=20immer=20"Ung=C3=BCltige=20Anmeldedaten",=20egal=20was?=
=?UTF-8?q?=20eingegeben=20wurde.=20Jetzt=20wird=20der=20Nutzer=20nach=20d?=
=?UTF-8?q?emselben=20Muster=20wie=20bei=20Google=20zur=20HA-Login-Seite?=
=?UTF-8?q?=20weitergeleitet,=20meldet=20sich=20dort=20an=20und=20kommt=20?=
=?UTF-8?q?zur=C3=BCck=20zu=20Calendarr.=20=C3=84nderungen:=20-=20Neuer=20?=
=?UTF-8?q?POST=20/api/homeassistant/auth-url=20und=20GET=20/callback=20En?=
=?UTF-8?q?dpoint=20-=20Account=20speichert=20client=5Fid=20f=C3=BCr=20sp?=
=?UTF-8?q?=C3=A4tere=20Token-Refreshes=20-=20Modal:=20"Benutzername/Passw?=
=?UTF-8?q?ort"=20=E2=86=92=20"Mit=20Home=20Assistant=20anmelden"=20-=20Fr?=
=?UTF-8?q?ontend=20behandelt=20=3Fha=5Fconnected=3D1=20/=20=3Fha=5Ferror?=
=?UTF-8?q?=3D...=20nach=20R=C3=BCckkehr=20-=20Version=20v1=20=E2=86=92=20?=
=?UTF-8?q?v2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 5 +
backend/models.py | 1 +
backend/routers/homeassistant_router.py | 193 +++++++++++++++++-------
frontend/index.html | 45 +++---
frontend/js/calendar.js | 103 +++++++++----
frontend/js/version.js | 2 +-
6 files changed, 235 insertions(+), 114 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index ca24b29..bb1327a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -78,6 +78,11 @@ def _migrate():
conn.commit()
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
+ conn.commit()
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index 4772ef0..36fc462 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -192,6 +192,7 @@ class HomeAssistantAccount(Base):
auth_method = Column(String(20), default="token")
refresh_token = Column(Text, nullable=True)
token_expiry = Column(DateTime, nullable=True)
+ client_id = Column(String(500), nullable=True)
user = relationship("User", back_populates="homeassistant_accounts")
calendars = relationship(
diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py
index 77f4e05..e6ef38a 100644
--- a/backend/routers/homeassistant_router.py
+++ b/backend/routers/homeassistant_router.py
@@ -1,9 +1,13 @@
import logging
+import secrets
+import time
from datetime import datetime, timezone
from typing import Optional
+from urllib.parse import urlencode
import requests as http_requests
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -15,44 +19,27 @@ logger = logging.getLogger(__name__)
router = APIRouter()
HA_DEFAULT_COLOR = "#03a9f4"
-HA_CLIENT_ID = "http://localhost/"
+
+# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
+_pending_oauth: dict[str, dict] = {}
+
+
+def _cleanup_pending():
+ now = time.time()
+ for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
+ _pending_oauth.pop(k, None)
# ── Auth helpers ──────────────────────────────────────────
-def _ha_login(url: str, username: str, password: str) -> tuple:
- """Password grant → (access_token, refresh_token, expires_in)"""
- try:
- resp = http_requests.post(
- f"{url.rstrip('/')}/auth/token",
- data={
- "grant_type": "password",
- "username": username,
- "password": password,
- "client_id": HA_CLIENT_ID,
- },
- timeout=10,
- verify=False,
- )
- except http_requests.exceptions.ConnectionError:
- raise HTTPException(503, "Home Assistant nicht erreichbar")
- except http_requests.exceptions.Timeout:
- raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
- if resp.status_code in (400, 401):
- raise HTTPException(400, "Ungültige Anmeldedaten")
- resp.raise_for_status()
- data = resp.json()
- return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800)
-
-
-def _ha_refresh(url: str, refresh_token: str) -> tuple:
+def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
"""Refresh grant → (access_token, expires_in)"""
resp = http_requests.post(
f"{url.rstrip('/')}/auth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
- "client_id": HA_CLIENT_ID,
+ "client_id": client_id,
},
timeout=10,
verify=False,
@@ -64,14 +51,16 @@ def _ha_refresh(url: str, refresh_token: str) -> tuple:
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
"""Return a valid access token, refreshing if necessary."""
- if account.auth_method != "password":
+ if account.auth_method != "oauth":
return account.token # Long-Lived Token läuft nicht ab
now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.token
# Needs refresh
try:
- access_token, expires_in = _ha_refresh(account.url, account.refresh_token)
+ access_token, expires_in = _ha_refresh(
+ account.url, account.refresh_token, account.client_id or ""
+ )
except Exception as exc:
logger.error("HA token refresh failed for %s: %s", account.name, exc)
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
@@ -188,9 +177,14 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
class HAAccountCreate(BaseModel):
name: str
url: str
- token: Optional[str] = None
- username: Optional[str] = None
- password: Optional[str] = None
+ token: str
+
+
+class HAOAuthStart(BaseModel):
+ name: str
+ url: str
+ client_id: str
+ redirect_uri: str
class HACalendarUpdate(BaseModel):
@@ -221,31 +215,17 @@ def add_account(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- now = datetime.now(timezone.utc)
-
- if data.username and data.password:
- access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password)
- auth_method = "password"
- stored_refresh = refresh_tok
- token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
- elif data.token:
- access_token = data.token
- auth_method = "token"
- stored_refresh = None
- token_expiry = None
- else:
- raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich")
-
- remote_cals = _ha_get_calendars(data.url, access_token)
+ """Create a HA account from a Long-Lived Access Token."""
+ remote_cals = _ha_get_calendars(data.url, data.token)
account = models.HomeAssistantAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
- token=access_token,
- auth_method=auth_method,
- refresh_token=stored_refresh,
- token_expiry=token_expiry,
+ token=data.token,
+ auth_method="token",
+ refresh_token=None,
+ token_expiry=None,
)
db.add(account)
db.flush()
@@ -267,6 +247,111 @@ def add_account(
return _account_dict(account)
+@router.post("/auth-url")
+def oauth_start(
+ data: HAOAuthStart,
+ current_user: models.User = Depends(get_current_user),
+):
+ """Start the OAuth flow: store pending state, return HA authorization URL."""
+ _cleanup_pending()
+ state_token = secrets.token_urlsafe(32)
+ _pending_oauth[state_token] = {
+ "user_id": current_user.id,
+ "ha_url": data.url.rstrip('/'),
+ "name": data.name,
+ "client_id": data.client_id,
+ "redirect_uri": data.redirect_uri,
+ "expires": time.time() + 600,
+ }
+ params = {
+ "client_id": data.client_id,
+ "redirect_uri": data.redirect_uri,
+ "state": state_token,
+ "response_type": "code",
+ }
+ return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
+
+
+@router.get("/callback")
+def oauth_callback(
+ request: Request,
+ code: str = Query(""),
+ state: str = Query(""),
+ error: str = Query(""),
+ db: Session = Depends(get_db),
+):
+ """Callback from Home Assistant after user authorization."""
+ if error or not code:
+ return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
+
+ pending = _pending_oauth.pop(state, None)
+ if not pending or pending["expires"] < time.time():
+ return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
+
+ ha_url = pending["ha_url"]
+ client_id = pending["client_id"]
+
+ # Exchange code for tokens
+ try:
+ resp = http_requests.post(
+ f"{ha_url}/auth/token",
+ data={
+ "grant_type": "authorization_code",
+ "code": code,
+ "client_id": client_id,
+ },
+ timeout=15,
+ verify=False,
+ )
+ except Exception as exc:
+ logger.error("HA token exchange connection error: %s", exc)
+ return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
+
+ if resp.status_code != 200:
+ logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
+ return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
+
+ tokens = resp.json()
+ access_token = tokens["access_token"]
+ refresh_token = tokens.get("refresh_token", "")
+ expires_in = tokens.get("expires_in", 1800)
+ now = datetime.now(timezone.utc)
+
+ try:
+ remote_cals = _ha_get_calendars(ha_url, access_token)
+ except HTTPException as exc:
+ logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
+ return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
+
+ account = models.HomeAssistantAccount(
+ user_id=pending["user_id"],
+ name=pending["name"],
+ url=ha_url,
+ token=access_token,
+ auth_method="oauth",
+ refresh_token=refresh_token,
+ token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
+ client_id=client_id,
+ )
+ db.add(account)
+ db.flush()
+
+ for cal in remote_cals:
+ entity_id = cal.get("entity_id", "")
+ if not entity_id:
+ continue
+ db.add(models.HomeAssistantCalendar(
+ account_id=account.id,
+ entity_id=entity_id,
+ name=cal.get("name") or entity_id,
+ color=None,
+ enabled=True,
+ ))
+
+ db.commit()
+ return RedirectResponse(url="/?ha_connected=1", status_code=302)
+
+
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
diff --git a/frontend/index.html b/frontend/index.html
index b327ee9..f202162 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v1
+ Calendarr v2
@@ -71,7 +71,7 @@
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';
--
2.47.3
From 013fb3dbc249645a786801337c475f1baa3e8553 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 29 Apr 2026 17:49:03 +0200
Subject: [PATCH 035/114] =?UTF-8?q?feat:=20Datum-Validierung,=20Monatsausw?=
=?UTF-8?q?ahl,=20CalDAV-Fix,=20wiederkehrende=20Termine=20-=20End-Datum?=
=?UTF-8?q?=20passt=20sich=20automatisch=20an=20wenn=20Start=20ge=C3=A4nde?=
=?UTF-8?q?rt=20wird=20(Duration=20bleibt=20erhalten)=20-=20Erstellen-Butt?=
=?UTF-8?q?on=20nutzt=20den=20aktuell=20angesehenen=20Tag=20statt=20immer?=
=?UTF-8?q?=20heute=20-=20Monatsansicht:=20Einzelklick=20=3D=20Tag=20ausw?=
=?UTF-8?q?=C3=A4hlen,=20Doppelklick=20=3D=20Tagesansicht,=20Rechtsklick?=
=?UTF-8?q?=20=3D=20Kontextmen=C3=BC=20-=20CalDAV=20URL-Matching=20robuste?=
=?UTF-8?q?r=20(Normalisierung,=20Path-Fallback,=20calendar=5Fid=20Paramet?=
=?UTF-8?q?er)=20-=20iCal-Abo-Termine=20sind=20nicht=20mehr=20bearbeitbar?=
=?UTF-8?q?=20(Read-Only-Schutz)=20-=20Wiederkehrende=20Termine=20mit=20RR?=
=?UTF-8?q?ULE-Support=20(t=C3=A4glich/w=C3=B6chentlich/monatlich/j=C3=A4h?=
=?UTF-8?q?rlich/benutzerdefiniert)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/caldav_client.py | 26 +++-
backend/main.py | 6 +
backend/models.py | 1 +
backend/routers/caldav_router.py | 191 +++++++++++++++++++++---
backend/routers/local_router.py | 6 +
frontend/css/app.css | 26 ++++
frontend/index.html | 50 +++++++
frontend/js/calendar.js | 249 +++++++++++++++++++++++++++++--
frontend/js/i18n.js | 18 +++
frontend/js/views/month.js | 36 ++++-
requirements.txt | 1 +
11 files changed, 564 insertions(+), 46 deletions(-)
diff --git a/backend/caldav_client.py b/backend/caldav_client.py
index 49f9ca6..0cacd8b 100644
--- a/backend/caldav_client.py
+++ b/backend/caldav_client.py
@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
import caldav
-from icalendar import Calendar, Event
+from icalendar import Calendar, Event, vRecur
logger = logging.getLogger(__name__)
@@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
location = str(component.get("LOCATION", "") or "")
description = str(component.get("DESCRIPTION", "") or "")
color = str(component.get("X-CALENDARR-COLOR", "") or "")
+ rrule_prop = component.get("RRULE")
+ rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None
dtstart_prop = component.get("DTSTART")
dtend_prop = component.get("DTEND")
@@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
"location": location,
"description": description,
"color": color or None,
+ "rrule": rrule_str,
}
)
except Exception as exc:
@@ -201,6 +204,8 @@ def create_event(
event.add("description", data["description"])
if data.get("color"):
event.add("x-calendarr-color", data["color"])
+ if data.get("rrule"):
+ event.add("rrule", _parse_rrule_str(data["rrule"]))
cal.add_component(event)
cal_obj.save_event(cal.to_ical().decode("utf-8"))
@@ -247,6 +252,11 @@ def update_event(
component["DESCRIPTION"] = data["description"]
if "color" in data:
component["X-CALENDARR-COLOR"] = data["color"]
+ if "rrule" in data:
+ if data["rrule"]:
+ component["RRULE"] = _parse_rrule_str(data["rrule"])
+ elif "RRULE" in component:
+ del component["RRULE"]
new_cal.add_component(component)
@@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str):
resource.delete()
+def _parse_rrule_str(rrule_str: str) -> vRecur:
+ """Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
+ params = {}
+ for part in rrule_str.split(";"):
+ if "=" not in part:
+ continue
+ key, val = part.split("=", 1)
+ if "," in val:
+ params[key] = val.split(",")
+ else:
+ params[key] = val
+ return vRecur(params)
+
+
def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
diff --git a/backend/main.py b/backend/main.py
index bb1327a..c457c27 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -83,6 +83,12 @@ def _migrate():
conn.commit()
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
+ conn.commit()
+ logging.info("Migration: added rrule to local_events")
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index 36fc462..98b65fb 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -112,6 +112,7 @@ class LocalEvent(Base):
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
+ rrule = Column(Text, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events")
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index c5f3167..6ef8806 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -1,9 +1,13 @@
import logging
+from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional
+from urllib.parse import urlparse
+from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
+from sqlalchemy import or_
import caldav_client
import models
@@ -41,6 +45,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
+ rrule: Optional[str] = None
class EventUpdate(BaseModel):
@@ -51,6 +56,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
+ rrule: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict:
@@ -75,16 +81,124 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
}
+def _expand_recurring_local(ev, local_cal, range_start, range_end):
+ """Expand a recurring LocalEvent into individual occurrences within the date range."""
+ results = []
+ try:
+ ev_start_str = ev.start.replace("Z", "+00:00")
+ ev_end_str = ev.end.replace("Z", "+00:00")
+
+ if ev.all_day:
+ ev_start = dt_date.fromisoformat(ev_start_str[:10])
+ ev_end = dt_date.fromisoformat(ev_end_str[:10])
+ duration = ev_end - ev_start
+ rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
+ r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
+ r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
+ occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
+ for occ in occurrences:
+ occ_start = occ.date()
+ occ_end = occ_start + duration
+ results.append({
+ "id": ev.uid,
+ "url": f"local://{ev.uid}",
+ "title": ev.title,
+ "start": occ_start.isoformat(),
+ "end": occ_end.isoformat(),
+ "allDay": True,
+ "location": ev.location or "",
+ "description": ev.description or "",
+ "color": ev.color,
+ "rrule": ev.rrule,
+ "calendar_id": f"local-{local_cal.id}",
+ "calendar_name": local_cal.name,
+ "calendarColor": local_cal.color,
+ "source": "local",
+ })
+ else:
+ ev_start = dt_datetime.fromisoformat(ev_start_str)
+ ev_end = dt_datetime.fromisoformat(ev_end_str)
+ if ev_start.tzinfo is None:
+ ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
+ if ev_end.tzinfo is None:
+ ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
+ duration = ev_end - ev_start
+ rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
+ r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
+ r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
+ if r_start.tzinfo is None:
+ r_start = r_start.replace(tzinfo=dt_timezone.utc)
+ if r_end.tzinfo is None:
+ r_end = r_end.replace(tzinfo=dt_timezone.utc)
+ occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
+ for occ in occurrences:
+ occ_end = occ + duration
+ results.append({
+ "id": ev.uid,
+ "url": f"local://{ev.uid}",
+ "title": ev.title,
+ "start": occ.isoformat(),
+ "end": occ_end.isoformat(),
+ "allDay": False,
+ "location": ev.location or "",
+ "description": ev.description or "",
+ "color": ev.color,
+ "rrule": ev.rrule,
+ "calendar_id": f"local-{local_cal.id}",
+ "calendar_name": local_cal.name,
+ "calendarColor": local_cal.color,
+ "source": "local",
+ })
+ except Exception as exc:
+ logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
+ # Fall back to single event
+ results.append({
+ "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
+ "start": ev.start, "end": ev.end, "allDay": ev.all_day,
+ "location": ev.location or "", "description": ev.description or "",
+ "color": ev.color, "rrule": ev.rrule,
+ "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
+ "calendarColor": local_cal.color, "source": "local",
+ })
+ return results
+
+
+def _normalize_url(url: str) -> str:
+ """Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
+ parsed = urlparse(url)
+ scheme = parsed.scheme.lower()
+ host = (parsed.hostname or '').lower()
+ port = parsed.port
+ if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
+ port = None
+ netloc = f"{host}:{port}" if port else host
+ path = parsed.path.rstrip('/')
+ return f"{scheme}://{netloc}{path}"
+
+
def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]:
+ norm_event = _normalize_url(event_url)
+ # Primary: match against normalized account URL
for acc in accounts:
- if event_url.startswith(acc.url):
+ if norm_event.startswith(_normalize_url(acc.url)):
return acc
- # fallback: check calendar urls
+ # Fallback: match against normalized calendar URLs
for acc in accounts:
for cal in acc.calendars:
- if event_url.startswith(cal.cal_id):
+ if norm_event.startswith(_normalize_url(cal.cal_id)):
+ return acc
+ # Second fallback: path-only matching
+ event_path = urlparse(event_url).path.rstrip('/')
+ for acc in accounts:
+ acc_path = urlparse(acc.url).path.rstrip('/')
+ if acc_path and event_path.startswith(acc_path):
+ return acc
+ for acc in accounts:
+ for cal in acc.calendars:
+ cal_path = urlparse(cal.cal_id).path.rstrip('/')
+ if cal_path and event_path.startswith(cal_path):
return acc
return None
@@ -302,27 +416,35 @@ def get_events(
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == local_cal.id,
- models.LocalEvent.start < end,
- models.LocalEvent.end > start,
+ or_(
+ # Non-recurring events in range
+ (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
+ # Recurring events: always include so we can expand
+ models.LocalEvent.rrule != None,
+ ),
)
.all()
)
for ev in local_events:
- all_events.append({
- "id": ev.uid,
- "url": f"local://{ev.uid}",
- "title": ev.title,
- "start": ev.start,
- "end": ev.end,
- "allDay": ev.all_day,
- "location": ev.location or "",
- "description": ev.description or "",
- "color": ev.color,
- "calendar_id": f"local-{local_cal.id}",
- "calendar_name": local_cal.name,
- "calendarColor": local_cal.color,
- "source": "local",
- })
+ if ev.rrule:
+ all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
+ else:
+ all_events.append({
+ "id": ev.uid,
+ "url": f"local://{ev.uid}",
+ "title": ev.title,
+ "start": ev.start,
+ "end": ev.end,
+ "allDay": ev.all_day,
+ "location": ev.location or "",
+ "description": ev.description or "",
+ "color": ev.color,
+ "rrule": None,
+ "calendar_id": f"local-{local_cal.id}",
+ "calendar_name": local_cal.name,
+ "calendarColor": local_cal.color,
+ "source": "local",
+ })
# ── iCal subscription events ──────────────────────────
ical_subs = (
@@ -403,6 +525,7 @@ def create_event(
"location": data.location,
"description": data.description,
"color": data.color,
+ "rrule": data.rrule,
},
)
return {"uid": uid, "calendar_id": data.calendar_id}
@@ -414,6 +537,7 @@ def create_event(
def update_event(
event_id: str,
event_url: str = Query(...),
+ calendar_id: Optional[int] = Query(None),
data: EventUpdate = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
@@ -423,7 +547,18 @@ def update_event(
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
- account = _find_account_for_event_url(event_url, accounts)
+ account = None
+ if calendar_id is not None:
+ cal = (
+ db.query(models.Calendar)
+ .join(models.CalDAVAccount)
+ .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
+ .first()
+ )
+ if cal:
+ account = next((a for a in accounts if a.id == cal.account_id), None)
+ if not account:
+ account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
@@ -443,6 +578,7 @@ def update_event(
def delete_event(
event_id: str,
event_url: str = Query(...),
+ calendar_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
@@ -451,7 +587,18 @@ def delete_event(
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
- account = _find_account_for_event_url(event_url, accounts)
+ account = None
+ if calendar_id is not None:
+ cal = (
+ db.query(models.Calendar)
+ .join(models.CalDAVAccount)
+ .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
+ .first()
+ )
+ if cal:
+ account = next((a for a in accounts if a.id == cal.account_id), None)
+ if not account:
+ account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index 405dd52..d0b2900 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -32,6 +32,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
+ rrule: Optional[str] = None
class EventUpdate(BaseModel):
@@ -42,6 +43,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
+ rrule: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict:
@@ -64,6 +66,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
+ "rrule": ev.rrule,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
@@ -180,6 +183,7 @@ def create_event(
location=data.location,
description=data.description,
color=data.color,
+ rrule=data.rrule,
)
db.add(ev)
db.commit()
@@ -219,6 +223,8 @@ def update_event(
ev.description = data.description
if data.color is not None:
ev.color = data.color
+ if data.rrule is not None:
+ ev.rrule = data.rrule if data.rrule else None
db.commit()
return {"ok": True}
diff --git a/frontend/css/app.css b/frontend/css/app.css
index ee6ec83..010297b 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -486,6 +486,8 @@ a { color: var(--primary); text-decoration: none; }
.month-col:last-child { border-right: none; }
.month-col:hover { background: var(--bg-hover); }
.month-col.today { background: rgba(66,133,244,.08); }
+.month-col.month-selected { background: var(--primary-dim); }
+.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; }
.month-col.other-month .cell-day { color: var(--text-3); }
.cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2);
@@ -785,6 +787,30 @@ a { color: var(--primary); text-decoration: none; }
padding: 12px 20px; border-top: 1px solid var(--border);
}
+/* ── Recurrence UI ─────────────────────────────────────── */
+.rec-weekdays { display: flex; gap: 4px; margin-top: 8px; }
+.rec-day-btn {
+ width: 36px; height: 36px; border-radius: 50%;
+ border: 1px solid var(--border); background: var(--bg-card);
+ color: var(--text-2); cursor: pointer; font-size: 12px;
+ display: flex; align-items: center; justify-content: center;
+ transition: background var(--transition), color var(--transition);
+}
+.rec-day-btn:hover { background: var(--bg-hover); }
+.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
+
+/* ── Day Context Menu ──────────────────────────────────── */
+.cal-context-menu {
+ position: fixed; z-index: 1000;
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3);
+ min-width: 180px; padding: 4px 0;
+}
+.ctx-item {
+ padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
+}
+.ctx-item:hover { background: var(--bg-hover); }
+
/* ── Event Popup ────────────────────────────────────────── */
.event-popup {
position: fixed; z-index: 600;
diff --git a/frontend/index.html b/frontend/index.html
index f202162..ae02952 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -238,6 +238,56 @@
+
+
+
+
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 5faf66a..186c7f9 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -250,7 +250,23 @@ function renderView() {
if (state.currentView === 'month') {
renderMonth(container, state.currentDate, evs,
- date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
+ (date, action, mouseEvent) => {
+ if (action === 'navigate') {
+ state.currentDate = date;
+ state.currentView = 'day';
+ updateViewButtons();
+ fetchAndRender();
+ } else if (action === 'context') {
+ state.currentDate = date;
+ showDayContextMenu(date, mouseEvent);
+ } else {
+ // 'select' — highlight day without navigating
+ state.currentDate = date;
+ renderMiniCal();
+ renderView();
+ updateTitle();
+ }
+ },
showEventPopup,
weekStartDay
);
@@ -764,7 +780,7 @@ function bindTopbar() {
});
document.getElementById('btn-settings').onclick = openSettingsModal;
- document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
+ document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate);
// Mouse wheel / trackpad scroll navigation – only for month & quarter
let _wheelLast = 0;
@@ -837,6 +853,29 @@ function bindSidebar() {
};
}
+// ── Day Context Menu (month view) ────────────────────────
+function showDayContextMenu(date, mouseEvent) {
+ document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
+
+ const menu = document.createElement('div');
+ menu.className = 'cal-context-menu';
+ menu.innerHTML = `
${t('ctx_create_event')}
`;
+
+ menu.style.left = mouseEvent.clientX + 'px';
+ menu.style.top = mouseEvent.clientY + 'px';
+ document.body.appendChild(menu);
+
+ menu.querySelector('[data-action="create"]').onclick = () => {
+ menu.remove();
+ openNewEventModal(date);
+ };
+
+ const close = (e) => {
+ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); }
+ };
+ setTimeout(() => document.addEventListener('click', close), 0);
+}
+
// ── Event Popup ───────────────────────────────────────────
function showEventPopup(ev, anchor) {
const popup = document.getElementById('popup-event');
@@ -877,6 +916,11 @@ function showEventPopup(ev, anchor) {
popup.style.left = Math.max(8, left) + 'px';
popup.style.top = Math.max(8, top) + 'px';
+ // Hide edit/delete for read-only iCal subscription events
+ const isReadOnly = (ev.source === 'ical');
+ document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : '';
+ document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : '';
+
document.getElementById('popup-edit').onclick = () => {
popup.classList.add('hidden');
openEditEventModal(ev);
@@ -921,7 +965,7 @@ function showEventPopup(ev, anchor) {
const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else {
- await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
+ await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`);
}
showToast(t('event_deleted'));
fetchAndRender(true);
@@ -1005,11 +1049,13 @@ function openNewEventModal(date) {
toggleAlldayFields(false);
populateCalendarSelect(null);
resetColorPicker('');
+ resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden');
openModal('modal-event');
}
function openEditEventModal(ev) {
+ if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; }
state.editingEvent = ev;
state.selectedEventColor = ev.color || '';
@@ -1033,6 +1079,23 @@ function openEditEventModal(ev) {
populateCalendarSelect(ev.calendar_id);
resetColorPicker(ev.color || '');
+
+ // Recurrence
+ const rrule = ev.rrule || '';
+ const recSel = document.getElementById('ev-recurrence');
+ const customPanel = document.getElementById('ev-recurrence-custom');
+ if (!rrule) {
+ recSel.value = '';
+ customPanel.classList.add('hidden');
+ } else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) {
+ recSel.value = rrule;
+ customPanel.classList.add('hidden');
+ } else {
+ recSel.value = 'custom';
+ customPanel.classList.remove('hidden');
+ parseRruleIntoUI(rrule);
+ }
+
document.getElementById('ev-delete').classList.remove('hidden');
openModal('modal-event');
}
@@ -1050,24 +1113,142 @@ function resetColorPicker(color) {
preview.style.background = color || 'var(--primary)';
}
+function buildRruleFromUI() {
+ const sel = document.getElementById('ev-recurrence').value;
+ if (!sel) return null;
+ if (sel !== 'custom') return sel;
+
+ const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1;
+ const freq = document.getElementById('ev-rec-freq').value;
+ let rule = `FREQ=${freq}`;
+ if (interval > 1) rule += `;INTERVAL=${interval}`;
+
+ if (freq === 'WEEKLY') {
+ const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day);
+ if (days.length) rule += `;BYDAY=${days.join(',')}`;
+ }
+
+ const endType = document.getElementById('ev-rec-end-type').value;
+ if (endType === 'count') {
+ rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`;
+ } else if (endType === 'until') {
+ const until = document.getElementById('ev-rec-until').value;
+ if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`;
+ }
+ return rule;
+}
+
+function parseRruleIntoUI(rruleStr) {
+ const parts = {};
+ rruleStr.split(';').forEach(p => {
+ const [k, v] = p.split('=', 2);
+ if (k && v) parts[k] = v;
+ });
+
+ document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1';
+ document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY';
+ document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY');
+
+ // Reset all weekday buttons
+ document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
+ if (parts.BYDAY) {
+ parts.BYDAY.split(',').forEach(day => {
+ const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`);
+ if (btn) btn.classList.add('active');
+ });
+ }
+
+ if (parts.COUNT) {
+ document.getElementById('ev-rec-end-type').value = 'count';
+ document.getElementById('ev-rec-count').value = parts.COUNT;
+ document.getElementById('ev-rec-end-count').classList.remove('hidden');
+ document.getElementById('ev-rec-end-until').classList.add('hidden');
+ } else if (parts.UNTIL) {
+ document.getElementById('ev-rec-end-type').value = 'until';
+ // Parse UNTIL: 20260501T235959Z → 2026-05-01
+ const u = parts.UNTIL.replace('Z', '');
+ const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : '';
+ if (formatted) setDtValue('ev-rec-until', formatted, 'date');
+ document.getElementById('ev-rec-end-count').classList.add('hidden');
+ document.getElementById('ev-rec-end-until').classList.remove('hidden');
+ } else {
+ document.getElementById('ev-rec-end-type').value = 'never';
+ document.getElementById('ev-rec-end-count').classList.add('hidden');
+ document.getElementById('ev-rec-end-until').classList.add('hidden');
+ }
+}
+
+function resetRecurrenceUI() {
+ document.getElementById('ev-recurrence').value = '';
+ document.getElementById('ev-recurrence-custom').classList.add('hidden');
+ document.getElementById('ev-rec-interval').value = '1';
+ document.getElementById('ev-rec-freq').value = 'DAILY';
+ document.getElementById('ev-rec-weekdays').classList.add('hidden');
+ document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active'));
+ document.getElementById('ev-rec-end-type').value = 'never';
+ document.getElementById('ev-rec-end-count').classList.add('hidden');
+ document.getElementById('ev-rec-end-until').classList.add('hidden');
+}
+
function bindEventModal() {
document.getElementById('ev-allday').addEventListener('change', e => {
toggleAlldayFields(e.target.checked);
});
- // Date/time pickers
+ // Date/time pickers with auto-adjustment logic
[
- { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' },
- { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' },
- { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' },
- { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' },
- ].forEach(({ displayId, inputId, mode }) => {
+ { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
+ { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' },
+ { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' },
+ { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' },
+ ].forEach(({ displayId, inputId, mode, role }) => {
const disp = document.getElementById(displayId);
if (!disp) return;
const open = async () => {
const current = document.getElementById(inputId)?.value || '';
- const result = await openDatePicker(disp, current, mode);
- if (result !== null) setDtValue(inputId, result, mode);
+ const oldStart = mode === 'datetime'
+ ? document.getElementById('ev-start').value
+ : document.getElementById('ev-start-date').value;
+ const oldEnd = mode === 'datetime'
+ ? document.getElementById('ev-end').value
+ : document.getElementById('ev-end-date').value;
+
+ const result = await openDatePicker(disp, current, mode);
+ if (result === null) return;
+ setDtValue(inputId, result, mode);
+
+ if (role === 'start') {
+ // Adjust end to maintain duration
+ if (mode === 'datetime') {
+ const os = oldStart ? new Date(oldStart) : null;
+ const oe = oldEnd ? new Date(oldEnd) : null;
+ const ns = new Date(result);
+ const duration = (os && oe && oe > os) ? (oe - os) : 3600000;
+ const ne = new Date(ns.getTime() + duration);
+ setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime');
+ } else {
+ const endVal = document.getElementById('ev-end-date').value;
+ if (!endVal || endVal < result) {
+ setDtValue('ev-end-date', result, 'date');
+ }
+ }
+ } else {
+ // Validate end is not before start
+ if (mode === 'datetime') {
+ const startVal = document.getElementById('ev-start').value;
+ if (startVal && new Date(result) <= new Date(startVal)) {
+ const corrected = new Date(new Date(startVal).getTime() + 3600000);
+ setDtValue('ev-end', toLocalDatetimeInput(corrected), 'datetime');
+ showToast(t('error_end_before_start'), true);
+ }
+ } else {
+ const startVal = document.getElementById('ev-start-date').value;
+ if (startVal && result < startVal) {
+ setDtValue('ev-end-date', startVal, 'date');
+ showToast(t('error_end_before_start'), true);
+ }
+ }
+ }
};
disp.addEventListener('click', open);
disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); });
@@ -1091,6 +1272,41 @@ function bindEventModal() {
}
});
+ // ── Recurrence UI ──────────────────────────────────────
+ const recSel = document.getElementById('ev-recurrence');
+ const customPanel = document.getElementById('ev-recurrence-custom');
+ const recFreq = document.getElementById('ev-rec-freq');
+ const weekdaysDiv = document.getElementById('ev-rec-weekdays');
+ const endTypeSel = document.getElementById('ev-rec-end-type');
+
+ recSel.addEventListener('change', () => {
+ customPanel.classList.toggle('hidden', recSel.value !== 'custom');
+ });
+
+ recFreq.addEventListener('change', () => {
+ weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY');
+ });
+
+ document.querySelectorAll('.rec-day-btn').forEach(btn => {
+ btn.addEventListener('click', () => btn.classList.toggle('active'));
+ });
+
+ endTypeSel.addEventListener('change', () => {
+ document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count');
+ document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until');
+ });
+
+ const untilDisp = document.getElementById('ev-rec-until-display');
+ if (untilDisp) {
+ const openUntil = async () => {
+ const current = document.getElementById('ev-rec-until').value || '';
+ const result = await openDatePicker(untilDisp, current, 'date');
+ if (result !== null) setDtValue('ev-rec-until', result, 'date');
+ };
+ untilDisp.addEventListener('click', openUntil);
+ untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); });
+ }
+
document.getElementById('ev-save').onclick = async () => {
const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast(t('error_enter_title'), true); return; }
@@ -1102,6 +1318,7 @@ function bindEventModal() {
const loc = document.getElementById('ev-location').value.trim();
const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor;
+ const rrule = buildRruleFromUI();
let start, end;
if (allDay) {
@@ -1127,7 +1344,7 @@ function bindEventModal() {
);
} else if (ev.source === 'local') {
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
- { title, start, end, allDay, location: loc, description: desc, color: color || null }
+ { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
);
} else if (ev.source === 'ical') {
const subId = ev.calendar_id.replace('ical-', '');
@@ -1136,8 +1353,8 @@ function bindEventModal() {
);
} else {
await api.put(
- `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`,
- { title, start, end, allDay, location: loc, description: desc, color: color || null }
+ `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,
+ { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
);
}
showToast(t('event_updated'));
@@ -1153,6 +1370,7 @@ function bindEventModal() {
await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
+ rrule: rrule || null,
});
showToast(t('event_created'));
} else {
@@ -1160,6 +1378,7 @@ function bindEventModal() {
await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
+ rrule: rrule || null,
});
showToast(t('event_created'));
}
@@ -1184,7 +1403,7 @@ function bindEventModal() {
const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else {
- await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
+ await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`);
}
showToast(t('event_deleted'));
closeModal('modal-event');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 48de8ae..8400b08 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -142,6 +142,15 @@ const translations = {
error_enter_title: 'Bitte Titel eingeben',
error_enter_date: 'Bitte Datum eingeben',
error_enter_start: 'Bitte Start-Zeit eingeben',
+ error_end_before_start: 'Ende kann nicht vor dem Start liegen',
+ ctx_create_event: 'Neuen Termin erstellen',
+ event_readonly: 'Abonnierte Termine können nicht bearbeitet werden',
+ rec_label: 'Wiederholung',
+ rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
+ rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
+ rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
+ rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
+ rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
@@ -337,6 +346,15 @@ const translations = {
error_enter_title: 'Please enter a title',
error_enter_date: 'Please enter a date',
error_enter_start: 'Please enter a start time',
+ error_end_before_start: 'End cannot be before start',
+ ctx_create_event: 'Create new event',
+ event_readonly: 'Subscribed events cannot be edited',
+ rec_label: 'Recurrence',
+ rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
+ rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
+ rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
+ rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
+ rec_on_date: 'On date', rec_occurrences: 'occurrences',
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index 201636e..72d0e4e 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -1,4 +1,4 @@
-import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
+import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
@@ -124,10 +124,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
rowCells.forEach(cell => {
const key = dateKey(cell);
const isOther = cell.getMonth() !== primaryMonth;
- const todayCls = isToday(cell) ? 'today' : '';
- const otherCls = isOther ? 'other-month' : '';
- const numCls = isToday(cell) ? 'today' : '';
- colsHtml += `
+ const todayCls = isToday(cell) ? 'today' : '';
+ const otherCls = isOther ? 'other-month' : '';
+ const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : '';
+ const numCls = isToday(cell) ? 'today' : '';
+ colsHtml += `
`;
});
@@ -148,6 +149,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
// Click handlers via event delegation on the body
const body = container.querySelector('.month-body');
+
+ // Single click: select day (or handle event / more clicks)
body.addEventListener('click', e => {
// Span event click
const spanEl = e.target.closest('.month-span-event');
@@ -161,13 +164,30 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
const moreEl = e.target.closest('.month-more');
if (moreEl) {
e.stopPropagation();
- onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
+ onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
return;
}
- // Column click → navigate to day view
+ // Column click → select day
const colEl = e.target.closest('.month-col');
if (colEl) {
- onDayClick(new Date(colEl.dataset.date + 'T00:00:00'));
+ onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select');
+ }
+ });
+
+ // Double click: navigate to day view
+ body.addEventListener('dblclick', e => {
+ const colEl = e.target.closest('.month-col');
+ if (colEl && !e.target.closest('.month-span-event')) {
+ onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate');
+ }
+ });
+
+ // Right click: context menu
+ body.addEventListener('contextmenu', e => {
+ const colEl = e.target.closest('.month-col');
+ if (colEl) {
+ e.preventDefault();
+ onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e);
}
});
}
diff --git a/requirements.txt b/requirements.txt
index 22c76ee..d51baa5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,3 +10,4 @@ requests==2.32.3
pyotp==2.9.0
qrcode[pil]==8.0
Pillow==11.0.0
+python-dateutil==2.9.0
--
2.47.3
From 1638c9f631d01558be79dac03642b2f6dbbae878 Mon Sep 17 00:00:00 2001
From: Guido Schmit
Date: Wed, 29 Apr 2026 18:13:12 +0200
Subject: [PATCH 036/114] =?UTF-8?q?fix:=20Runde-2-Fixes=20=E2=80=93=20Mona?=
=?UTF-8?q?tsauswahl,=20CalDAV-Update,=20L=C3=B6sch-Dialog,=20EXDATE=20-?=
=?UTF-8?q?=20Monatsansicht:=20selectedDate=20von=20currentDate=20getrennt?=
=?UTF-8?q?,=20Klick=20verschiebt=20View=20nicht=20mehr=20-=20Selected-Day?=
=?UTF-8?q?=20Styling:=20wei=C3=9Fer=20Text=20auf=20Primary-Hintergrund=20?=
=?UTF-8?q?statt=20nur=20Textfarbe=20-=20Kontextmen=C3=BC:=20--bg-surface?=
=?UTF-8?q?=20statt=20fehlendem=20--bg-card=20-=20CalDAV=20Update/Delete:?=
=?UTF-8?q?=20parent=20Calendar-Objekt=20=C3=BCbergeben=20(behebt=20NoneTy?=
=?UTF-8?q?pe-Fehler)=20-=20HA-Kalender=20im=20Kalender-Selektor=20erg?=
=?UTF-8?q?=C3=A4nzt=20-=20Browser-confirm()=20durch=20styled=20Modal-Dial?=
=?UTF-8?q?og=20ersetzt=20mit=20Serie/Einzeln-Option=20-=20EXDATE-Support:?=
=?UTF-8?q?=20einzelne=20Vorkommen=20wiederkehrender=20Termine=20l=C3=B6sc?=
=?UTF-8?q?hen=20(lokal=20+=20CalDAV)=20-=20Fehlende=20i18n-Keys=20f=C3=BC?=
=?UTF-8?q?r=20L=C3=B6sch-Dialog=20erg=C3=A4nzt=20(DE=20+=20EN)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/caldav_client.py | 31 +++++--
backend/main.py | 7 ++
backend/models.py | 1 +
backend/routers/caldav_router.py | 33 +++++++-
backend/routers/local_router.py | 8 ++
frontend/css/app.css | 6 +-
frontend/index.html | 26 ++++++
frontend/js/calendar.js | 138 ++++++++++++++++++++++++-------
frontend/js/i18n.js | 8 ++
frontend/js/views/month.js | 5 +-
10 files changed, 220 insertions(+), 43 deletions(-)
diff --git a/backend/caldav_client.py b/backend/caldav_client.py
index 0cacd8b..d7a8db2 100644
--- a/backend/caldav_client.py
+++ b/backend/caldav_client.py
@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
import caldav
-from icalendar import Calendar, Event, vRecur
+from icalendar import Calendar, Event, vDatetime, vRecur
logger = logging.getLogger(__name__)
@@ -213,10 +213,15 @@ def create_event(
def update_event(
- url: str, username: str, password: str, event_url: str, data: Dict
+ url: str, username: str, password: str, event_url: str, data: Dict,
+ calendar_url: str = None,
):
client = _client(url, username, password)
- resource = caldav.Event(client=client, url=event_url)
+ if calendar_url:
+ cal_obj = client.calendar(url=calendar_url)
+ resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
+ else:
+ resource = caldav.Event(client=client, url=event_url)
resource.load()
raw = resource.data
@@ -258,15 +263,31 @@ def update_event(
elif "RRULE" in component:
del component["RRULE"]
+ if "exdate" in data and data["exdate"]:
+ # Parse YYYYMMDD string into a proper EXDATE
+ exdate_str = data["exdate"]
+ # Determine if event uses dates or datetimes
+ dtstart_prop = component.get("DTSTART")
+ if dtstart_prop and isinstance(dtstart_prop.dt, date) and not isinstance(dtstart_prop.dt, datetime):
+ exdate_val = date(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]))
+ else:
+ exdate_val = datetime(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]), tzinfo=timezone.utc)
+ component.add("exdate", [exdate_val])
+
new_cal.add_component(component)
resource.data = new_cal.to_ical().decode("utf-8")
resource.save()
-def delete_event(url: str, username: str, password: str, event_url: str):
+def delete_event(url: str, username: str, password: str, event_url: str,
+ calendar_url: str = None):
client = _client(url, username, password)
- resource = caldav.Event(client=client, url=event_url)
+ if calendar_url:
+ cal_obj = client.calendar(url=calendar_url)
+ resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
+ else:
+ resource = caldav.Event(client=client, url=event_url)
resource.delete()
diff --git a/backend/main.py b/backend/main.py
index c457c27..46ccbde 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -90,6 +90,13 @@ def _migrate():
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT"))
+ conn.commit()
+ logging.info("Migration: added exdate to local_events")
+ except Exception:
+ pass
+
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
diff --git a/backend/models.py b/backend/models.py
index 98b65fb..92a57ec 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -113,6 +113,7 @@ class LocalEvent(Base):
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
+ exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
calendar = relationship("LocalCalendar", back_populates="events")
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index 6ef8806..447663f 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -57,6 +57,7 @@ class EventUpdate(BaseModel):
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
+ exdate: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict:
@@ -84,6 +85,13 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
def _expand_recurring_local(ev, local_cal, range_start, range_end):
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
results = []
+ # Parse excluded dates
+ excluded = set()
+ if ev.exdate:
+ for d in ev.exdate.split(","):
+ d = d.strip()
+ if d:
+ excluded.add(d)
try:
ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.replace("Z", "+00:00")
@@ -98,6 +106,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end):
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_start = occ.date()
+ occ_key = occ_start.strftime("%Y%m%d")
+ if occ_key in excluded:
+ continue
occ_end = occ_start + duration
results.append({
"id": ev.uid,
@@ -132,6 +143,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end):
r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
+ occ_key = occ.strftime("%Y%m%d")
+ if occ_key in excluded:
+ continue
occ_end = occ + duration
results.append({
"id": ev.uid,
@@ -548,6 +562,7 @@ def update_event(
.all()
)
account = None
+ cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
@@ -557,8 +572,15 @@ def update_event(
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
+ cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
+ # Try to find the calendar URL for the account
+ if account and not cal_url:
+ for c in account.calendars:
+ if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
+ cal_url = c.cal_id
+ break
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
@@ -568,6 +590,7 @@ def update_event(
account.password,
event_url,
data.model_dump(exclude_none=True) if data else {},
+ calendar_url=cal_url,
)
return {"ok": True}
except Exception as exc:
@@ -588,6 +611,7 @@ def delete_event(
.all()
)
account = None
+ cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
@@ -597,13 +621,20 @@ def delete_event(
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
+ cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
+ if account and not cal_url:
+ for c in account.calendars:
+ if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
+ cal_url = c.cal_id
+ break
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
caldav_client.delete_event(
- account.url, account.username, account.password, event_url
+ account.url, account.username, account.password, event_url,
+ calendar_url=cal_url,
)
return {"ok": True}
except Exception as exc:
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index d0b2900..e7d6b97 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -44,6 +44,7 @@ class EventUpdate(BaseModel):
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
+ exdate: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict:
@@ -67,6 +68,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
+ "exdate": ev.exdate,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
@@ -225,6 +227,12 @@ def update_event(
ev.color = data.color
if data.rrule is not None:
ev.rrule = data.rrule if data.rrule else None
+ if data.exdate is not None:
+ existing = ev.exdate or ""
+ dates = [d for d in existing.split(",") if d]
+ if data.exdate not in dates:
+ dates.append(data.exdate)
+ ev.exdate = ",".join(dates)
db.commit()
return {"ok": True}
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 010297b..0bd114d 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -487,7 +487,7 @@ a { color: var(--primary); text-decoration: none; }
.month-col:hover { background: var(--bg-hover); }
.month-col.today { background: rgba(66,133,244,.08); }
.month-col.month-selected { background: var(--primary-dim); }
-.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; }
+.month-col.month-selected .cell-day { background: var(--primary); color: #fff; font-weight: 700; }
.month-col.other-month .cell-day { color: var(--text-3); }
.cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2);
@@ -802,8 +802,8 @@ a { color: var(--primary); text-decoration: none; }
/* ── Day Context Menu ──────────────────────────────────── */
.cal-context-menu {
position: fixed; z-index: 1000;
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3);
+ background: var(--bg-surface); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.5);
min-width: 180px; padding: 4px 0;
}
.ctx-item {
diff --git a/frontend/index.html b/frontend/index.html
index ae02952..40795b9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -317,6 +317,32 @@
+
+
+
-
+
+
@@ -834,7 +841,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/app.js b/frontend/js/app.js
index b99f093..c33fed0 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -188,3 +188,12 @@ function loadAvatarImage(avatarEl, username) {
// ── Start ─────────────────────────────────────────────────
boot();
+
+// ── Service Worker registration (PWA) ─────────────────────
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
+ console.warn('SW registration failed:', err);
+ });
+ });
+}
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 7b3b41b..1135044 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -820,7 +820,10 @@ function bindTopbar() {
function bindSidebar() {
document.getElementById('sidebar-toggle').onclick = () => {
document.getElementById('sidebar').classList.toggle('collapsed');
+ document.body.classList.toggle('sidebar-open'); // mobile slide-in
};
+ const backdrop = document.getElementById('sidebar-backdrop');
+ if (backdrop) backdrop.onclick = () => document.body.classList.remove('sidebar-open');
// Add calendar dropdown
const addBtn = document.getElementById('btn-add-cal');
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 9d3baa6..b54d089 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v2';
+export const APP_VERSION = 'v3';
diff --git a/frontend/manifest.json b/frontend/manifest.json
new file mode 100644
index 0000000..496cdbb
--- /dev/null
+++ b/frontend/manifest.json
@@ -0,0 +1,30 @@
+{
+ "name": "Calendarr",
+ "short_name": "Calendarr",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0e0e14",
+ "theme_color": "#4285f4",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/frontend/sw.js b/frontend/sw.js
new file mode 100644
index 0000000..81d23ad
--- /dev/null
+++ b/frontend/sw.js
@@ -0,0 +1,86 @@
+// Calendarr Service Worker
+// Cache-first for static assets, network-first for /api/* (graceful offline)
+
+const CACHE_VERSION = 'calendarr-v3';
+const STATIC_ASSETS = [
+ '/',
+ '/index.html',
+ '/manifest.json',
+ '/static/css/app.css',
+ '/static/favicon.svg',
+ '/static/js/app.js',
+ '/static/js/api.js',
+ '/static/js/calendar.js',
+ '/static/js/color-picker.js',
+ '/static/js/date-picker.js',
+ '/static/js/i18n.js',
+ '/static/js/utils.js',
+ '/static/js/version.js',
+ '/static/js/views/agenda.js',
+ '/static/js/views/month.js',
+ '/static/js/views/quarter.js',
+ '/static/js/views/week.js',
+ '/icons/icon-192.png',
+ '/icons/icon-512.png',
+ '/icons/icon.svg',
+];
+
+self.addEventListener('install', event => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then(cache =>
+ // Use addAll with a fallback so a single missing file doesn't abort install
+ Promise.all(
+ STATIC_ASSETS.map(url =>
+ cache.add(url).catch(err => console.warn('[SW] skip', url, err))
+ )
+ )
+ ).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil(
+ caches.keys().then(keys =>
+ Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
+ ).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', event => {
+ const req = event.request;
+ if (req.method !== 'GET') return;
+
+ const url = new URL(req.url);
+
+ // Network-first for API routes — fail silently if offline
+ if (url.pathname.startsWith('/api/')) {
+ event.respondWith(
+ fetch(req).catch(() =>
+ new Response(JSON.stringify({ offline: true }), {
+ status: 503,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ )
+ );
+ return;
+ }
+
+ // Cache-first for everything else (static)
+ event.respondWith(
+ caches.match(req).then(cached => {
+ if (cached) return cached;
+ return fetch(req).then(resp => {
+ // Only cache successful, basic-origin responses
+ if (resp && resp.status === 200 && resp.type === 'basic') {
+ const clone = resp.clone();
+ caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
+ }
+ return resp;
+ }).catch(() => {
+ // Offline fallback for navigation requests
+ if (req.mode === 'navigate') return caches.match('/index.html');
+ return new Response('', { status: 503 });
+ });
+ })
+ );
+});
--
2.47.3
From fdf9af09cdede6ed9d2c51a1a4550081fabf6af4 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 18:52:51 +0200
Subject: [PATCH 057/114] fix(mobile): Zoom blocken, Long-Press, KW-Bubble,
Swipe-Nav, Safe-Area
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Viewport: maximum-scale=1, user-scalable=no — kein Pinch-Zoom mehr
- Profil-Dropdown öffnet wieder: overflow:hidden auf .topbar-right
in der Mobile-Media-Query entfernt (hatte das absolut positionierte
Dropdown abgeschnitten)
- Long-Press auf Kalenderzellen markiert keinen Text mehr:
user-select/touch-callout/tap-highlight in der ganzen Mobile-UI aus
- Long-Press auf Avatar zeigt nicht "Bild speichern":
-webkit-touch-callout:none + pointer-events:none auf
- Kalenderwochen erscheinen als kleine Bubble oben links in jeder
Zeile statt als eigene 38px-Spalte
- Status-Bar-Overlap im Settings-Modal behoben: safe-area-inset-top
auf .settings-page-header und Modal-Header in der Mobile-Media-Query
- Swipe links/rechts auf #view-container navigiert prev/next
(≥60 px, überwiegend horizontal, < 700 ms)
- Version v3 → v4 (auch SW-Cache)
Co-Authored-By: Claude Sonnet 4.6
---
frontend/css/app.css | 60 ++++++++++++++++++++++++++++++++++++++++-
frontend/index.html | 10 +++----
frontend/js/calendar.js | 28 +++++++++++++++++++
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
5 files changed, 94 insertions(+), 8 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index bbca5fb..213afb0 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1270,7 +1270,7 @@ a { color: var(--primary); text-decoration: none; }
}
.topbar-right {
min-width: 0;
- overflow: hidden;
+ /* no overflow:hidden — would clip the user dropdown on tap */
}
.view-switcher {
overflow-x: auto;
@@ -1346,6 +1346,64 @@ a { color: var(--primary); text-decoration: none; }
/* ── Misc safety: prevent overflow on flex topbar items ──── */
.main-view { width: 100%; min-width: 0; }
#view-container { max-width: 100%; overflow-x: hidden; }
+
+ /* ── Long-press / text-selection / image-save fixes ──────── */
+ /* Calendar UI shouldn't be selectable on touch — long-press
+ should reach our handlers, not trigger iOS text selection. */
+ .topbar, .sidebar, .main-view,
+ .month-view, .month-row, .month-col, .cell-day, .month-events-overlay, .month-span-event,
+ .week-view, .day-view, .week-day-col, .week-day-header, .week-allday-row,
+ .quarter-view, .agenda-view,
+ .view-switcher, .view-btn, .btn, .icon-btn, .user-avatar, .user-dropdown, .dropdown-item {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+ /* Faster taps, no double-tap zoom on interactive elements */
+ .icon-btn, .btn, .view-btn, .user-avatar, .month-col, .week-day-col, .dropdown-item,
+ .sidebar-copyright, .impressum-link, .modal-close, [data-modal] {
+ touch-action: manipulation;
+ }
+ /* Avatar long-press: don't show "Save Image" — taps reach parent */
+ .user-avatar img {
+ -webkit-touch-callout: none;
+ pointer-events: none;
+ }
+
+ /* ── Calendar weeks (KW) shown as a small bubble, not a column ── */
+ .month-header {
+ grid-template-columns: repeat(7, 1fr) !important;
+ }
+ .month-kw-header { display: none !important; }
+ .month-row-right {
+ margin-left: 0 !important;
+ }
+ .month-kw-cell {
+ position: absolute;
+ left: 3px; top: 3px;
+ width: auto; height: auto;
+ bottom: auto;
+ padding: 1px 7px;
+ background: var(--bg-active);
+ border: none !important;
+ border-radius: 10px;
+ font-size: 10px; font-weight: 600;
+ color: var(--text-2);
+ z-index: 5;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ /* ── Status-bar safe area inside full-screen modals (PWA) ── */
+ .modal-card .modal-header,
+ .settings-page-header {
+ padding-top: calc(16px + env(safe-area-inset-top, 0px));
+ }
+ .modal-footer,
+ .settings-page-body {
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
+ }
}
/* iOS notch / home-indicator safe areas (PWA standalone) */
diff --git a/frontend/index.html b/frontend/index.html
index 69bce84..9efd076 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,9 +2,9 @@
-
+
- Calendarr v3
+ Calendarr v4
@@ -77,7 +77,7 @@
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',
--
2.47.3
From 264c47fefdc8d5da4164819986ecadc62fc57bbd Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:08:20 +0200
Subject: [PATCH 058/114] fix(mobile): Monatstitel sichtbar, KW-Bubble unten,
Termine mit Text, Long-Press, Settings-Hamburger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- View-Switcher auf Mobile in Popup-Menü ausgelagert (neuer Icon-Button
rechts in der Topbar). Dadurch wird in der Topbar Platz frei für
prev/next + Monatstitel ("Mai 2026" usw.).
- Topbar-Settings-Icon auf Mobile ausgeblendet, dafür neuer
"Einstellungen"-Eintrag im User-Dropdown. "Heute" wandert ins
View-Popup.
- KW-Bubble: von oben-links nach unten-links verschoben — überlappt
jetzt nicht mehr die Tagesnummer.
- Termine in der Monatsansicht zeigen wieder ihren Text (kleinere
14px-Höhe, 9px Schrift) statt nur farbiger Punkte.
- Long-Press auf einen Tag öffnet das Kontextmenü "Termin erstellen"
(synthetisches contextmenu-Event nach 500 ms ohne Bewegung). Der
nachfolgende synthetische Click wird unterdrückt.
- Settings-Modal: Sidebar (Darstellung/Konten/Benutzerverwaltung) auf
Mobile als slide-in Overlay mit Hamburger-Toggle. Auf Desktop bleibt
sie immer sichtbar.
- Version v4 → v5 (auch SW-Cache)
---
frontend/css/app.css | 88 +++++++++++++++++++++++++++++++------
frontend/index.html | 29 +++++++++++--
frontend/js/calendar.js | 96 ++++++++++++++++++++++++++++++++++++++++-
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
5 files changed, 198 insertions(+), 19 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 213afb0..6eefc88 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1218,6 +1218,12 @@ a { color: var(--primary); text-decoration: none; }
/* Backdrop element exists in DOM but is hidden by default on desktop */
.sidebar-backdrop { display: none; }
+/* Mobile-only UI elements: hidden on desktop ───────────── */
+.view-mobile-wrapper { position: relative; display: none; }
+.settings-nav-toggle { display: none; }
+.settings-nav-backdrop { display: none; }
+.dropdown-item-mobile-only { display: none; }
+
@media (max-width: 768px) {
html, body { overflow-x: hidden; max-width: 100vw; }
@@ -1291,16 +1297,14 @@ a { color: var(--primary); text-decoration: none; }
.btn { min-height: 44px; }
.view-switcher .view-btn { min-height: 40px; }
- /* ── Month view: dots instead of full event titles ───────── */
+ /* ── Month view: events keep text, just smaller ──────────── */
.month-span-event {
- height: 6px !important;
- line-height: 0 !important;
- padding: 0 !important;
- border-radius: 3px !important;
- font-size: 0 !important;
- text-overflow: clip !important;
+ height: 14px !important;
+ line-height: 14px !important;
+ padding: 0 4px !important;
+ font-size: 9px !important;
+ font-weight: 500;
}
- .month-events-overlay { gap: 1px; }
.month-more {
font-size: 9px;
padding: 0 2px;
@@ -1381,18 +1385,20 @@ a { color: var(--primary); text-decoration: none; }
}
.month-kw-cell {
position: absolute;
- left: 3px; top: 3px;
+ /* Bottom-left: away from the day-number (top-left) */
+ left: 3px; bottom: 3px;
+ top: auto; right: auto;
width: auto; height: auto;
- bottom: auto;
- padding: 1px 7px;
+ padding: 1px 6px;
background: var(--bg-active);
border: none !important;
border-radius: 10px;
- font-size: 10px; font-weight: 600;
+ font-size: 9px; font-weight: 600;
color: var(--text-2);
z-index: 5;
display: inline-flex;
align-items: center;
+ pointer-events: none;
}
/* ── Status-bar safe area inside full-screen modals (PWA) ── */
@@ -1404,6 +1410,64 @@ a { color: var(--primary); text-decoration: none; }
.settings-page-body {
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
+
+ /* ── Topbar: hide desktop view-switcher + settings, show
+ mobile view-toggle and let the title breathe ──────────── */
+ .topbar .view-switcher { display: none; }
+ .topbar #btn-settings { display: none; }
+ .view-mobile-wrapper { display: inline-block; }
+ .dropdown-item-mobile-only { display: flex; }
+
+ /* Hide "Heute" button on desktop topbar (it's in the view popup now) */
+ .topbar #btn-today { display: none; }
+
+ /* The title is the most important info — let it grow */
+ .topbar-center { flex: 1; min-width: 0; }
+ .topbar-center .view-title {
+ font-size: 17px;
+ font-weight: 500;
+ padding-left: 4px;
+ flex: 1;
+ }
+ .topbar-left { gap: 0; }
+ .topbar-right { gap: 0; }
+
+ /* ── Settings modal: nav becomes a slide-in overlay ──────── */
+ .settings-nav-toggle { display: inline-flex !important; }
+ .settings-page-body { position: relative; }
+ .settings-nav {
+ position: absolute;
+ top: 0; left: 0; bottom: 0;
+ width: min(75vw, 280px);
+ z-index: 50;
+ background: var(--bg-app);
+ border-right: 1px solid var(--border);
+ transform: translateX(-100%);
+ transition: transform .25s ease;
+ box-shadow: var(--shadow-lg);
+ }
+ .settings-page-card.nav-open .settings-nav {
+ transform: translateX(0);
+ }
+ .settings-nav-backdrop {
+ display: block;
+ position: absolute; inset: 0;
+ background: rgba(0,0,0,.5);
+ z-index: 40;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity .2s ease;
+ }
+ .settings-page-card.nav-open .settings-nav-backdrop {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ .settings-panels { padding: 16px; }
+
+ /* Modal headers: tighter on mobile */
+ .modal-header { padding: 12px 16px; }
+ .modal-body { padding: 16px; }
+ .modal-footer { padding: 12px 16px; }
}
/* iOS notch / home-indicator safe areas (PWA standalone) */
diff --git a/frontend/index.html b/frontend/index.html
index 9efd076..4a90364 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v4
+ Calendarr v5
@@ -77,7 +77,7 @@
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',
--
2.47.3
From 2f8fed060055d1b40cc020563c3dc4ba9d1ff809 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:17:26 +0200
Subject: [PATCH 059/114] feat(auth): "Angemeldet bleiben"-Checkbox auf
Login-Screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wenn aktiviert, bekommt der JWT-Token statt der üblichen 7 Tage eine
Lebensdauer von 180 Tagen. Der Token liegt wie bisher in localStorage,
bleibt also bis zum manuellen Löschen / Cookie-Reset gültig.
- backend/routers/auth_router.py: LoginRequest.remember_me, längere
expires_delta beim Token-Erstellen
- index.html: Checkbox unter dem 2FA-Feld
- api.js: login() reicht remember_me als 4. Parameter durch
- app.js: Wert aus #login-remember lesen und mitschicken
- Version v5 → v6
---
backend/routers/auth_router.py | 8 +++++++-
frontend/index.html | 21 ++++++++++++---------
frontend/js/api.js | 4 ++--
frontend/js/app.js | 3 ++-
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
6 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py
index 3f18f7e..98ede29 100644
--- a/backend/routers/auth_router.py
+++ b/backend/routers/auth_router.py
@@ -1,3 +1,4 @@
+from datetime import timedelta
from typing import Optional
import pyotp
@@ -11,6 +12,9 @@ import models
from auth import create_access_token, get_current_user, get_password_hash, verify_password
from database import get_db
+# When "Angemeldet bleiben" is ticked the token lives for half a year.
+REMEMBER_ME_EXPIRY = timedelta(days=180)
+
router = APIRouter()
@@ -24,6 +28,7 @@ class LoginRequest(BaseModel):
username: str
password: str
totp_code: Optional[str] = None
+ remember_me: Optional[bool] = False
def _user_dict(user: models.User) -> dict:
@@ -98,7 +103,8 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)):
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger 2FA-Code",
)
- token = create_access_token({"sub": user.username})
+ expires = REMEMBER_ME_EXPIRY if req.remember_me else None
+ token = create_access_token({"sub": user.username}, expires_delta=expires)
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
diff --git a/frontend/index.html b/frontend/index.html
index 4a90364..6565e70 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v5
+ Calendarr v6
@@ -73,11 +73,14 @@
+
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',
--
2.47.3
From 85d427f9b2e06765204b2598883f061982ee49a5 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Thu, 7 May 2026 19:23:35 +0200
Subject: [PATCH 060/114] =?UTF-8?q?perf:=20Event-Cache=20von=20=C2=B18=20W?=
=?UTF-8?q?ochen=20auf=20=C2=B110=20Monate=20erweitern?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Damit lädt beim Swipen durch Monate erst nach ~10 Monaten in beide
Richtungen erneut Daten nach. Vorher reichte der Cache nur ±2 Monate,
sodass nach 2-3 Wischen ein Spinner kam.
- CACHE_BUF 56 → 300 Tage (initial ±10 Monate)
- PREFETCH_EXT 56 → 180 Tage (Verlängerung bei Edge ~6 Monate)
- PREFETCH_EDGE 28 → 90 Tage (Trigger ~3 Monate vor Cache-Rand)
Version v6 → v7.
---
frontend/index.html | 22 +++++++++++-----------
frontend/js/calendar.js | 6 +++---
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 6565e70..5233a62 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v6
+ Calendarr v7
@@ -80,7 +80,7 @@
Anmelden
- © 2026 Scarriffleservices · v6
+ © 2026 Scarriffleservices · v7
@@ -159,7 +159,7 @@
@@ -235,7 +235,7 @@
@@ -253,7 +253,7 @@
@@ -311,7 +311,7 @@
@@ -372,19 +372,10 @@
@@ -884,7 +889,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index ab9310b..ba49419 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -154,7 +154,7 @@ const translations = {
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
- copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
+ copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
edit_before_copy: 'Vor dem Kopieren bearbeiten',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
@@ -365,7 +365,7 @@ const translations = {
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences',
- copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
+ copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
edit_before_copy: 'Edit before copying',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index ada9b11..0384aba 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v14';
+export const APP_VERSION = 'v15';
diff --git a/frontend/sw.js b/frontend/sw.js
index 2b3878c..aeed36d 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v14';
+const CACHE_VERSION = 'calendarr-v15';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From dc1cb4b57d67c25c11025e329d71403f557f4e7a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 11 May 2026 08:54:20 +0200
Subject: [PATCH 077/114] =?UTF-8?q?fix:=20Popup-Action-Icons=20riesig,=20"?=
=?UTF-8?q?copy"=20als=20Text=20=E2=80=94=20Cache-Robustheit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wenn der Browser noch die alte CSS bzw. i18n.js aus dem Cache hatte,
lief das neu strukturierte Popup ins Leere:
- SVGs ohne CSS-Width-Constraint nahmen die Browser-Standardgröße
(300×150) an → riesige Icons, Layout brach in Vertikalstapel
- Der Key "copy" fehlte in der alten i18n.js → "Kopieren" wurde durch
den Roh-Key "copy" ersetzt
Robust gemacht:
- SVGs der Action-Buttons bekommen jetzt direkt im HTML width="16"
height="16" — funktioniert auch ohne dass die zugehörige CSS-Regel
geladen wurde
- applyLang() in i18n.js fällt bei fehlendem Schlüssel auf den
HTML-Default-Text zurück, anstatt den Key als Text einzuschreiben
(gleiches Prinzip für data-i18n, -i18n-ph, -i18n-title)
Version v15 → v16.
---
frontend/index.html | 24 ++++++++++++------------
frontend/js/i18n.js | 19 +++++++++++++++----
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 29 insertions(+), 18 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 35256fd..e73b5dd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v15
+ Calendarr v16
@@ -80,7 +80,7 @@
Anmelden
- © 2026 Scarriffleservices · v15
+ © 2026 Scarriffleservices · v16
@@ -199,7 +199,7 @@
-
+
@@ -235,7 +235,7 @@
@@ -253,7 +253,7 @@
@@ -311,7 +311,7 @@
@@ -385,15 +385,15 @@
@@ -889,7 +889,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index ba49419..957b180 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -442,15 +442,26 @@ export function t(key, vars = {}) {
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
}
+// Look up a translation but return null if the key is undefined in both
+// the current language and German. Lets callers fall back to the existing
+// HTML default rather than displaying the raw key.
+function tOrNull(key) {
+ const dict = translations[currentLang] ?? translations.de;
+ const val = dict[key] ?? translations.de[key];
+ return typeof val === 'string' ? val : null;
+}
+
export function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(el => {
- const v = t(el.dataset.i18n);
- if (typeof v === 'string') el.textContent = v;
+ const v = tOrNull(el.dataset.i18n);
+ if (v != null) el.textContent = v;
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
- el.placeholder = t(el.dataset.i18nPh);
+ const v = tOrNull(el.dataset.i18nPh);
+ if (v != null) el.placeholder = v;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
- el.title = t(el.dataset.i18nTitle);
+ const v = tOrNull(el.dataset.i18nTitle);
+ if (v != null) el.title = v;
});
}
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 0384aba..15b9c55 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v15';
+export const APP_VERSION = 'v16';
diff --git a/frontend/sw.js b/frontend/sw.js
index aeed36d..344e6a0 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v15';
+const CACHE_VERSION = 'calendarr-v16';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 254adfa12ac47a3d0e26d1518138f88ab73e6339 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Mon, 11 May 2026 09:10:07 +0200
Subject: [PATCH 078/114] =?UTF-8?q?ui:=20Event-Popup-Aktionen=20modernisie?=
=?UTF-8?q?rt=20=E2=80=94=20kompakte=20Icon-Toolbar=20im=20Header?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Das Popup hatte vorher Text+Icon-Buttons in einem Footer mit
verschwendeter vertikaler Höhe. Jetzt:
- Color-Dot + Titel links (volle Breite, kann sauber umbrechen)
- Kompakte 30px-Icon-Toolbar rechts oben: Bearbeiten / Kopieren /
Löschen / Schließen
- Icons im Ruhezustand transparent (nur SVG sichtbar, sehr dezent)
- Auf Hover: runder farbiger Hintergrund. Edit/Copy in Primärfarbe,
Delete in Akzentrot, Close in neutralem bg-hover
- Klick gibt mit Scale-Down (.9) taktilen Feedback
- Popup-Breite leicht erhöht (340 → 360 px) damit Titel + Toolbar
bequem nebeneinander passen
- Trash- und Copy-SVG-Pfade auf den 24x24-viewBox normalisiert
(waren vorher zu lang)
Version v16 → v17.
---
frontend/css/app.css | 122 +++++++++++++++++++----------------------
frontend/index.html | 47 ++++++++--------
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 80 insertions(+), 93 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index b481fd6..d3a38f0 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -975,92 +975,78 @@ a { color: var(--primary); text-decoration: none; }
}
.ctx-item:hover { background: var(--bg-hover); }
-/* ── Event Popup ────────────────────────────────────────── */
+/* ── Event Popup ──────────────────────────────────────────
+ Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
+ Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
+ bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
+ modern und lässt dem Titel die meiste Breite. */
.event-popup {
position: fixed; z-index: 600;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
- width: 340px;
+ width: 360px;
box-shadow: var(--shadow-lg);
}
-/* Close-X tucked into the top-right corner so the title gets full width */
-.popup-close {
- position: absolute;
- top: 8px; right: 8px;
- width: 28px; height: 28px;
- border-radius: 50%;
- display: flex; align-items: center; justify-content: center;
- font-size: 18px; line-height: 1;
- color: var(--text-3);
- background: transparent;
- border: none;
- cursor: pointer;
- z-index: 1;
- transition: background var(--transition), color var(--transition), transform .1s ease;
-}
-.popup-close:hover {
- background: rgba(234,67,53,.18);
- background: color-mix(in srgb, var(--accent) 18%, transparent);
- color: var(--accent);
-}
-.popup-close:active { transform: scale(.92); }
-
.popup-header {
display: flex; align-items: flex-start; gap: 10px;
- padding: 14px 44px 12px 16px; /* right padding leaves room for close-X */
+ padding: 12px 10px 12px 16px;
border-bottom: 1px solid var(--border);
}
.popup-color-dot {
- width: 12px; height: 12px; border-radius: 50%;
+ width: 11px; height: 11px; border-radius: 50%;
flex-shrink: 0;
- margin-top: 5px; /* visually align with the title's first line */
+ margin-top: 6px;
}
.popup-header h4 {
- flex: 1; font-size: 15px; font-weight: 500;
- line-height: 1.35;
+ flex: 1;
+ font-size: 14px; font-weight: 500;
+ line-height: 1.4;
word-break: break-word;
+ padding-top: 2px;
}
+.popup-toolbar {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+ margin-left: 4px;
+}
+.popup-icon-btn {
+ width: 30px; height: 30px;
+ border-radius: 50%;
+ display: inline-flex; align-items: center; justify-content: center;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--text-3);
+ -webkit-tap-highlight-color: transparent;
+ transition:
+ background var(--transition),
+ color var(--transition),
+ transform .1s ease;
+}
+.popup-icon-btn svg { width: 15px; height: 15px; fill: currentColor; flex-shrink: 0; }
+.popup-icon-btn:hover {
+ background: rgba(66,133,244,.16);
+ background: color-mix(in srgb, var(--primary) 16%, transparent);
+ color: var(--primary);
+}
+.popup-icon-btn-danger:hover {
+ background: rgba(234,67,53,.16);
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+ color: var(--accent);
+}
+.popup-icon-btn-close:hover {
+ background: var(--bg-hover);
+ color: var(--text-1);
+}
+.popup-icon-btn:active { transform: scale(.9); }
+
.popup-body { padding: 12px 16px; }
.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; }
-
-/* Footer with the three action buttons, each takes equal width */
-.popup-actions {
- display: flex;
- gap: 4px;
- padding: 8px 10px 10px;
- border-top: 1px solid var(--border);
-}
-.popup-action-btn {
- flex: 1;
- display: inline-flex; align-items: center; justify-content: center;
- gap: 6px;
- padding: 8px 6px;
- border-radius: 8px;
- background: transparent;
- border: none;
- cursor: pointer;
- color: var(--text-2);
- font-size: 12px;
- font-weight: 500;
- -webkit-tap-highlight-color: transparent;
- transition: background var(--transition), color var(--transition), transform .1s ease;
-}
-.popup-action-btn svg { width: 16px; height: 16px; fill: currentColor; }
-.popup-action-btn:hover {
- background: rgba(66,133,244,.14);
- background: color-mix(in srgb, var(--primary) 14%, transparent);
- color: var(--primary);
-}
-.popup-action-btn.popup-action-danger:hover {
- background: rgba(234,67,53,.14);
- background: color-mix(in srgb, var(--accent) 14%, transparent);
- color: var(--accent);
-}
-.popup-action-btn:active { transform: scale(.96); }
.popup-copy-menu {
border-top: 1px solid var(--border);
padding: 4px 0;
@@ -1712,9 +1698,11 @@ a { color: var(--primary); text-decoration: none; }
.topbar-right { gap: 0; }
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */
- .event-popup { width: min(94vw, 360px); max-width: 94vw; }
- .popup-actions { padding: 8px 8px 10px; }
- .popup-action-btn { font-size: 11px; padding: 8px 4px; }
+ .event-popup { width: min(94vw, 380px); max-width: 94vw; }
+ .popup-header { padding: 10px 8px 10px 14px; }
+ .popup-header h4 { font-size: 13.5px; }
+ .popup-icon-btn { width: 32px; height: 32px; }
+ .popup-icon-btn svg { width: 16px; height: 16px; }
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
.month-event-time { display: none; }
diff --git a/frontend/index.html b/frontend/index.html
index e73b5dd..5ee5a49 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
- Calendarr v16
+ Calendarr v17
@@ -80,7 +80,7 @@
Anmelden
- © 2026 Scarriffleservices · v16
+ © 2026 Scarriffleservices · v17
@@ -199,7 +199,7 @@
-
+
@@ -235,7 +235,7 @@
@@ -253,7 +253,7 @@
@@ -311,7 +311,7 @@
@@ -372,10 +372,23 @@
@@ -889,7 +888,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 15b9c55..9b7797e 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v16';
+export const APP_VERSION = 'v17';
diff --git a/frontend/sw.js b/frontend/sw.js
index 344e6a0..7d26e5f 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v16';
+const CACHE_VERSION = 'calendarr-v17';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 8f9eafe56183d4c7cebc462b48706d334ab88d2c Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 09:49:45 +0200
Subject: [PATCH 079/114] feat(settings): Schriftfarbe, Linienfarbe und
Hintergrundfarbe per Color-Picker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Die bisherigen Stufen-Wähler ("Dunkel/Mittel/Hell/Maximum" und
"Kaum/Subtil/Normal/Stark") für Schrift- bzw. Linienkontrast sind durch
echte Hex-Color-Picker ersetzt. Zusätzlich kann jetzt auch die
Hintergrundfarbe der Seite frei gewählt werden.
Wenn ein Override gesetzt ist:
- text_color → setzt --text-1 direkt, --text-2/--text-3 werden
daraus per shadeHex(-0.25 / -0.55) abgeleitet, damit der Hue passt
- line_color → setzt --border, --border-light wird leicht abgedunkelt
- bg_color → setzt --bg-app, daraus werden Topbar/Sidebar/Surface/
Hover/Active per shadeHex(+0.10…+0.40) konsistent hochskaliert
Per "Reset"-Knopf wird der Override geleert und die alte Stufen-Logik
(falls noch vorhanden) bzw. der Default-Theme greift wieder.
Backend:
- 3 neue nullable VARCHAR(7)-Spalten in user_settings (text_color,
line_color, bg_color) inkl. Migrationen in main.py
- settings_router nutzt model_dump(exclude_unset=True) und respektiert
explizite null-Werte nur für diese 3 Override-Felder, damit Reset
funktioniert
Auch enthalten: Auflösen der Merge-Konflikte in sw.js, index.html,
version.js (HEAD-Stand v17 behalten) und Bump auf v18.
---
backend/main.py | 18 ++++++
backend/models.py | 3 +
backend/routers/settings_router.py | 18 +++++-
frontend/index.html | 96 +++++++++---------------------
frontend/js/calendar.js | 54 ++++++++++++++++-
frontend/js/i18n.js | 8 +++
frontend/js/utils.js | 68 ++++++++++++++++++---
frontend/js/version.js | 6 +-
frontend/sw.js | 86 +-------------------------
9 files changed, 187 insertions(+), 170 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 5225ef6..3312035 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -114,6 +114,24 @@ def _migrate():
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
+ conn.commit()
+ except Exception:
+ pass
+
+ try:
+ conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
+ conn.commit()
+ except Exception:
+ pass
+
+ try:
+ conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
+ conn.commit()
+ except Exception:
+ pass
+
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
diff --git a/backend/models.py b/backend/models.py
index 1892076..18f6cc7 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -84,6 +84,9 @@ class UserSettings(Base):
language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0")
+ text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
+ line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
+ bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
user = relationship("User", back_populates="settings")
diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py
index a82ecb0..b5c639d 100644
--- a/backend/routers/settings_router.py
+++ b/backend/routers/settings_router.py
@@ -24,6 +24,9 @@ class SettingsUpdate(BaseModel):
language: Optional[str] = None
month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None
+ text_color: Optional[str] = None
+ line_color: Optional[str] = None
+ bg_color: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict:
@@ -40,6 +43,9 @@ def _settings_dict(s: models.UserSettings) -> dict:
"language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0",
+ "text_color": s.text_color,
+ "line_color": s.line_color,
+ "bg_color": s.bg_color,
}
@@ -76,8 +82,16 @@ def update_settings(
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
- for field, value in data.model_dump(exclude_none=True).items():
- setattr(settings, field, value)
+ # For these three override colours, an explicit null is meaningful
+ # ("reset to default") and must be persisted as NULL. All other fields
+ # keep the previous behaviour where a null/missing value is ignored.
+ NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"}
+ update_data = data.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ if field in NULLABLE_OVERRIDES:
+ setattr(settings, field, value or None)
+ elif value is not None:
+ setattr(settings, field, value)
db.commit()
return {"ok": True}
diff --git a/frontend/index.html b/frontend/index.html
index 8526ce5..827f1eb 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,14 +1,10 @@
-
+
-<<<<<<< HEAD
- Calendarr v17
-=======
- Calendarr v11
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
+ Calendarr v18
@@ -84,11 +80,7 @@
Anmelden
-<<<<<<< HEAD
- © 2026 Scarriffleservices · v17
-=======
- © 2026 Scarriffleservices · v11
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
+ © 2026 Scarriffleservices · v18
@@ -207,11 +199,7 @@
-<<<<<<< HEAD
-
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
+
@@ -247,11 +235,7 @@
—
-<<<<<<< HEAD
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -273,11 +253,7 @@
—
-<<<<<<< HEAD
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -339,11 +311,7 @@
—
-<<<<<<< HEAD
-=======
-
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
@@ -407,7 +375,6 @@
@@ -667,22 +622,29 @@
- Schriftkontrast
- Helligkeit der Beschriftungen und Texte
-
-
AaDunkel
-
AaMittel
-
AaHell
-
AaMaximum
+
-
-
Linienkontrast
-
Sichtbarkeit von Trennlinien und Rahmen
-
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index abe53c4..10f07e7 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -2045,6 +2045,24 @@ function openSettingsModal() {
document.getElementById(id + '-hex').value = val.toUpperCase();
document.getElementById(id + '-preview').style.background = val;
});
+
+ // Optional colour overrides — empty hex input means "auto"
+ [
+ { id: 'cfg-text-color', val: s.text_color },
+ { id: 'cfg-line-color', val: s.line_color },
+ { id: 'cfg-bg-color', val: s.bg_color },
+ ].forEach(({ id, val }) => {
+ const hex = document.getElementById(id + '-hex');
+ const prev = document.getElementById(id + '-preview');
+ if (!hex || !prev) return;
+ if (val) {
+ hex.value = String(val).toUpperCase();
+ prev.style.background = val;
+ } else {
+ hex.value = '';
+ prev.style.background = 'transparent';
+ }
+ });
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
@@ -2376,6 +2394,32 @@ function bindSettingsModal() {
});
});
+ // Optional override colours (text / line / background) — empty = use default
+ [
+ { prefix: 'cfg-text-color', defaultColor: '#c8c8d8' },
+ { prefix: 'cfg-line-color', defaultColor: '#3a3a52' },
+ { prefix: 'cfg-bg-color', defaultColor: '#0e0e14' },
+ ].forEach(({ prefix, defaultColor }) => {
+ const preview = document.getElementById(prefix + '-preview');
+ const hex = document.getElementById(prefix + '-hex');
+ const reset = document.getElementById(prefix + '-reset');
+ if (!preview || !hex || !reset) return;
+ preview.addEventListener('click', async () => {
+ const picked = await openColorPicker(preview, hex.value || defaultColor);
+ if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
+ });
+ hex.addEventListener('change', () => {
+ let val = hex.value.trim();
+ if (!val) { preview.style.background = 'transparent'; return; }
+ if (!val.startsWith('#')) val = '#' + val;
+ if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
+ });
+ reset.addEventListener('click', () => {
+ hex.value = '';
+ preview.style.background = 'transparent';
+ });
+ });
+
// Panel navigation
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
@@ -2415,6 +2459,11 @@ function bindSettingsModal() {
const btn = document.querySelector(`#${id} .contrast-btn.active`);
return btn ? Number(btn.dataset.val) : null;
};
+ // Optional override colours: empty input → null (use default)
+ const colourOrNull = (id) => {
+ const v = (document.getElementById(id).value || '').trim();
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null;
+ };
const settings = {
default_view: document.getElementById('cfg-default-view').value,
week_start_day: document.getElementById('cfg-week-start').value,
@@ -2423,9 +2472,10 @@ function bindSettingsModal() {
today_color: document.getElementById('cfg-today-hex').value,
month_divider_color: document.getElementById('cfg-month-divider-hex').value,
month_label_color: document.getElementById('cfg-month-label-hex').value,
+ text_color: colourOrNull('cfg-text-color-hex'),
+ line_color: colourOrNull('cfg-line-color-hex'),
+ bg_color: colourOrNull('cfg-bg-color-hex'),
dim_past_events: document.getElementById('cfg-dim-past').checked,
- text_contrast: getActive('cfg-text-contrast') || 3,
- line_contrast: getActive('cfg-line-contrast') || 3,
hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value,
};
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 250b214..c918a7e 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -67,6 +67,10 @@ const translations = {
settings_today_color: 'Heutige-Tag-Farbe',
settings_month_divider_color: 'Monatswechsel-Linie',
settings_month_label_color: 'Monatskürzel-Farbe',
+ settings_text_color: 'Schriftfarbe',
+ settings_line_color: 'Linienfarbe',
+ settings_bg_color: 'Hintergrundfarbe',
+ reset: 'Reset',
settings_text_contrast: 'Schriftkontrast',
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
@@ -282,6 +286,10 @@ const translations = {
settings_today_color: 'Today highlight color',
settings_month_divider_color: 'Month divider line',
settings_month_label_color: 'Month label color',
+ settings_text_color: 'Text color',
+ settings_line_color: 'Line color',
+ settings_bg_color: 'Background color',
+ reset: 'Reset',
settings_text_contrast: 'Text contrast',
settings_text_contrast_desc: 'Brightness of labels and text',
contrast_dark: 'Dark', contrast_medium: 'Medium',
diff --git a/frontend/js/utils.js b/frontend/js/utils.js
index 5db6786..4b71860 100644
--- a/frontend/js/utils.js
+++ b/frontend/js/utils.js
@@ -83,14 +83,47 @@ export function applyTheme(settings) {
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
- const tc = TEXT_CONTRAST[settings.text_contrast || 3];
- root.style.setProperty('--text-1', tc.t1);
- root.style.setProperty('--text-2', tc.t2);
- root.style.setProperty('--text-3', tc.t3);
+ // Text colour: a custom hex (settings.text_color) wins over the legacy
+ // 1–4 contrast step. We derive --text-2/--text-3 by darkening the
+ // chosen colour so the secondary/tertiary text stays in the same hue.
+ if (settings.text_color) {
+ root.style.setProperty('--text-1', settings.text_color);
+ root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25));
+ root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55));
+ } else {
+ const tc = TEXT_CONTRAST[settings.text_contrast || 3];
+ root.style.setProperty('--text-1', tc.t1);
+ root.style.setProperty('--text-2', tc.t2);
+ root.style.setProperty('--text-3', tc.t3);
+ }
- const lc = LINE_CONTRAST[settings.line_contrast || 3];
- root.style.setProperty('--border', lc.border);
- root.style.setProperty('--border-light', lc.light);
+ // Line colour: custom hex overrides the legacy contrast step.
+ if (settings.line_color) {
+ root.style.setProperty('--border', settings.line_color);
+ root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25));
+ } else {
+ const lc = LINE_CONTRAST[settings.line_contrast || 3];
+ root.style.setProperty('--border', lc.border);
+ root.style.setProperty('--border-light', lc.light);
+ }
+
+ // Background colour: optional. If set, also tint the topbar/sidebar
+ // and surface variants so the whole UI stays coherent.
+ if (settings.bg_color) {
+ root.style.setProperty('--bg-app', settings.bg_color);
+ root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10));
+ root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10));
+ root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18));
+ root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26));
+ root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40));
+ } else {
+ root.style.removeProperty('--bg-app');
+ root.style.removeProperty('--bg-topbar');
+ root.style.removeProperty('--bg-sidebar');
+ root.style.removeProperty('--bg-surface');
+ root.style.removeProperty('--bg-hover');
+ root.style.removeProperty('--bg-active');
+ }
const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px');
@@ -105,3 +138,24 @@ function hexToRgba(hex, alpha) {
const b = parseInt(hex.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
+
+// Brighten (positive amount) or darken (negative) a hex colour.
+// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
+// from a single user-picked colour so the whole UI stays in the same family.
+function shadeHex(hex, amount) {
+ let r = parseInt(hex.slice(1,3), 16);
+ let g = parseInt(hex.slice(3,5), 16);
+ let b = parseInt(hex.slice(5,7), 16);
+ if (amount >= 0) {
+ r = Math.round(r + (255 - r) * amount);
+ g = Math.round(g + (255 - g) * amount);
+ b = Math.round(b + (255 - b) * amount);
+ } else {
+ const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
+ r = Math.round(r * a);
+ g = Math.round(g * a);
+ b = Math.round(b * a);
+ }
+ const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
+ return '#' + h(r) + h(g) + h(b);
+}
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 9d0c41b..eefbcf1 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,6 +1,2 @@
// Increment APP_VERSION with every code change
-<<<<<<< HEAD
-export const APP_VERSION = 'v17';
-=======
-export const APP_VERSION = 'v11';
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
+export const APP_VERSION = 'v18';
diff --git a/frontend/sw.js b/frontend/sw.js
index 1d172c7..24dd649 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,3 @@
-<<<<<<< HEAD
// Calendarr Service Worker — minimal-cache strategy
//
// Strategy: network-first for everything. The cache is only used as a
@@ -8,52 +7,15 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v17';
+const CACHE_VERSION = 'calendarr-v18';
const OFFLINE_SHELL = ['/', '/index.html'];
-=======
-// Calendarr Service Worker
-// Cache-first for static assets, network-first for /api/* (graceful offline)
-
-const CACHE_VERSION = 'calendarr-v11';
-const STATIC_ASSETS = [
- '/',
- '/index.html',
- '/manifest.json',
- '/static/css/app.css',
- '/static/favicon.svg',
- '/static/js/app.js',
- '/static/js/api.js',
- '/static/js/calendar.js',
- '/static/js/color-picker.js',
- '/static/js/date-picker.js',
- '/static/js/i18n.js',
- '/static/js/utils.js',
- '/static/js/version.js',
- '/static/js/views/agenda.js',
- '/static/js/views/month.js',
- '/static/js/views/quarter.js',
- '/static/js/views/week.js',
- '/icons/icon-192.png',
- '/icons/icon-512.png',
- '/icons/icon.svg',
-];
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION).then(cache =>
-<<<<<<< HEAD
Promise.all(OFFLINE_SHELL.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
))
-=======
- // Use addAll with a fallback so a single missing file doesn't abort install
- Promise.all(
- STATIC_ASSETS.map(url =>
- cache.add(url).catch(err => console.warn('[SW] skip', url, err))
- )
- )
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
).then(() => self.skipWaiting())
);
});
@@ -72,12 +34,8 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url);
-<<<<<<< HEAD
// API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise).
-=======
- // Network-first for API routes — fail silently if offline
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(req).catch(() =>
@@ -90,7 +48,6 @@ self.addEventListener('fetch', event => {
return;
}
-<<<<<<< HEAD
// Everything else: network-first. The browser's HTTP cache (driven by
// the server's Cache-Control headers) already throttles re-fetches —
// the SW just makes sure offline still works for the entry HTML.
@@ -114,47 +71,6 @@ self.addEventListener('fetch', event => {
return caches.match(req).then(c => c || caches.match('/index.html'));
}
return new Response('', { status: 503 });
-=======
- // Network-first for navigation (HTML) and the version-defining files —
- // ensures users always get the freshest entry point so new releases
- // take effect on the next reload without a manual SW unregister.
- const isHtml = req.mode === 'navigate'
- || url.pathname === '/'
- || url.pathname === '/index.html';
- const isVersionFile = url.pathname === '/static/js/version.js';
-
- if (isHtml || isVersionFile) {
- event.respondWith(
- fetch(req).then(resp => {
- if (resp && resp.status === 200) {
- const clone = resp.clone();
- caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
- }
- return resp;
- }).catch(() =>
- caches.match(req).then(c => c || caches.match('/index.html'))
- )
- );
- return;
- }
-
- // Cache-first for everything else (static)
- event.respondWith(
- caches.match(req).then(cached => {
- if (cached) return cached;
- return fetch(req).then(resp => {
- // Only cache successful, basic-origin responses
- if (resp && resp.status === 200 && resp.type === 'basic') {
- const clone = resp.clone();
- caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
- }
- return resp;
- }).catch(() => {
- // Offline fallback for navigation requests
- if (req.mode === 'navigate') return caches.match('/index.html');
- return new Response('', { status: 503 });
- });
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
})
);
});
--
2.47.3
From fd7562966ae081fd28fde2e6de54937ba58f2939 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 09:57:40 +0200
Subject: [PATCH 080/114] =?UTF-8?q?fix(settings):=20Schrift-/Linien-/Hinte?=
=?UTF-8?q?rgrundfarbe=20=E2=80=94=20Live-Vorschau=20+=20Hex=20ohne=20'#'?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Live-Vorschau beim Tippen statt erst bei Blur (input-Event)
- Hex-Werte werden auch ohne fuehrendes '#' akzeptiert ("ff0000" -> "#FF0000")
- Reset-Button wendet Standardwerte sofort an
- v19 / sw cache v19
---
frontend/js/calendar.js | 57 ++++++++++++++++++++++++++++++++++-------
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
3 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 10f07e7..23e905b 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -2394,7 +2394,19 @@ function bindSettingsModal() {
});
});
- // Optional override colours (text / line / background) — empty = use default
+ // Optional override colours (text / line / background) — empty = use default.
+ // Live-apply to the page so the user sees the effect while typing, not only after Save.
+ const overrideFieldMap = {
+ 'cfg-text-color': 'text_color',
+ 'cfg-line-color': 'line_color',
+ 'cfg-bg-color': 'bg_color',
+ };
+ const liveApplyOverride = (prefix, value) => {
+ const field = overrideFieldMap[prefix];
+ if (!field) return;
+ state.settings[field] = value || null;
+ applyTheme(state.settings);
+ };
[
{ prefix: 'cfg-text-color', defaultColor: '#c8c8d8' },
{ prefix: 'cfg-line-color', defaultColor: '#3a3a52' },
@@ -2404,19 +2416,43 @@ function bindSettingsModal() {
const hex = document.getElementById(prefix + '-hex');
const reset = document.getElementById(prefix + '-reset');
if (!preview || !hex || !reset) return;
+
+ const normalize = (raw) => {
+ let v = (raw || '').trim();
+ if (!v) return '';
+ if (!v.startsWith('#')) v = '#' + v;
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null;
+ };
+
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || defaultColor);
- if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
+ if (picked) {
+ hex.value = picked.toUpperCase();
+ preview.style.background = picked;
+ liveApplyOverride(prefix, picked);
+ }
});
+
+ const onTyped = () => {
+ const norm = normalize(hex.value);
+ if (norm === '') {
+ preview.style.background = 'transparent';
+ liveApplyOverride(prefix, null);
+ } else if (norm) {
+ preview.style.background = norm;
+ liveApplyOverride(prefix, norm);
+ }
+ };
+ hex.addEventListener('input', onTyped);
hex.addEventListener('change', () => {
- let val = hex.value.trim();
- if (!val) { preview.style.background = 'transparent'; return; }
- if (!val.startsWith('#')) val = '#' + val;
- if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
+ const norm = normalize(hex.value);
+ if (norm) hex.value = norm;
});
+
reset.addEventListener('click', () => {
hex.value = '';
preview.style.background = 'transparent';
+ liveApplyOverride(prefix, null);
});
});
@@ -2459,10 +2495,13 @@ function bindSettingsModal() {
const btn = document.querySelector(`#${id} .contrast-btn.active`);
return btn ? Number(btn.dataset.val) : null;
};
- // Optional override colours: empty input → null (use default)
+ // Optional override colours: empty input → null (use default).
+ // Tolerant: accepts both "ff0000" and "#ff0000".
const colourOrNull = (id) => {
- const v = (document.getElementById(id).value || '').trim();
- return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null;
+ let v = (document.getElementById(id).value || '').trim();
+ if (!v) return null;
+ if (!v.startsWith('#')) v = '#' + v;
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toUpperCase() : null;
};
const settings = {
default_view: document.getElementById('cfg-default-view').value,
diff --git a/frontend/js/version.js b/frontend/js/version.js
index eefbcf1..fb9a0e6 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v18';
+export const APP_VERSION = 'v19';
diff --git a/frontend/sw.js b/frontend/sw.js
index 24dd649..d1e5ca9 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v18';
+const CACHE_VERSION = 'calendarr-v19';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 43575f90423e31c7479357f4518798727c61a409 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 10:06:12 +0200
Subject: [PATCH 081/114] fix(theme): Defaults weiss-auf-schwarz +
Kontrast-Sicherheitsbremse
- Default-Schriftfarbe = #FFFFFF, Default-Hintergrund = #000000
- Wenn Schrift- und Hintergrundfarbe zu wenig Kontrast haben (< 2.5:1),
wird automatisch auf weiss-auf-schwarz zurueckgefallen. So kann
man sich nicht mehr in eine unsichtbare Seite manoevrieren.
- Color-Picker zeigt jetzt die wirksame Default-Farbe in der Vorschau
(statt leer/transparent), auch wenn keine Override gesetzt ist.
---
frontend/js/calendar.js | 32 +++++++--------
frontend/js/utils.js | 86 +++++++++++++++++++++++------------------
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 64 insertions(+), 58 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 23e905b..702a33d 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -1,5 +1,5 @@
import { api } from './api.js';
-import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart } from './utils.js';
+import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
@@ -2046,22 +2046,18 @@ function openSettingsModal() {
document.getElementById(id + '-preview').style.background = val;
});
- // Optional colour overrides — empty hex input means "auto"
+ // Override-Farben — leeres Hex-Input bedeutet "Default verwenden",
+ // aber die Preview zeigt trotzdem die aktuell wirksame Farbe.
[
- { id: 'cfg-text-color', val: s.text_color },
- { id: 'cfg-line-color', val: s.line_color },
- { id: 'cfg-bg-color', val: s.bg_color },
- ].forEach(({ id, val }) => {
+ { id: 'cfg-text-color', val: s.text_color, fallback: DEFAULT_TEXT_COLOR },
+ { id: 'cfg-line-color', val: s.line_color, fallback: DEFAULT_LINE_COLOR },
+ { id: 'cfg-bg-color', val: s.bg_color, fallback: DEFAULT_BG_COLOR },
+ ].forEach(({ id, val, fallback }) => {
const hex = document.getElementById(id + '-hex');
const prev = document.getElementById(id + '-preview');
if (!hex || !prev) return;
- if (val) {
- hex.value = String(val).toUpperCase();
- prev.style.background = val;
- } else {
- hex.value = '';
- prev.style.background = 'transparent';
- }
+ hex.value = val ? String(val).toUpperCase() : '';
+ prev.style.background = val || fallback;
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
@@ -2408,9 +2404,9 @@ function bindSettingsModal() {
applyTheme(state.settings);
};
[
- { prefix: 'cfg-text-color', defaultColor: '#c8c8d8' },
- { prefix: 'cfg-line-color', defaultColor: '#3a3a52' },
- { prefix: 'cfg-bg-color', defaultColor: '#0e0e14' },
+ { prefix: 'cfg-text-color', defaultColor: DEFAULT_TEXT_COLOR },
+ { prefix: 'cfg-line-color', defaultColor: DEFAULT_LINE_COLOR },
+ { prefix: 'cfg-bg-color', defaultColor: DEFAULT_BG_COLOR },
].forEach(({ prefix, defaultColor }) => {
const preview = document.getElementById(prefix + '-preview');
const hex = document.getElementById(prefix + '-hex');
@@ -2436,7 +2432,7 @@ function bindSettingsModal() {
const onTyped = () => {
const norm = normalize(hex.value);
if (norm === '') {
- preview.style.background = 'transparent';
+ preview.style.background = defaultColor;
liveApplyOverride(prefix, null);
} else if (norm) {
preview.style.background = norm;
@@ -2451,7 +2447,7 @@ function bindSettingsModal() {
reset.addEventListener('click', () => {
hex.value = '';
- preview.style.background = 'transparent';
+ preview.style.background = defaultColor;
liveApplyOverride(prefix, null);
});
});
diff --git a/frontend/js/utils.js b/frontend/js/utils.js
index 4b71860..b0e9b36 100644
--- a/frontend/js/utils.js
+++ b/frontend/js/utils.js
@@ -76,6 +76,12 @@ const LINE_CONTRAST = {
4: { border: '#5a5a78', light: '#484860' },
};
+// Defaults wenn kein Custom-Override gesetzt ist.
+// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
+export const DEFAULT_TEXT_COLOR = '#FFFFFF';
+export const DEFAULT_LINE_COLOR = '#3A3A52';
+export const DEFAULT_BG_COLOR = '#000000';
+
export function applyTheme(settings) {
const root = document.documentElement;
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
@@ -83,47 +89,33 @@ export function applyTheme(settings) {
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
- // Text colour: a custom hex (settings.text_color) wins over the legacy
- // 1–4 contrast step. We derive --text-2/--text-3 by darkening the
- // chosen colour so the secondary/tertiary text stays in the same hue.
- if (settings.text_color) {
- root.style.setProperty('--text-1', settings.text_color);
- root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25));
- root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55));
- } else {
- const tc = TEXT_CONTRAST[settings.text_contrast || 3];
- root.style.setProperty('--text-1', tc.t1);
- root.style.setProperty('--text-2', tc.t2);
- root.style.setProperty('--text-3', tc.t3);
+ // Effektive Farben bestimmen (Override > Default).
+ let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
+ let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
+ let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
+
+ // Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
+ // Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
+ // erzwinge weiss-auf-schwarz, damit man nicht in einer unbedienbaren
+ // Seite landet.
+ if (contrastRatio(textColor, bgColor) < 2.5) {
+ textColor = DEFAULT_TEXT_COLOR;
+ bgColor = DEFAULT_BG_COLOR;
}
- // Line colour: custom hex overrides the legacy contrast step.
- if (settings.line_color) {
- root.style.setProperty('--border', settings.line_color);
- root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25));
- } else {
- const lc = LINE_CONTRAST[settings.line_contrast || 3];
- root.style.setProperty('--border', lc.border);
- root.style.setProperty('--border-light', lc.light);
- }
+ root.style.setProperty('--text-1', textColor);
+ root.style.setProperty('--text-2', shadeHex(textColor, -0.25));
+ root.style.setProperty('--text-3', shadeHex(textColor, -0.55));
- // Background colour: optional. If set, also tint the topbar/sidebar
- // and surface variants so the whole UI stays coherent.
- if (settings.bg_color) {
- root.style.setProperty('--bg-app', settings.bg_color);
- root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10));
- root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10));
- root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18));
- root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26));
- root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40));
- } else {
- root.style.removeProperty('--bg-app');
- root.style.removeProperty('--bg-topbar');
- root.style.removeProperty('--bg-sidebar');
- root.style.removeProperty('--bg-surface');
- root.style.removeProperty('--bg-hover');
- root.style.removeProperty('--bg-active');
- }
+ root.style.setProperty('--border', lineColor);
+ root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
+
+ root.style.setProperty('--bg-app', bgColor);
+ root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10));
+ root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
+ root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
+ root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
+ root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px');
@@ -132,6 +124,24 @@ export function applyTheme(settings) {
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
}
+function luminance(hex) {
+ const c = (n) => {
+ const v = n / 255;
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ };
+ const r = c(parseInt(hex.slice(1, 3), 16));
+ const g = c(parseInt(hex.slice(3, 5), 16));
+ const b = c(parseInt(hex.slice(5, 7), 16));
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+function contrastRatio(c1, c2) {
+ try {
+ const l1 = luminance(c1);
+ const l2 = luminance(c2);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+ } catch { return 21; }
+}
+
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
diff --git a/frontend/js/version.js b/frontend/js/version.js
index fb9a0e6..b8dfc41 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v19';
+export const APP_VERSION = 'v20';
diff --git a/frontend/sw.js b/frontend/sw.js
index d1e5ca9..4c460d0 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v19';
+const CACHE_VERSION = 'calendarr-v20';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From f102f02cb90ceff74ac8addaf1f8bf6db58710d0 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 10:12:32 +0200
Subject: [PATCH 082/114] fix(version): Tab-Titel + Impressum dynamisch aus
version.js
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Vorher waren "Calendarr v18" in index.html hardcoded und wurden bei
Releases nie mit gebumpt — v19/v20 wurden zwar in version.js gepflegt,
landeten aber nie im Tab-Titel. Jetzt liest calendar.js APP_VERSION
direkt aus version.js und setzt sowohl document.title als auch das
Impressum-Footer-Label, damit das nicht mehr auseinanderlaufen kann.
v21 / sw cache v21
---
frontend/index.html | 6 +++---
frontend/js/calendar.js | 10 +++++++++-
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 827f1eb..0a7569a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,8 +3,8 @@
-
- Calendarr v18
+
+ Calendarr
@@ -895,7 +895,7 @@
scarriffleservices@gmail.com
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 702a33d..de0be27 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -7,6 +7,14 @@ import { renderQuarter } from './views/quarter.js';
import { openColorPicker } from './color-picker.js';
import { openDatePicker, formatDtDisplay } from './date-picker.js';
import { t, setLang, getLang } from './i18n.js';
+import { APP_VERSION } from './version.js';
+
+// Version sofort beim Modul-Load ueberall sichtbar setzen.
+document.title = `Calendarr ${APP_VERSION}`;
+document.addEventListener('DOMContentLoaded', () => {
+ const imp = document.getElementById('impressum-version');
+ if (imp) imp.textContent = `Calendarr ${APP_VERSION}`;
+});
// Fetch avatar image as blob URL (with auth header)
function fetchAvatarBlob() {
@@ -439,7 +447,7 @@ function updateTitle() {
titleEl.innerHTML =
`${main}` +
(year ? `${year}` : '');
- document.title = `Calendarr - ${fullText}`;
+ document.title = `Calendarr ${APP_VERSION} - ${fullText}`;
}
function updateViewButtons() {
diff --git a/frontend/js/version.js b/frontend/js/version.js
index b8dfc41..c7fd4d5 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v20';
+export const APP_VERSION = 'v21';
diff --git a/frontend/sw.js b/frontend/sw.js
index 4c460d0..efb9c6b 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v20';
+const CACHE_VERSION = 'calendarr-v21';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 275e5a2ae028f63d394d179328872509e6c6c40a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 10:18:49 +0200
Subject: [PATCH 083/114] fix: unaufgeloeste Merge-Konflikt-Marker aus
i18n/calendar/week/css entfernt
Beim letzten Beta->Master-Merge sind die <<<<<<< / ======= / >>>>>>>
Marker mit committet worden. Das hat i18n.js mit einem SyntaxError beim
Parsen abgebrochen und damit den gesamten Frontend-Start kaputt gemacht
(=> komplett schwarze Seite, weil applyTheme nie lief).
Acht Bloecke aufgeloest, in allen Faellen die HEAD-Seite behalten
(neue Features: copy-Key, URL-State, all-day-continues-Logik, Event-
Popup-Header). v22 / sw cache v22.
---
frontend/css/app.css | 16 ----------------
frontend/js/calendar.js | 14 --------------
frontend/js/i18n.js | 8 --------
frontend/js/version.js | 2 +-
frontend/js/views/week.js | 13 -------------
frontend/sw.js | 2 +-
6 files changed, 2 insertions(+), 53 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 74654d8..d3a38f0 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -975,15 +975,11 @@ a { color: var(--primary); text-decoration: none; }
}
.ctx-item:hover { background: var(--bg-hover); }
-<<<<<<< HEAD
/* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
modern und lässt dem Titel die meiste Breite. */
-=======
-/* ── Event Popup ────────────────────────────────────────── */
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
.event-popup {
position: fixed; z-index: 600;
background: var(--bg-surface);
@@ -1701,24 +1697,12 @@ a { color: var(--primary); text-decoration: none; }
.topbar-left { gap: 0; }
.topbar-right { gap: 0; }
-<<<<<<< HEAD
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */
.event-popup { width: min(94vw, 380px); max-width: 94vw; }
.popup-header { padding: 10px 8px 10px 14px; }
.popup-header h4 { font-size: 13.5px; }
.popup-icon-btn { width: 32px; height: 32px; }
.popup-icon-btn svg { width: 16px; height: 16px; }
-=======
- /* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */
- .event-popup .icon-btn {
- min-width: 32px !important;
- min-height: 32px !important;
- width: 32px;
- height: 32px;
- }
- .event-popup .popup-header { gap: 2px; padding: 10px 12px; }
- .event-popup { width: min(92vw, 340px); max-width: 92vw; }
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
.month-event-time { display: none; }
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index de0be27..3d15ac5 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -123,7 +123,6 @@ export async function initCalendar() {
bindProfileModal();
bindSwipeNavigation();
handleHAOAuthReturn();
-<<<<<<< HEAD
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
@@ -140,8 +139,6 @@ export async function initCalendar() {
}
if (changed) fetchAndRender();
});
-=======
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
}
function handleHAOAuthReturn() {
@@ -155,11 +152,7 @@ function handleHAOAuthReturn() {
};
if (params.has('ha_connected')) {
showToast('Home Assistant verbunden');
-<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
-=======
- window.history.replaceState({}, '', window.location.pathname);
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
fetchAndRender(true);
api.get('/homeassistant/accounts').then(accs => {
state.haAccounts = accs || [];
@@ -169,11 +162,7 @@ function handleHAOAuthReturn() {
} else if (params.has('ha_error')) {
const code = params.get('ha_error');
showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
-<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
-=======
- window.history.replaceState({}, '', window.location.pathname);
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
}
}
@@ -268,10 +257,7 @@ async function fetchAndRender(force = false, silent = false) {
renderView();
updateTitle();
renderMiniCal();
-<<<<<<< HEAD
writeUrlState();
-=======
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
prefetchIfNeeded(start, end); // extend cache in background if approaching an edge
return;
}
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index c918a7e..c443647 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -158,11 +158,7 @@ const translations = {
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
-<<<<<<< HEAD
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
-=======
- copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Vor dem Kopieren bearbeiten',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
@@ -377,11 +373,7 @@ const translations = {
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences',
-<<<<<<< HEAD
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
-=======
- copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Edit before copying',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index c7fd4d5..7cd23f5 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v21';
+export const APP_VERSION = 'v22';
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 4aa14d0..e84c0cb 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -63,7 +63,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : '';
-<<<<<<< HEAD
// continues-left/right: compute on date-only basis for all-day events
let evStart = new Date(ev.start);
let evEnd = new Date(ev.end);
@@ -77,10 +76,6 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
-=======
- const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
- const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title;
@@ -252,7 +247,6 @@ function renderNowLine(container, days, hourH = 60) {
function layoutWeekAllDay(evs, days) {
const items = [];
evs.forEach(ev => {
-<<<<<<< HEAD
// For all-day events, normalize to date-only with inclusive end-day
// (iCal stores exclusive end → subtract 1). For timed events, keep
// the original strict-overlap logic so events ending exactly at
@@ -275,13 +269,6 @@ function layoutWeekAllDay(evs, days) {
matches = new Date(ev.start) < de && new Date(ev.end) > ds;
}
if (matches) {
-=======
- let colStart = -1, colEnd = -1;
- days.forEach((day, i) => {
- const ds = new Date(day); ds.setHours(0, 0, 0, 0);
- const de = new Date(day); de.setHours(24, 0, 0, 0);
- if (new Date(ev.start) < de && new Date(ev.end) > ds) {
->>>>>>> e744b1829e99db6b80922f75542ced329138e474
if (colStart === -1) colStart = i;
colEnd = i;
}
diff --git a/frontend/sw.js b/frontend/sw.js
index efb9c6b..4626683 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v21';
+const CACHE_VERSION = 'calendarr-v22';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 362cc7212c4e6fed37b81115ae2ef029441c33fd Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Tue, 19 May 2026 10:22:13 +0200
Subject: [PATCH 084/114] fix(version): Sidebar-Copyright wird jetzt auch aus
version.js befuellt
---
frontend/index.html | 2 +-
frontend/js/calendar.js | 2 ++
frontend/js/version.js | 2 +-
frontend/sw.js | 2 +-
4 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 0a7569a..5b030f8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -199,7 +199,7 @@
-
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 3d15ac5..8c4449e 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -14,6 +14,8 @@ document.title = `Calendarr ${APP_VERSION}`;
document.addEventListener('DOMContentLoaded', () => {
const imp = document.getElementById('impressum-version');
if (imp) imp.textContent = `Calendarr ${APP_VERSION}`;
+ const side = document.getElementById('sidebar-copyright');
+ if (side) side.innerHTML = `© 2026 Scarriffleservices · ${APP_VERSION}`;
});
// Fetch avatar image as blob URL (with auth header)
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 7cd23f5..bb0b7ed 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v22';
+export const APP_VERSION = 'v23';
diff --git a/frontend/sw.js b/frontend/sw.js
index 4626683..79c4d88 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v22';
+const CACHE_VERSION = 'calendarr-v23';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
--
2.47.3
From 32268a18b229119f790cab34ed014943a3a721be Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 16:05:18 +0200
Subject: [PATCH 085/114] feat: Kalender-Sharing, Gruppen, iCal Import/Export &
Ersteller (Server)
Kollaborations-Features ausschliesslich fuer lokale Kalender:
- Sharing: calendar_shares-Tabelle, GET/POST/DELETE /api/local/calendars/{id}/shares
(nur Besitzer), GET /api/users/directory, geteilte Kalender in
GET /api/local/calendars (shared_by/permission/owned) und im Merge-Read.
- Gruppen: groups/group_members/group_calendars + /api/groups-Router inkl.
kombinierter Ansicht /api/groups/{id}/combined (owner + is_group_event).
- Ersteller: local_events.creator_id (serverseitig gesetzt) + creator_name_external
aus ORGANIZER; creator-Feld in allen lokalen Event-Responses.
- Private-Flag: local_events.is_private + user_settings.private_event_visibility
(hidden|busy), Filterung in der Gruppenansicht.
- iCal Import/Export: ical_io.py, POST /api/local/calendars/{id}/import,
POST /api/local/import, GET /api/local/calendars/{id}/export.
- Zentraler Berechtigungs-Helper (permissions.py) und gemeinsamer Event-Dict-
Builder (local_events_util.py) ersetzen die Nur-Besitzer-Filter.
- pytest-Suite (12 Tests) fuer Sharing, Gruppen, Parser, Private-Filterung.
Additiv & rueckwaertskompatibel; Migrationen in main.py._migrate().
Co-Authored-By: Claude Opus 4.8
---
backend/ical_io.py | 205 +++++++++++++++++
backend/local_events_util.py | 165 ++++++++++++++
backend/main.py | 29 ++-
backend/models.py | 88 +++++++-
backend/permissions.py | 126 +++++++++++
backend/routers/caldav_router.py | 125 +----------
backend/routers/groups_router.py | 334 ++++++++++++++++++++++++++++
backend/routers/local_router.py | 311 +++++++++++++++++++++-----
backend/routers/settings_router.py | 7 +-
backend/routers/users_router.py | 19 ++
backend/tests/conftest.py | 61 +++++
backend/tests/test_collaboration.py | 282 +++++++++++++++++++++++
requirements-dev.txt | 3 +
13 files changed, 1582 insertions(+), 173 deletions(-)
create mode 100644 backend/ical_io.py
create mode 100644 backend/local_events_util.py
create mode 100644 backend/permissions.py
create mode 100644 backend/routers/groups_router.py
create mode 100644 backend/tests/conftest.py
create mode 100644 backend/tests/test_collaboration.py
create mode 100644 requirements-dev.txt
diff --git a/backend/ical_io.py b/backend/ical_io.py
new file mode 100644
index 0000000..905c471
--- /dev/null
+++ b/backend/ical_io.py
@@ -0,0 +1,205 @@
+"""iCal (.ics) import/export for local calendars.
+
+Reuses the already-installed ``icalendar`` library. The parser produces dicts
+matching the LocalEvent storage shape (ISO strings, comma-separated EXDATE);
+the generator emits a VCALENDAR with ORGANIZER, RRULE, etc.
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from datetime import date, datetime, timedelta, timezone
+
+from icalendar import Calendar, Event, vCalAddress, vRecur, vText
+
+logger = logging.getLogger(__name__)
+
+
+def _rrule_to_str(component) -> str | None:
+ prop = component.get("RRULE")
+ if not prop:
+ return None
+ return prop.to_ical().decode("utf-8")
+
+
+def _exdate_to_csv(component) -> str | None:
+ """Collect EXDATE values as comma-separated YYYYMMDD strings."""
+ exdate = component.get("EXDATE")
+ if not exdate:
+ return None
+ items = exdate if isinstance(exdate, list) else [exdate]
+ out = []
+ for ex in items:
+ dts = getattr(ex, "dts", None) or []
+ for d in dts:
+ val = d.dt
+ if isinstance(val, datetime):
+ out.append(val.strftime("%Y%m%d"))
+ elif isinstance(val, date):
+ out.append(val.strftime("%Y%m%d"))
+ return ",".join(out) if out else None
+
+
+def _organizer_name(component) -> str | None:
+ org = component.get("ORGANIZER")
+ if not org:
+ return None
+ # CN parameter holds the display name; fall back to the mailto address.
+ try:
+ cn = org.params.get("CN")
+ if cn:
+ return str(cn)
+ except Exception:
+ pass
+ raw = str(org)
+ if raw.lower().startswith("mailto:"):
+ return raw[7:]
+ return raw or None
+
+
+def parse_ics(raw: bytes) -> dict:
+ """Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}.
+
+ Raises ValueError if the payload is not a parseable calendar at all.
+ """
+ try:
+ cal = Calendar.from_ical(raw)
+ except Exception as e:
+ raise ValueError(f"Datei ist kein gültiges iCal-Format: {e}") from e
+
+ events = []
+ errors = []
+ for component in cal.walk():
+ if component.name != "VEVENT":
+ continue
+ try:
+ uid = str(component.get("UID") or uuid.uuid4())
+ title = str(component.get("SUMMARY", "") or "")
+ location = str(component.get("LOCATION", "") or "") or None
+ description = str(component.get("DESCRIPTION", "") or "") or None
+
+ dtstart_prop = component.get("DTSTART")
+ if dtstart_prop is None:
+ errors.append(f"VEVENT {uid}: kein DTSTART, übersprungen")
+ continue
+ dtstart = dtstart_prop.dt
+ dtend_prop = component.get("DTEND")
+ duration_prop = component.get("DURATION")
+
+ all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
+ if all_day:
+ if dtend_prop:
+ dtend = dtend_prop.dt
+ elif duration_prop:
+ dtend = dtstart + duration_prop.dt
+ else:
+ dtend = dtstart + timedelta(days=1)
+ start_str = dtstart.isoformat()
+ end_str = (dtend.isoformat() if isinstance(dtend, date)
+ else (dtstart + timedelta(days=1)).isoformat())
+ else:
+ if dtstart.tzinfo is None:
+ dtstart = dtstart.replace(tzinfo=timezone.utc)
+ if dtend_prop:
+ dtend = dtend_prop.dt
+ if isinstance(dtend, date) and not isinstance(dtend, datetime):
+ dtend = datetime.combine(dtend, datetime.min.time(), tzinfo=timezone.utc)
+ elif dtend.tzinfo is None:
+ dtend = dtend.replace(tzinfo=timezone.utc)
+ elif duration_prop:
+ dtend = dtstart + duration_prop.dt
+ else:
+ dtend = dtstart + timedelta(hours=1)
+ start_str = dtstart.isoformat()
+ end_str = dtend.isoformat()
+
+ events.append({
+ "uid": uid,
+ "title": title,
+ "start": start_str,
+ "end": end_str,
+ "all_day": all_day,
+ "location": location,
+ "description": description,
+ "rrule": _rrule_to_str(component),
+ "exdate": _exdate_to_csv(component),
+ "organizer": _organizer_name(component),
+ })
+ except Exception as exc:
+ logger.warning("Skipping malformed VEVENT: %s", exc)
+ errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
+ return {"events": events, "errors": errors}
+
+
+def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
+ params = {}
+ for part in rrule_str.split(";"):
+ if "=" not in part:
+ continue
+ key, val = part.split("=", 1)
+ params[key] = val.split(",") if "," in val else val
+ return vRecur(params)
+
+
+def _parse_iso(s: str) -> datetime:
+ s = s.replace("Z", "+00:00")
+ dt = datetime.fromisoformat(s)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt
+
+
+def build_ics(calendar, events, *, name_cache: dict | None = None) -> str:
+ """Build a VCALENDAR string for a local calendar and its events."""
+ cal = Calendar()
+ cal.add("prodid", "-//Calendarr//EN")
+ cal.add("version", "2.0")
+ cal.add("x-wr-calname", calendar.name)
+
+ for ev in events:
+ item = Event()
+ item.add("uid", ev.uid)
+ item.add("summary", ev.title or "")
+ item.add("dtstamp", datetime.now(timezone.utc))
+
+ if ev.all_day:
+ try:
+ start = date.fromisoformat(ev.start[:10])
+ end = date.fromisoformat(ev.end[:10])
+ except ValueError:
+ continue
+ if end <= start:
+ end = start + timedelta(days=1)
+ item.add("dtstart", start)
+ item.add("dtend", end)
+ else:
+ try:
+ item.add("dtstart", _parse_iso(ev.start))
+ item.add("dtend", _parse_iso(ev.end))
+ except ValueError:
+ continue
+
+ if ev.location:
+ item.add("location", ev.location)
+ if ev.description:
+ item.add("description", ev.description)
+ if ev.color:
+ item.add("x-calendarr-color", ev.color)
+ if ev.rrule:
+ item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
+
+ # ORGANIZER from the creator (local user or imported name).
+ organizer_name = None
+ if getattr(ev, "creator_id", None) and name_cache:
+ organizer_name = name_cache.get(ev.creator_id)
+ if not organizer_name:
+ organizer_name = getattr(ev, "creator_name_external", None)
+ if organizer_name:
+ organizer = vCalAddress("mailto:noreply@calendarr.local")
+ organizer.params["CN"] = vText(organizer_name.replace('"', ""))
+ item.add("organizer", organizer)
+
+ cal.add_component(item)
+
+ return cal.to_ical().decode("utf-8")
diff --git a/backend/local_events_util.py b/backend/local_events_util.py
new file mode 100644
index 0000000..1a88c08
--- /dev/null
+++ b/backend/local_events_util.py
@@ -0,0 +1,165 @@
+"""Shared builders for local-event API dicts.
+
+Every local event returned by the API (the local router, the unified event
+merge in caldav_router, and the group combined view) must look identical and
+carry the additive collaboration fields: ``creator``, ``private``, ``type``,
+and — in the group view — ``owner`` and ``is_group_event``.
+
+Centralising this avoids the three near-duplicate dict constructions that used
+to live in caldav_router.py.
+"""
+
+import logging
+from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
+from typing import Optional
+
+from dateutil.rrule import rrulestr
+from sqlalchemy.orm import Session
+
+import models
+
+logger = logging.getLogger(__name__)
+
+
+def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None) -> Optional[dict]:
+ """Build the ``creator`` payload for an event.
+
+ Returns ``{"id": int, "display_name": username}`` for a local creator,
+ ``{"id": None, "display_name": " (importiert)"}`` for an imported
+ event, or ``None`` when no creator info exists (legacy events).
+
+ ``name_cache`` maps user_id -> username to avoid per-event DB lookups; the
+ creator relationship is used as a fallback.
+ """
+ if ev.creator_id:
+ display = None
+ if name_cache is not None:
+ display = name_cache.get(ev.creator_id)
+ if display is None and ev.creator is not None:
+ display = ev.creator.username
+ if display is not None:
+ return {"id": ev.creator_id, "display_name": display}
+ if ev.creator_name_external:
+ return {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
+ return None
+
+
+def build_local_event_dict(
+ ev: models.LocalEvent,
+ cal: models.LocalCalendar,
+ *,
+ start: Optional[str] = None,
+ end: Optional[str] = None,
+ all_day: Optional[bool] = None,
+ rrule: Optional[str] = ...,
+ creator: Optional[dict] = None,
+ owner: Optional[dict] = None,
+ is_group_event: bool = False,
+) -> dict:
+ """Build the unified dict for a single local event (or occurrence).
+
+ ``start``/``end``/``all_day`` override the stored values (used when emitting
+ an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
+ by the group combined view.
+ """
+ d = {
+ "id": ev.uid,
+ "url": f"local://{ev.uid}",
+ "title": ev.title,
+ "start": ev.start if start is None else start,
+ "end": ev.end if end is None else end,
+ "allDay": ev.all_day if all_day is None else all_day,
+ "location": ev.location or "",
+ "description": ev.description or "",
+ "color": ev.color,
+ "rrule": ev.rrule if rrule is ... else rrule,
+ "exdate": ev.exdate,
+ "calendar_id": f"local-{cal.id}",
+ "calendar_name": cal.name,
+ "calendarColor": cal.color,
+ "source": "local",
+ "type": "local",
+ "creator": creator,
+ "private": bool(ev.is_private),
+ }
+ if owner is not None:
+ d["owner"] = owner
+ if is_group_event:
+ d["is_group_event"] = True
+ return d
+
+
+def expand_recurring_local(
+ ev: models.LocalEvent,
+ local_cal: models.LocalCalendar,
+ range_start,
+ range_end,
+ *,
+ creator: Optional[dict] = None,
+ owner: Optional[dict] = None,
+ is_group_event: bool = False,
+) -> list:
+ """Expand a recurring LocalEvent into individual occurrences in the range."""
+ results = []
+ excluded = set()
+ if ev.exdate:
+ for d in ev.exdate.split(","):
+ d = d.strip()
+ if d:
+ excluded.add(d)
+ try:
+ ev_start_str = ev.start.replace("Z", "+00:00")
+ ev_end_str = ev.end.replace("Z", "+00:00")
+
+ if ev.all_day:
+ ev_start = dt_date.fromisoformat(ev_start_str[:10])
+ ev_end = dt_date.fromisoformat(ev_end_str[:10])
+ duration = ev_end - ev_start
+ rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
+ r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
+ r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
+ occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
+ for occ in occurrences:
+ occ_start = occ.date()
+ occ_key = occ_start.strftime("%Y%m%d")
+ if occ_key in excluded:
+ continue
+ occ_end = occ_start + duration
+ results.append(build_local_event_dict(
+ ev, local_cal,
+ start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
+ creator=creator, owner=owner, is_group_event=is_group_event,
+ ))
+ else:
+ ev_start = dt_datetime.fromisoformat(ev_start_str)
+ ev_end = dt_datetime.fromisoformat(ev_end_str)
+ if ev_start.tzinfo is None:
+ ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
+ if ev_end.tzinfo is None:
+ ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
+ duration = ev_end - ev_start
+ rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
+ r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
+ r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
+ if r_start.tzinfo is None:
+ r_start = r_start.replace(tzinfo=dt_timezone.utc)
+ if r_end.tzinfo is None:
+ r_end = r_end.replace(tzinfo=dt_timezone.utc)
+ occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
+ for occ in occurrences:
+ occ_key = occ.strftime("%Y%m%d")
+ if occ_key in excluded:
+ continue
+ occ_end = occ + duration
+ results.append(build_local_event_dict(
+ ev, local_cal,
+ start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
+ creator=creator, owner=owner, is_group_event=is_group_event,
+ ))
+ except Exception as exc:
+ logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
+ # Fall back to a single event.
+ results.append(build_local_event_dict(
+ ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
+ ))
+ return results
diff --git a/backend/main.py b/backend/main.py
index 3312035..562fa3a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
-from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
+from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
@@ -132,6 +132,32 @@ def _migrate():
except Exception:
pass
+ # ── Collaboration features (sharing, groups, creator, private) ──
+ try:
+ conn.execute(text("ALTER TABLE user_settings ADD COLUMN private_event_visibility VARCHAR(10) DEFAULT 'busy'"))
+ conn.commit()
+ logging.info("Migration: added private_event_visibility to user_settings")
+ except Exception:
+ pass
+ try:
+ conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_id INTEGER"))
+ conn.commit()
+ logging.info("Migration: added creator_id to local_events")
+ except Exception:
+ pass
+ try:
+ conn.execute(text("ALTER TABLE local_events ADD COLUMN creator_name_external TEXT"))
+ conn.commit()
+ logging.info("Migration: added creator_name_external to local_events")
+ except Exception:
+ pass
+ try:
+ conn.execute(text("ALTER TABLE local_events ADD COLUMN is_private BOOLEAN DEFAULT 0"))
+ conn.commit()
+ logging.info("Migration: added is_private to local_events")
+ except Exception:
+ pass
+
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@@ -170,6 +196,7 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
+app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
diff --git a/backend/models.py b/backend/models.py
index 18f6cc7..5b3fb39 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from database import Base
@@ -34,6 +34,11 @@ class User(Base):
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
)
+ @property
+ def display_name(self) -> str:
+ """No dedicated display-name column exists — fall back to the username."""
+ return self.username
+
class CalDAVAccount(Base):
__tablename__ = "caldav_accounts"
@@ -87,6 +92,9 @@ class UserSettings(Base):
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
+ # How this user's private events appear to other group members:
+ # 'hidden' = invisible, 'busy' = anonymous busy block (default).
+ private_event_visibility = Column(String(10), default="busy")
user = relationship("User", back_populates="settings")
@@ -119,8 +127,15 @@ class LocalEvent(Base):
color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
+ # Creator: set server-side from the auth token on create, never from the client.
+ creator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
+ # For imported events without a local user (from the .ics ORGANIZER field).
+ creator_name_external = Column(Text, nullable=True)
+ # Private events are filtered for other group members per their visibility setting.
+ is_private = Column(Boolean, default=False)
calendar = relationship("LocalCalendar", back_populates="events")
+ creator = relationship("User")
class ICalSubscription(Base):
@@ -219,3 +234,74 @@ class HomeAssistantCalendar(Base):
sidebar_hidden = Column(Boolean, default=False)
account = relationship("HomeAssistantAccount", back_populates="calendars")
+
+
+# ── Collaboration: sharing & groups (local calendars only) ────────────────
+
+
+class CalendarShare(Base):
+ """A local calendar shared with another Calendarr user."""
+
+ __tablename__ = "calendar_shares"
+ __table_args__ = (
+ UniqueConstraint("calendar_id", "user_id", name="uq_calendar_share"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ permission = Column(String(20), default="read") # 'read' | 'read_write'
+ created_at = Column(String(50), nullable=True) # ISO 8601
+
+ calendar = relationship("LocalCalendar")
+ user = relationship("User")
+
+
+class Group(Base):
+ __tablename__ = "groups"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String(100), nullable=False)
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
+ created_at = Column(String(50), nullable=True) # ISO 8601
+
+ members = relationship(
+ "GroupMember", back_populates="group", cascade="all, delete-orphan"
+ )
+ group_calendar = relationship(
+ "GroupCalendar", back_populates="group", uselist=False,
+ cascade="all, delete-orphan",
+ )
+
+
+class GroupMember(Base):
+ __tablename__ = "group_members"
+ __table_args__ = (
+ UniqueConstraint("group_id", "user_id", name="uq_group_member"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ role = Column(String(10), default="member") # 'owner' | 'member'
+ joined_at = Column(String(50), nullable=True) # ISO 8601
+
+ group = relationship("Group", back_populates="members")
+ user = relationship("User")
+
+
+class GroupCalendar(Base):
+ """1:1 link between a group and its shared local calendar."""
+
+ __tablename__ = "group_calendars"
+ __table_args__ = (
+ UniqueConstraint("group_id", name="uq_group_calendar_group"),
+ UniqueConstraint("calendar_id", name="uq_group_calendar_calendar"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
+ calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
+
+ group = relationship("Group", back_populates="group_calendar")
+ calendar = relationship("LocalCalendar")
diff --git a/backend/permissions.py b/backend/permissions.py
new file mode 100644
index 0000000..3c86c3b
--- /dev/null
+++ b/backend/permissions.py
@@ -0,0 +1,126 @@
+"""Central access control for local calendars.
+
+Local calendars are visible/writable to a user if any of the following holds:
+ - the user owns the calendar (LocalCalendar.user_id),
+ - the calendar is shared with the user (calendar_shares; write needs 'read_write'),
+ - the calendar is a group calendar and the user is a member of that group
+ (members get read & write).
+
+These helpers replace the scattered owner-only filters so sharing and groups
+work consistently across every local-calendar endpoint and the event merge read.
+"""
+
+from typing import Optional
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+import models
+
+
+def _share_for(db: Session, calendar_id: int, user_id: int) -> Optional[models.CalendarShare]:
+ return (
+ db.query(models.CalendarShare)
+ .filter(
+ models.CalendarShare.calendar_id == calendar_id,
+ models.CalendarShare.user_id == user_id,
+ )
+ .first()
+ )
+
+
+def _is_group_calendar_member(db: Session, calendar_id: int, user_id: int) -> bool:
+ gc = (
+ db.query(models.GroupCalendar)
+ .filter(models.GroupCalendar.calendar_id == calendar_id)
+ .first()
+ )
+ if not gc:
+ return False
+ member = (
+ db.query(models.GroupMember)
+ .filter(
+ models.GroupMember.group_id == gc.group_id,
+ models.GroupMember.user_id == user_id,
+ )
+ .first()
+ )
+ return member is not None
+
+
+def accessible_local_calendar(
+ db: Session,
+ user: models.User,
+ calendar_id: int,
+ *,
+ require_write: bool = False,
+) -> models.LocalCalendar:
+ """Return the calendar if the user may access it, else raise 404/403.
+
+ 404 when the calendar does not exist or is not visible to the user (so we
+ don't leak existence). 403 when it is visible (read) but write is required.
+ """
+ cal = (
+ db.query(models.LocalCalendar)
+ .filter(models.LocalCalendar.id == calendar_id)
+ .first()
+ )
+ if not cal:
+ raise HTTPException(404, "Calendar not found")
+
+ if cal.user_id == user.id:
+ return cal # owner: full access
+
+ if _is_group_calendar_member(db, calendar_id, user.id):
+ return cal # group members get read & write
+
+ share = _share_for(db, calendar_id, user.id)
+ if share is None:
+ raise HTTPException(404, "Calendar not found")
+ if require_write and share.permission != "read_write":
+ raise HTTPException(403, "You only have read access to this calendar")
+ return cal
+
+
+def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
+ """Return the calendar only if the user owns it, else raise 404."""
+ cal = (
+ db.query(models.LocalCalendar)
+ .filter(
+ models.LocalCalendar.id == calendar_id,
+ models.LocalCalendar.user_id == user.id,
+ )
+ .first()
+ )
+ if not cal:
+ raise HTTPException(404, "Calendar not found")
+ return cal
+
+
+def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
+ """All local calendar ids the user may read: own + shared + group calendars."""
+ ids: set[int] = set()
+
+ own = (
+ db.query(models.LocalCalendar.id)
+ .filter(models.LocalCalendar.user_id == user.id)
+ .all()
+ )
+ ids.update(r[0] for r in own)
+
+ shared = (
+ db.query(models.CalendarShare.calendar_id)
+ .filter(models.CalendarShare.user_id == user.id)
+ .all()
+ )
+ ids.update(r[0] for r in shared)
+
+ group_cals = (
+ db.query(models.GroupCalendar.calendar_id)
+ .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
+ .filter(models.GroupMember.user_id == user.id)
+ .all()
+ )
+ ids.update(r[0] for r in group_cals)
+
+ return list(ids)
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index 98e2304..63cdac8 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -11,8 +11,10 @@ from sqlalchemy import or_
import caldav_client
import models
+import permissions
from auth import get_current_user
from database import get_db
+from local_events_util import build_local_event_dict, expand_recurring_local, resolve_creator
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
logger = logging.getLogger(__name__)
@@ -82,101 +84,6 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
}
-def _expand_recurring_local(ev, local_cal, range_start, range_end):
- """Expand a recurring LocalEvent into individual occurrences within the date range."""
- results = []
- # Parse excluded dates
- excluded = set()
- if ev.exdate:
- for d in ev.exdate.split(","):
- d = d.strip()
- if d:
- excluded.add(d)
- try:
- ev_start_str = ev.start.replace("Z", "+00:00")
- ev_end_str = ev.end.replace("Z", "+00:00")
-
- if ev.all_day:
- ev_start = dt_date.fromisoformat(ev_start_str[:10])
- ev_end = dt_date.fromisoformat(ev_end_str[:10])
- duration = ev_end - ev_start
- rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
- r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
- r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
- occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
- for occ in occurrences:
- occ_start = occ.date()
- occ_key = occ_start.strftime("%Y%m%d")
- if occ_key in excluded:
- continue
- occ_end = occ_start + duration
- results.append({
- "id": ev.uid,
- "url": f"local://{ev.uid}",
- "title": ev.title,
- "start": occ_start.isoformat(),
- "end": occ_end.isoformat(),
- "allDay": True,
- "location": ev.location or "",
- "description": ev.description or "",
- "color": ev.color,
- "rrule": ev.rrule,
- "calendar_id": f"local-{local_cal.id}",
- "calendar_name": local_cal.name,
- "calendarColor": local_cal.color,
- "source": "local",
- })
- else:
- ev_start = dt_datetime.fromisoformat(ev_start_str)
- ev_end = dt_datetime.fromisoformat(ev_end_str)
- if ev_start.tzinfo is None:
- ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
- if ev_end.tzinfo is None:
- ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
- duration = ev_end - ev_start
- rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
- r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
- r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
- if r_start.tzinfo is None:
- r_start = r_start.replace(tzinfo=dt_timezone.utc)
- if r_end.tzinfo is None:
- r_end = r_end.replace(tzinfo=dt_timezone.utc)
- occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
- for occ in occurrences:
- occ_key = occ.strftime("%Y%m%d")
- if occ_key in excluded:
- continue
- occ_end = occ + duration
- results.append({
- "id": ev.uid,
- "url": f"local://{ev.uid}",
- "title": ev.title,
- "start": occ.isoformat(),
- "end": occ_end.isoformat(),
- "allDay": False,
- "location": ev.location or "",
- "description": ev.description or "",
- "color": ev.color,
- "rrule": ev.rrule,
- "calendar_id": f"local-{local_cal.id}",
- "calendar_name": local_cal.name,
- "calendarColor": local_cal.color,
- "source": "local",
- })
- except Exception as exc:
- logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
- # Fall back to single event
- results.append({
- "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
- "start": ev.start, "end": ev.end, "allDay": ev.all_day,
- "location": ev.location or "", "description": ev.description or "",
- "color": ev.color, "rrule": ev.rrule,
- "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
- "calendarColor": local_cal.color, "source": "local",
- })
- return results
-
-
def _normalize_url(url: str) -> str:
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
parsed = urlparse(url)
@@ -417,15 +324,17 @@ def get_events(
"Error fetching calendar %s: %s", calendar.id, exc
)
- # ── Local calendar events ─────────────────────────────
+ # ── Local calendar events (own + shared + group calendars) ─────────────
+ readable_ids = permissions.readable_local_calendar_ids(db, current_user)
local_calendars = (
db.query(models.LocalCalendar)
.filter(
- models.LocalCalendar.user_id == current_user.id,
+ models.LocalCalendar.id.in_(readable_ids),
models.LocalCalendar.enabled == True,
)
.all()
- )
+ ) if readable_ids else []
+ name_cache = {u.id: u.username for u in db.query(models.User).all()}
for local_cal in local_calendars:
local_events = (
db.query(models.LocalEvent)
@@ -441,25 +350,11 @@ def get_events(
.all()
)
for ev in local_events:
+ creator = resolve_creator(ev, name_cache=name_cache)
if ev.rrule:
- all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
+ all_events.extend(expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator))
else:
- all_events.append({
- "id": ev.uid,
- "url": f"local://{ev.uid}",
- "title": ev.title,
- "start": ev.start,
- "end": ev.end,
- "allDay": ev.all_day,
- "location": ev.location or "",
- "description": ev.description or "",
- "color": ev.color,
- "rrule": None,
- "calendar_id": f"local-{local_cal.id}",
- "calendar_name": local_cal.name,
- "calendarColor": local_cal.color,
- "source": "local",
- })
+ all_events.append(build_local_event_dict(ev, local_cal, rrule=None, creator=creator))
# ── iCal subscription events ──────────────────────────
ical_subs = (
diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py
new file mode 100644
index 0000000..f3314e1
--- /dev/null
+++ b/backend/routers/groups_router.py
@@ -0,0 +1,334 @@
+"""Groups: shared group calendar + combined member-calendar overlay view.
+
+A group has members and exactly one group calendar (a local calendar owned by
+the creator, linked via group_calendars). Members get read/write on the group
+calendar (enforced by permissions.accessible_local_calendar). The combined view
+overlays every member's local calendars plus the group calendar, applying each
+member's private-event visibility setting.
+"""
+
+import logging
+from datetime import datetime, timezone
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+import models
+from auth import get_current_user
+from database import get_db
+from local_events_util import build_local_event_dict, expand_recurring_local
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+class GroupCreate(BaseModel):
+ name: str
+ member_ids: List[int] = []
+
+
+class MemberAdd(BaseModel):
+ user_id: int
+
+
+def _membership(db: Session, group_id: int, user_id: int) -> Optional[models.GroupMember]:
+ return (
+ db.query(models.GroupMember)
+ .filter(
+ models.GroupMember.group_id == group_id,
+ models.GroupMember.user_id == user_id,
+ )
+ .first()
+ )
+
+
+def _require_member(db: Session, group: models.Group, user: models.User) -> models.GroupMember:
+ m = _membership(db, group.id, user.id)
+ if not m:
+ raise HTTPException(403, "You are not a member of this group")
+ return m
+
+
+def _require_owner(db: Session, group: models.Group, user: models.User) -> None:
+ m = _membership(db, group.id, user.id)
+ if not m or m.role != "owner":
+ raise HTTPException(403, "Only the group owner may do this")
+
+
+def _get_group_or_404(db: Session, group_id: int) -> models.Group:
+ g = db.query(models.Group).filter(models.Group.id == group_id).first()
+ if not g:
+ raise HTTPException(404, "Group not found")
+ return g
+
+
+def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
+ gc = (
+ db.query(models.GroupCalendar)
+ .filter(models.GroupCalendar.group_id == group_id)
+ .first()
+ )
+ return gc.calendar_id if gc else None
+
+
+@router.post("/")
+def create_group(
+ data: GroupCreate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = models.Group(name=data.name, created_by=current_user.id, created_at=_now_iso())
+ db.add(group)
+ db.flush()
+
+ # Creator is owner; add the requested members (deduped, excluding creator).
+ db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", joined_at=_now_iso()))
+ seen = {current_user.id}
+ for uid in data.member_ids:
+ if uid in seen:
+ continue
+ if not db.query(models.User).filter(models.User.id == uid).first():
+ continue
+ db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", joined_at=_now_iso()))
+ seen.add(uid)
+
+ # Auto-create the group calendar (a local calendar owned by the creator).
+ cal = models.LocalCalendar(
+ user_id=current_user.id,
+ name=f"{data.name} (Gruppe)",
+ color=PALETTE[group.id % len(PALETTE)],
+ )
+ db.add(cal)
+ db.flush()
+ db.add(models.GroupCalendar(group_id=group.id, calendar_id=cal.id))
+
+ db.commit()
+ db.refresh(group)
+ return _group_detail(db, group, current_user)
+
+
+@router.get("/")
+def list_groups(
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ memberships = (
+ db.query(models.GroupMember)
+ .filter(models.GroupMember.user_id == current_user.id)
+ .all()
+ )
+ out = []
+ for m in memberships:
+ group = db.query(models.Group).filter(models.Group.id == m.group_id).first()
+ if not group:
+ continue
+ member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count()
+ out.append({
+ "id": group.id,
+ "name": group.name,
+ "role": m.role,
+ "member_count": member_count,
+ "group_calendar_id": _group_calendar_id(db, group.id),
+ })
+ return out
+
+
+def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict:
+ members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all()
+ member_dicts = []
+ for m in members:
+ u = db.query(models.User).filter(models.User.id == m.user_id).first()
+ member_dicts.append({
+ "id": m.user_id,
+ "display_name": u.username if u else None,
+ "role": m.role,
+ })
+ return {
+ "id": group.id,
+ "name": group.name,
+ "created_by": group.created_by,
+ "members": member_dicts,
+ "group_calendar_id": _group_calendar_id(db, group.id),
+ }
+
+
+@router.get("/{group_id}")
+def get_group(
+ group_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ _require_member(db, group, current_user)
+ return _group_detail(db, group, current_user)
+
+
+@router.post("/{group_id}/members")
+def add_member(
+ group_id: int,
+ data: MemberAdd,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ _require_owner(db, group, current_user)
+ if not db.query(models.User).filter(models.User.id == data.user_id).first():
+ raise HTTPException(404, "User not found")
+ if _membership(db, group_id, data.user_id):
+ return {"ok": True} # already a member
+ db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", joined_at=_now_iso()))
+ db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{group_id}/members/{user_id}")
+def remove_member(
+ group_id: int,
+ user_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ # Owner can remove anyone; a member may remove themselves (leave).
+ if user_id != current_user.id:
+ _require_owner(db, group, current_user)
+ else:
+ _require_member(db, group, current_user)
+ target = _membership(db, group_id, user_id)
+ if not target:
+ raise HTTPException(404, "Member not found")
+ if target.role == "owner":
+ raise HTTPException(422, "The owner cannot be removed; delete the group instead")
+ db.delete(target)
+ db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{group_id}")
+def delete_group(
+ group_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ _require_owner(db, group, current_user)
+ # Remove the group calendar (and its events) too.
+ gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
+ if gc:
+ cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
+ if cal:
+ db.delete(cal) # cascades to events
+ db.delete(group) # cascades to members + group_calendar link
+ db.commit()
+ return {"ok": True}
+
+
+def _strip_busy(event: dict) -> dict:
+ """Anonymise a private event for the 'busy' visibility mode."""
+ event = dict(event)
+ event["title"] = "Beschäftigt"
+ event["location"] = ""
+ event["description"] = ""
+ event["private"] = True
+ return event
+
+
+@router.get("/{group_id}/combined")
+def combined_events(
+ group_id: int,
+ start: str = Query(...),
+ end: str = Query(...),
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ _require_member(db, group, current_user)
+
+ try:
+ start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
+ end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
+ except ValueError:
+ raise HTTPException(400, "Invalid date format — use ISO 8601")
+ if start_dt.tzinfo is None:
+ start_dt = start_dt.replace(tzinfo=timezone.utc)
+ if end_dt.tzinfo is None:
+ end_dt = end_dt.replace(tzinfo=timezone.utc)
+
+ members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
+ name_cache = {u.id: u.username for u in db.query(models.User).all()}
+ visibility_cache: dict[int, str] = {}
+
+ def visibility_for(user_id: int) -> str:
+ if user_id not in visibility_cache:
+ s = db.query(models.UserSettings).filter(models.UserSettings.user_id == user_id).first()
+ visibility_cache[user_id] = (s.private_event_visibility if s else None) or "busy"
+ return visibility_cache[user_id]
+
+ group_cal_id = _group_calendar_id(db, group_id)
+ all_events: list[dict] = []
+
+ def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
+ owner_user = name_cache.get(owner_id)
+ owner = {"id": owner_id, "display_name": owner_user}
+ events = (
+ db.query(models.LocalEvent)
+ .filter(
+ models.LocalEvent.calendar_id == cal.id,
+ or_(
+ (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
+ models.LocalEvent.rrule != None,
+ ),
+ )
+ .all()
+ )
+ for ev in events:
+ creator_owner_id = ev.creator_id or owner_id
+ # Private filtering for events that belong to someone else.
+ if ev.is_private and creator_owner_id != current_user.id:
+ vis = visibility_for(creator_owner_id)
+ if vis == "hidden":
+ continue
+ creator = None
+ if ev.creator_id and name_cache.get(ev.creator_id):
+ creator = {"id": ev.creator_id, "display_name": name_cache[ev.creator_id]}
+ elif ev.creator_name_external:
+ creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
+
+ if ev.rrule:
+ built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group)
+ else:
+ built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
+
+ for b in built:
+ if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
+ b = _strip_busy(b)
+ all_events.append(b)
+
+ # Each member's own local calendars (excluding the group calendar to avoid dupes).
+ for m in members:
+ member_cals = (
+ db.query(models.LocalCalendar)
+ .filter(models.LocalCalendar.user_id == m.user_id)
+ .all()
+ )
+ for cal in member_cals:
+ if group_cal_id is not None and cal.id == group_cal_id:
+ continue
+ emit_calendar(cal, m.user_id, is_group=False)
+
+ # The group calendar itself.
+ if group_cal_id is not None:
+ group_cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == group_cal_id).first()
+ if group_cal:
+ emit_calendar(group_cal, group_cal.user_id, is_group=True)
+
+ return {"events": all_events}
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index e7d6b97..7144c8b 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -1,17 +1,26 @@
import uuid
+from datetime import datetime, timezone
from typing import Optional
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
+from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
+import ical_io
import models
+import permissions
from auth import get_current_user
from database import get_db
+from local_events_util import build_local_event_dict, resolve_creator
router = APIRouter()
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
class CalendarCreate(BaseModel):
name: str
color: str = "#34a853"
@@ -33,6 +42,7 @@ class EventCreate(BaseModel):
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
+ private: bool = False
class EventUpdate(BaseModel):
@@ -45,35 +55,33 @@ class EventUpdate(BaseModel):
color: Optional[str] = None
rrule: Optional[str] = None
exdate: Optional[str] = None
+ private: Optional[bool] = None
-def _cal_dict(cal: models.LocalCalendar) -> dict:
- return {
+class ShareCreate(BaseModel):
+ user_id: int
+ permission: str = "read"
+
+
+def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
+ shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
+ d = {
"id": cal.id,
"name": cal.name,
"color": cal.color,
"enabled": cal.enabled,
+ "type": "local",
+ "owned": owned,
}
+ if shared_by is not None:
+ d["shared_by"] = shared_by
+ if permission is not None:
+ d["permission"] = permission
+ return d
-def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
- return {
- "id": ev.uid,
- "url": f"local://{ev.uid}",
- "title": ev.title,
- "start": ev.start,
- "end": ev.end,
- "allDay": ev.all_day,
- "location": ev.location or "",
- "description": ev.description or "",
- "color": ev.color,
- "rrule": ev.rrule,
- "exdate": ev.exdate,
- "calendar_id": f"local-{cal.id}",
- "calendar_name": cal.name,
- "calendarColor": cal.color,
- "source": "local",
- }
+def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
+ return build_local_event_dict(ev, cal, creator=resolve_creator(ev))
# ── Calendar CRUD ─────────────────────────────────────────
@@ -83,12 +91,31 @@ def list_calendars(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- cals = (
+ # Own calendars
+ own = (
db.query(models.LocalCalendar)
.filter(models.LocalCalendar.user_id == current_user.id)
.all()
)
- return [_cal_dict(c) for c in cals]
+ result = [_cal_dict(c, owned=True) for c in own]
+
+ # Calendars shared with this user
+ shares = (
+ db.query(models.CalendarShare)
+ .filter(models.CalendarShare.user_id == current_user.id)
+ .all()
+ )
+ for share in shares:
+ cal = share.calendar
+ if cal is None:
+ continue
+ owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
+ result.append(_cal_dict(
+ cal, owned=False,
+ shared_by=owner.username if owner else None,
+ permission=share.permission,
+ ))
+ return result
@router.post("/calendars")
@@ -164,16 +191,10 @@ def create_event(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- cal = (
- db.query(models.LocalCalendar)
- .filter(
- models.LocalCalendar.id == data.calendar_id,
- models.LocalCalendar.user_id == current_user.id,
- )
- .first()
+ # Owner, shared (read_write), or group-member calendars are writable.
+ cal = permissions.accessible_local_calendar(
+ db, current_user, data.calendar_id, require_write=True
)
- if not cal:
- raise HTTPException(404, "Calendar not found")
ev = models.LocalEvent(
calendar_id=cal.id,
@@ -186,11 +207,22 @@ def create_event(
description=data.description,
color=data.color,
rrule=data.rrule,
+ is_private=data.private,
+ creator_id=current_user.id, # server-side, never from the client
)
db.add(ev)
db.commit()
db.refresh(ev)
- return _event_dict(ev, cal)
+ return _event_dict(ev, cal, db)
+
+
+def _writable_event(db: Session, current_user: models.User, uid: str) -> models.LocalEvent:
+ ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
+ if not ev:
+ raise HTTPException(404, "Event not found")
+ # Raises 404/403 unless the user may write this event's calendar.
+ permissions.accessible_local_calendar(db, current_user, ev.calendar_id, require_write=True)
+ return ev
@router.put("/events/{uid}")
@@ -200,17 +232,9 @@ def update_event(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- ev = (
- db.query(models.LocalEvent)
- .join(models.LocalCalendar)
- .filter(
- models.LocalEvent.uid == uid,
- models.LocalCalendar.user_id == current_user.id,
- )
- .first()
- )
- if not ev:
- raise HTTPException(404, "Event not found")
+ ev = _writable_event(db, current_user, uid)
+ if data.private is not None:
+ ev.is_private = data.private
if data.title is not None:
ev.title = data.title
if data.start is not None:
@@ -243,17 +267,194 @@ def delete_event(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- ev = (
- db.query(models.LocalEvent)
- .join(models.LocalCalendar)
- .filter(
- models.LocalEvent.uid == uid,
- models.LocalCalendar.user_id == current_user.id,
- )
- .first()
- )
- if not ev:
- raise HTTPException(404, "Event not found")
+ ev = _writable_event(db, current_user, uid)
db.delete(ev)
db.commit()
return {"ok": True}
+
+
+# ── Sharing (owner only) ──────────────────────────────────
+
+@router.get("/calendars/{calendar_id}/shares")
+def list_shares(
+ calendar_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ permissions.is_calendar_owner(db, current_user, calendar_id)
+ shares = (
+ db.query(models.CalendarShare)
+ .filter(models.CalendarShare.calendar_id == calendar_id)
+ .all()
+ )
+ out = []
+ for s in shares:
+ u = db.query(models.User).filter(models.User.id == s.user_id).first()
+ out.append({
+ "user_id": s.user_id,
+ "display_name": u.username if u else None,
+ "permission": s.permission,
+ "created_at": s.created_at,
+ })
+ return out
+
+
+@router.post("/calendars/{calendar_id}/shares")
+def add_share(
+ calendar_id: int,
+ data: ShareCreate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ permissions.is_calendar_owner(db, current_user, calendar_id)
+ if data.permission not in ("read", "read_write"):
+ raise HTTPException(422, "permission must be 'read' or 'read_write'")
+ target = db.query(models.User).filter(models.User.id == data.user_id).first()
+ if not target:
+ raise HTTPException(404, "User not found")
+ if target.id == current_user.id:
+ raise HTTPException(422, "Cannot share a calendar with yourself")
+
+ share = (
+ db.query(models.CalendarShare)
+ .filter(
+ models.CalendarShare.calendar_id == calendar_id,
+ models.CalendarShare.user_id == data.user_id,
+ )
+ .first()
+ )
+ if share:
+ share.permission = data.permission # update existing
+ else:
+ share = models.CalendarShare(
+ calendar_id=calendar_id,
+ user_id=data.user_id,
+ permission=data.permission,
+ created_at=_now_iso(),
+ )
+ db.add(share)
+ db.commit()
+ return {"ok": True}
+
+
+@router.delete("/calendars/{calendar_id}/shares/{user_id}")
+def remove_share(
+ calendar_id: int,
+ user_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ permissions.is_calendar_owner(db, current_user, calendar_id)
+ share = (
+ db.query(models.CalendarShare)
+ .filter(
+ models.CalendarShare.calendar_id == calendar_id,
+ models.CalendarShare.user_id == user_id,
+ )
+ .first()
+ )
+ if not share:
+ raise HTTPException(404, "Share not found")
+ db.delete(share)
+ db.commit()
+ return {"ok": True}
+
+
+# ── iCal Import / Export (local calendars only) ───────────
+
+def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict:
+ parsed = ical_io.parse_ics(raw)
+ imported = 0
+ skipped = 0
+ for item in parsed["events"]:
+ uid = item.get("uid") or str(uuid.uuid4())
+ existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
+ if existing:
+ skipped += 1
+ continue
+ ev = models.LocalEvent(
+ calendar_id=cal.id,
+ uid=uid,
+ title=item.get("title") or "(ohne Titel)",
+ start=item["start"],
+ end=item["end"],
+ all_day=item.get("all_day", False),
+ location=item.get("location"),
+ description=item.get("description"),
+ rrule=item.get("rrule"),
+ exdate=item.get("exdate"),
+ creator_name_external=item.get("organizer"),
+ )
+ db.add(ev)
+ imported += 1
+ db.commit()
+ return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]}
+
+
+@router.post("/calendars/{calendar_id}/import")
+async def import_calendar(
+ calendar_id: int,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
+ raw = await file.read()
+ try:
+ return _import_ics_into(cal, raw, db)
+ except ValueError as e:
+ raise HTTPException(422, str(e))
+
+
+@router.post("/import")
+async def import_generic(
+ file: UploadFile = File(...),
+ calendar_id: Optional[int] = Form(None),
+ create_calendar: bool = Form(False),
+ calendar_name: Optional[str] = Form(None),
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ if create_calendar:
+ cal = models.LocalCalendar(
+ user_id=current_user.id,
+ name=calendar_name or "Importiert",
+ )
+ db.add(cal)
+ db.commit()
+ db.refresh(cal)
+ elif calendar_id is not None:
+ cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
+ else:
+ raise HTTPException(422, "Provide calendar_id or create_calendar=true")
+
+ raw = await file.read()
+ try:
+ result = _import_ics_into(cal, raw, db)
+ except ValueError as e:
+ raise HTTPException(422, str(e))
+ result["calendar_id"] = cal.id
+ return result
+
+
+@router.get("/calendars/{calendar_id}/export")
+def export_calendar(
+ calendar_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
+ events = (
+ db.query(models.LocalEvent)
+ .filter(models.LocalEvent.calendar_id == cal.id)
+ .all()
+ )
+ # Resolve creator display names for ORGANIZER.
+ name_cache = {u.id: u.username for u in db.query(models.User).all()}
+ ics = ical_io.build_ics(cal, events, name_cache=name_cache)
+ safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
+ return Response(
+ content=ics,
+ media_type="text/calendar",
+ headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
+ )
diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py
index b5c639d..1caad75 100644
--- a/backend/routers/settings_router.py
+++ b/backend/routers/settings_router.py
@@ -1,6 +1,6 @@
from typing import Optional
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -27,6 +27,7 @@ class SettingsUpdate(BaseModel):
text_color: Optional[str] = None
line_color: Optional[str] = None
bg_color: Optional[str] = None
+ private_event_visibility: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict:
@@ -46,6 +47,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
+ "private_event_visibility": s.private_event_visibility or "busy",
}
@@ -82,6 +84,9 @@ def update_settings(
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
+ if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
+ raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
+
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py
index 7b50248..35fbd08 100644
--- a/backend/routers/users_router.py
+++ b/backend/routers/users_router.py
@@ -35,6 +35,25 @@ def list_users(
return [_user_dict(u) for u in db.query(models.User).all()]
+@router.get("/directory")
+def user_directory(
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ """Lightweight list of all users (id + display_name) for sharing/group pickers.
+
+ Available to any authenticated user (unlike GET / which is admin-only).
+ Excludes the requesting user.
+ """
+ users = (
+ db.query(models.User)
+ .filter(models.User.id != current_user.id)
+ .order_by(models.User.username)
+ .all()
+ )
+ return [{"id": u.id, "display_name": u.username} for u in users]
+
+
@router.post("/")
def create_user(
req: CreateUserRequest,
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..105e3fd
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,61 @@
+"""Pytest fixtures: an isolated app + temp SQLite DB, wiped between tests."""
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+# Use a throwaway data dir BEFORE importing the app (database.py reads DATA_DIR
+# at import time and builds the engine from it).
+os.environ.setdefault("DATA_DIR", tempfile.mkdtemp(prefix="calendarr-test-"))
+os.environ.setdefault("SECRET_KEY", "test-secret-key")
+
+BACKEND_DIR = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND_DIR))
+
+import pytest
+from fastapi.testclient import TestClient
+
+import main # noqa: E402 (creates tables + runs migrations against the temp DB)
+import models # noqa: E402
+from database import engine # noqa: E402
+
+
+@pytest.fixture
+def client():
+ return TestClient(main.app)
+
+
+@pytest.fixture(autouse=True)
+def clean_db():
+ """Wipe every table before each test for isolation."""
+ with engine.begin() as conn:
+ for table in reversed(models.Base.metadata.sorted_tables):
+ conn.execute(table.delete())
+ yield
+
+
+# ── Helpers ───────────────────────────────────────────────
+
+def register_admin(client, username="admin", password="pw"):
+ r = client.post("/api/auth/setup", json={"username": username, "password": password})
+ assert r.status_code == 200, r.text
+ return r.json()["access_token"]
+
+
+def create_user(client, admin_token, username, password="pw"):
+ r = client.post(
+ "/api/users/",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ json={"username": username, "password": password},
+ )
+ assert r.status_code == 200, r.text
+ uid = r.json()["id"]
+ # Log in to get the user's own token.
+ r2 = client.post("/api/auth/login", json={"username": username, "password": password})
+ assert r2.status_code == 200, r2.text
+ return uid, r2.json()["access_token"]
+
+
+def auth(token):
+ return {"Authorization": f"Bearer {token}"}
diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py
new file mode 100644
index 0000000..740cc24
--- /dev/null
+++ b/backend/tests/test_collaboration.py
@@ -0,0 +1,282 @@
+"""Tests for sharing, group permissions, the iCal parser, and private filtering."""
+
+from conftest import register_admin, create_user, auth
+
+RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
+
+
+def _make_calendar(client, token, name="Cal"):
+ r = client.post("/api/local/calendars", headers=auth(token), json={"name": name})
+ assert r.status_code == 200, r.text
+ return r.json()["id"]
+
+
+def _make_event(client, token, cal_id, title="Event", private=False,
+ start="2026-06-10T10:00:00+00:00", end="2026-06-10T11:00:00+00:00"):
+ r = client.post("/api/local/events", headers=auth(token), json={
+ "calendar_id": cal_id, "title": title, "start": start, "end": end,
+ "private": private,
+ })
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+# ── Sharing ───────────────────────────────────────────────
+
+def test_share_read_then_read_write(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+
+ cal_id = _make_calendar(client, admin, "Admins Kalender")
+ ev = _make_event(client, admin, cal_id, "Meeting")
+
+ # Creator field populated server-side.
+ assert ev["creator"]["display_name"] == "admin"
+ assert ev["type"] == "local"
+
+ # Share read-only with bob.
+ r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
+ json={"user_id": b_id, "permission": "read"})
+ assert r.status_code == 200, r.text
+
+ # Bob sees the shared calendar with shared_by.
+ cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
+ shared = [c for c in cals if not c["owned"]]
+ assert len(shared) == 1
+ assert shared[0]["shared_by"] == "admin"
+ assert shared[0]["permission"] == "read"
+
+ # Bob sees the event in the merged read.
+ events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
+ assert any(e["title"] == "Meeting" for e in events)
+
+ # Bob cannot write (read-only) -> 403.
+ r = client.post("/api/local/events", headers=auth(b_tok), json={
+ "calendar_id": cal_id, "title": "Nope",
+ "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
+ })
+ assert r.status_code == 403, r.text
+
+ # Upgrade to read_write -> bob can write.
+ client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(admin),
+ json={"user_id": b_id, "permission": "read_write"})
+ r = client.post("/api/local/events", headers=auth(b_tok), json={
+ "calendar_id": cal_id, "title": "Bobs Eintrag",
+ "start": "2026-06-11T10:00:00+00:00", "end": "2026-06-11T11:00:00+00:00",
+ })
+ assert r.status_code == 200, r.text
+ # Created by bob.
+ assert r.json()["creator"]["display_name"] == "bob"
+
+
+def test_non_owner_cannot_manage_shares(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ cal_id = _make_calendar(client, admin)
+ # Bob (no access at all) cannot list shares -> 404 (existence hidden).
+ r = client.get(f"/api/local/calendars/{cal_id}/shares", headers=auth(b_tok))
+ assert r.status_code == 404
+
+
+def test_unshared_calendar_invisible(client):
+ admin = register_admin(client)
+ _b_id, b_tok = create_user(client, admin, "bob")
+ cal_id = _make_calendar(client, admin)
+ _make_event(client, admin, cal_id, "Privat")
+ events = client.get("/api/caldav/events", headers=auth(b_tok), params=RANGE).json()["events"]
+ assert not any(e["title"] == "Privat" for e in events)
+
+
+# ── Groups ────────────────────────────────────────────────
+
+def test_group_create_and_members(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ c_id, c_tok = create_user(client, admin, "carol")
+
+ r = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Familie", "member_ids": [b_id]})
+ assert r.status_code == 200, r.text
+ group = r.json()
+ gid = group["id"]
+ assert group["group_calendar_id"] is not None
+ assert {m["display_name"] for m in group["members"]} == {"admin", "bob"}
+
+ # Both members see the group.
+ assert any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(b_tok)).json())
+ # Carol is not a member.
+ assert not any(g["id"] == gid for g in client.get("/api/groups/", headers=auth(c_tok)).json())
+ assert client.get(f"/api/groups/{gid}", headers=auth(c_tok)).status_code == 403
+
+ # Only owner adds members.
+ assert client.post(f"/api/groups/{gid}/members", headers=auth(b_tok),
+ json={"user_id": c_id}).status_code == 403
+ assert client.post(f"/api/groups/{gid}/members", headers=auth(admin),
+ json={"user_id": c_id}).status_code == 200
+
+ # Member can leave; owner cannot be removed.
+ assert client.delete(f"/api/groups/{gid}/members/{c_id}", headers=auth(c_tok)).status_code == 200
+ admin_id = client.get("/api/auth/me", headers=auth(admin)).json()["id"]
+ assert client.delete(f"/api/groups/{gid}/members/{admin_id}", headers=auth(admin)).status_code == 422
+
+
+def test_group_members_can_write_group_calendar(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ group = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Team", "member_ids": [b_id]}).json()
+ gcal = group["group_calendar_id"]
+ # Bob (member, not owner of the calendar) can create in the group calendar.
+ r = client.post("/api/local/events", headers=auth(b_tok), json={
+ "calendar_id": gcal, "title": "Teamtermin",
+ "start": "2026-06-12T09:00:00+00:00", "end": "2026-06-12T10:00:00+00:00",
+ })
+ assert r.status_code == 200, r.text
+
+
+def test_combined_view_marks_owner_and_group_event(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ group = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Team", "member_ids": [b_id]}).json()
+ gid = group["id"]
+ gcal = group["group_calendar_id"]
+
+ # Bob's own calendar + event.
+ b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
+ _make_event(client, b_tok, b_cal, "Bobs Termin")
+ # A group-calendar event.
+ _make_event(client, admin, gcal, "Gruppentermin")
+
+ events = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
+ titles = {e["title"]: e for e in events}
+ assert "Bobs Termin" in titles
+ assert titles["Bobs Termin"]["owner"]["display_name"] == "bob"
+ assert titles["Bobs Termin"].get("is_group_event") is not True
+ assert "Gruppentermin" in titles
+ assert titles["Gruppentermin"]["is_group_event"] is True
+
+
+# ── Private filtering ─────────────────────────────────────
+
+def _combined_titles(client, token, gid):
+ evs = client.get(f"/api/groups/{gid}/combined", headers=auth(token), params=RANGE).json()["events"]
+ return evs
+
+
+def test_private_visibility_hidden_and_busy(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ group = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Team", "member_ids": [b_id]}).json()
+ gid = group["id"]
+
+ b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
+ _make_event(client, b_tok, b_cal, "Geheimes", private=True,
+ start="2026-06-15T10:00:00+00:00", end="2026-06-15T11:00:00+00:00")
+
+ # Bob sees his own private event in full.
+ own = _combined_titles(client, b_tok, gid)
+ assert any(e["title"] == "Geheimes" for e in own)
+
+ # Default visibility = busy -> admin sees it as anonymous "Beschäftigt".
+ seen = _combined_titles(client, admin, gid)
+ busy = [e for e in seen if e["start"].startswith("2026-06-15")]
+ assert busy and all(e["title"] == "Beschäftigt" for e in busy)
+ assert all(e["location"] == "" and e["description"] == "" for e in busy)
+
+ # Switch bob to hidden -> admin no longer sees it at all.
+ client.put("/api/settings/", headers=auth(b_tok), json={"private_event_visibility": "hidden"})
+ seen2 = _combined_titles(client, admin, gid)
+ assert not any(e["start"].startswith("2026-06-15") for e in seen2)
+
+
+def test_private_visibility_validation(client):
+ admin = register_admin(client)
+ r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
+ assert r.status_code == 422
+
+
+# ── iCal import/export ────────────────────────────────────
+
+SAMPLE_ICS = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//EN
+BEGIN:VEVENT
+UID:evt-1@test
+SUMMARY:Importiert 1
+DTSTART:20260620T100000Z
+DTEND:20260620T110000Z
+LOCATION:Buero
+ORGANIZER;CN=Max Mustermann:mailto:max@example.com
+RRULE:FREQ=WEEKLY;BYDAY=MO
+END:VEVENT
+BEGIN:VEVENT
+UID:evt-2@test
+SUMMARY:Importiert 2
+DTSTART;VALUE=DATE:20260621
+DTEND;VALUE=DATE:20260622
+END:VEVENT
+END:VCALENDAR
+"""
+
+
+def test_ical_parser_roundtrip():
+ import ical_io
+ parsed = ical_io.parse_ics(SAMPLE_ICS)
+ assert len(parsed["events"]) == 2
+ ev1 = next(e for e in parsed["events"] if e["uid"] == "evt-1@test")
+ assert ev1["title"] == "Importiert 1"
+ assert ev1["location"] == "Buero"
+ assert ev1["organizer"] == "Max Mustermann"
+ assert ev1["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
+ ev2 = next(e for e in parsed["events"] if e["uid"] == "evt-2@test")
+ assert ev2["all_day"] is True
+
+
+def test_import_dedupes_by_uid(client):
+ admin = register_admin(client)
+ cal_id = _make_calendar(client, admin, "Import-Ziel")
+
+ files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
+ r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["imported"] == 2 and body["skipped"] == 0
+
+ # Re-import -> all skipped (UID dedupe).
+ files = {"file": ("test.ics", SAMPLE_ICS, "text/calendar")}
+ r2 = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
+ assert r2.json()["imported"] == 0 and r2.json()["skipped"] == 2
+
+ # Imported events carry the external creator name.
+ events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
+ imported = [e for e in events if e["title"] == "Importiert 1"]
+ assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
+
+
+def test_export_contains_organizer_and_rrule(client):
+ admin = register_admin(client)
+ cal_id = _make_calendar(client, admin, "Export-Test")
+ _make_event(client, admin, cal_id, "Wöchentlich")
+ # Add a recurring rule via update.
+ events = client.get("/api/caldav/events", headers=auth(admin), params=RANGE).json()["events"]
+ uid = next(e["id"] for e in events if e["title"] == "Wöchentlich")
+ client.put(f"/api/local/events/{uid}", headers=auth(admin), json={"rrule": "FREQ=WEEKLY;BYDAY=MO"})
+
+ r = client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(admin))
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/calendar")
+ body = r.text
+ assert "BEGIN:VCALENDAR" in body
+ assert "ORGANIZER" in body and "admin" in body
+ assert "RRULE" in body
+
+
+def test_import_export_only_local(client):
+ """Import/export endpoints reject non-existent / inaccessible calendars."""
+ admin = register_admin(client)
+ _b, b_tok = create_user(client, admin, "bob")
+ cal_id = _make_calendar(client, admin, "Privat")
+ # Bob has no access -> 404 on export.
+ assert client.get(f"/api/local/calendars/{cal_id}/export", headers=auth(b_tok)).status_code == 404
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..2f21eed
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+pytest>=8.0
+httpx>=0.27
--
2.47.3
From 8d2a697f8b64e38184a5fc5331985c927dbb0573 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 16:30:47 +0200
Subject: [PATCH 086/114] =?UTF-8?q?feat:=20Web-Frontend=20=E2=80=93=20Shar?=
=?UTF-8?q?ing,=20iCal=20Import/Export,=20Ersteller=20&=20Privat-Flag?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Ersteller-Zeile im Event-Popup (nur wenn Ersteller != aktueller User).
- Privat-Toggle im Event-Editor (nur lokale Kalender) + Sichtbarkeits-
Auswahl (hidden|busy) in den Einstellungen.
- Lokale Kalender in Settings & Sidebar: Teilen/Importieren/Exportieren-
Aktionen (nur eigene; geteilte mit "geteilt von"-Badge, kein Loeschen).
- Share-Modal: Benutzerverzeichnis mit Suche, read/read_write, Freigaben
entfernen.
- api.js: download()-Helper fuer iCal-Export (Blob).
Co-Authored-By: Claude Opus 4.8
---
frontend/css/app.css | 37 ++++++++
frontend/index.html | 44 ++++++++++
frontend/js/api.js | 34 ++++++++
frontend/js/calendar.js | 189 +++++++++++++++++++++++++++++++++++++---
frontend/js/i18n.js | 46 ++++++++++
5 files changed, 336 insertions(+), 14 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index d3a38f0..e519df7 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1764,3 +1764,40 @@ a { color: var(--primary); text-decoration: none; }
}
}
+
+/* ── Collaboration: sharing badges & user picker ───────────── */
+.cal-badge {
+ display: inline-block;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--bg-surface);
+ color: var(--text-2);
+ border: 1px solid var(--border);
+ white-space: nowrap;
+}
+.cal-badge-shared {
+ background: rgba(66, 133, 244, 0.15);
+ color: var(--primary);
+ border-color: transparent;
+}
+.share-user-picker {
+ margin-top: 8px;
+ max-height: 220px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+.share-user-item {
+ padding: 10px 14px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border);
+}
+.share-user-item:last-child { border-bottom: none; }
+.share-user-item:hover { background: var(--bg-surface); }
+.popup-creator {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--text-2);
+ font-style: italic;
+}
diff --git a/frontend/index.html b/frontend/index.html
index 5b030f8..df32297 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -319,6 +319,11 @@
+
+
+
@@ -344,6 +349,34 @@
+
+
+
+
+
+
Aktuelle Freigaben
+
+
+
Benutzer hinzufügen
+
+
+
+
+
+
+
+
+
+
@@ -672,6 +706,16 @@
+ Privatsphäre
+ Wie private Termine für andere Gruppenmitglieder erscheinen
+
+
+
+
+
Stundenhöhe (Wochen- & Tagesansicht)
Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt
diff --git a/frontend/js/api.js b/frontend/js/api.js
index b715277..cc12b4a 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -57,12 +57,46 @@ async function uploadRequest(path, formData) {
return res.json();
}
+async function downloadRequest(path, fallbackName) {
+ const token = localStorage.getItem('token');
+ const headers = {};
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+
+ const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
+ if (res.status === 401) {
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ window.location.reload();
+ return null;
+ }
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
+ throw new Error(err.detail || `HTTP ${res.status}`);
+ }
+ // Derive filename from Content-Disposition if present.
+ let filename = fallbackName || 'calendar.ics';
+ const cd = res.headers.get('Content-Disposition') || '';
+ const m = cd.match(/filename="?([^"]+)"?/);
+ if (m) filename = m[1];
+
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+}
+
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form),
+ download: (path, name) => downloadRequest(path, name),
login: (username, password, totp_code = null, remember_me = false) =>
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 8c4449e..2d2f748 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -538,19 +538,28 @@ function renderCalendarList() {
}).join('');
}
- // ── Local calendars ────────────────────────────────────
+ // ── Local calendars (own + shared with me) ─────────────
if (state.localCalendars.length) {
html += `
${t('cal_local')}
`;
- html += state.localCalendars.map(cal =>
- `
+ html += state.localCalendars.map(cal => {
+ const owned = cal.owned !== false;
+ // Shared calendars get an owner badge and no delete button (owner-only).
+ const sharedBadge = !owned
+ ? `
${escHtml(cal.shared_by || '')}`
+ : '';
+ const removeBtn = owned
+ ? `
+
+ `
+ : '';
+ return `
${escHtml(cal.name)}
-
-
-
-
`
- ).join('');
+ ${sharedBadge}
+ ${removeBtn}
+
`;
+ }).join('');
}
// ── iCal subscriptions ─────────────────────────────────
@@ -1203,6 +1212,16 @@ function showEventPopup(ev, anchor) {
document.getElementById('popup-description').style.display = ev.description ? '' : 'none';
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
+ // Creator — only shown when it isn't the current user.
+ const creatorEl = document.getElementById('popup-creator');
+ const me = JSON.parse(localStorage.getItem('user') || '{}');
+ if (ev.creator && ev.creator.display_name && ev.creator.id !== me.id) {
+ creatorEl.textContent = t('created_by', { name: ev.creator.display_name });
+ creatorEl.style.display = '';
+ } else {
+ creatorEl.style.display = 'none';
+ }
+
// Position near anchor
const rect = anchor.getBoundingClientRect();
const pw = 300, ph = 200;
@@ -1375,6 +1394,7 @@ function openNewEventModal(date) {
toggleAlldayFields(false);
populateCalendarSelect(null);
+ updatePrivateRow(false);
resetColorPicker('');
resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden');
@@ -1410,6 +1430,7 @@ function openCopyEditModal(ev, targetCal) {
if (targetCal.type === 'caldav') selectedId = targetCal.id;
else selectedId = `${targetCal.type}-${targetCal.id}`;
populateCalendarSelect(selectedId);
+ updatePrivateRow(ev.private);
resetColorPicker(ev.color || '');
resetRecurrenceUI();
@@ -1442,6 +1463,7 @@ function openEditEventModal(ev) {
}
populateCalendarSelect(ev.calendar_id);
+ updatePrivateRow(ev.private);
resetColorPicker(ev.color || '');
// Recurrence
@@ -1469,6 +1491,18 @@ function toggleAlldayFields(allDay) {
document.getElementById('ev-date-row').style.display = allDay ? '' : 'none';
}
+// The "Privat" toggle only applies to local calendars; hide it otherwise.
+function updatePrivateRow(isPrivate) {
+ const calVal = document.getElementById('ev-calendar').value || '';
+ const isLocal = calVal.startsWith('local-');
+ const row = document.getElementById('ev-private-row');
+ row.style.display = isLocal ? '' : 'none';
+ if (isPrivate !== undefined) {
+ document.getElementById('ev-private').checked = !!isPrivate;
+ }
+ if (!isLocal) document.getElementById('ev-private').checked = false;
+}
+
function resetColorPicker(color) {
state.selectedEventColor = color;
const hex = document.getElementById('ev-color-hex');
@@ -1559,6 +1593,9 @@ function bindEventModal() {
toggleAlldayFields(e.target.checked);
});
+ // The "Privat" toggle is only relevant for local calendars.
+ document.getElementById('ev-calendar').addEventListener('change', () => updatePrivateRow());
+
// Date/time pickers with auto-adjustment logic
[
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
@@ -1684,6 +1721,7 @@ function bindEventModal() {
const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor;
const rrule = buildRruleFromUI();
+ const isPrivate = isLocal && document.getElementById('ev-private').checked;
let start, end;
if (allDay) {
@@ -1711,7 +1749,7 @@ function bindEventModal() {
);
} else if (ev.source === 'local') {
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
- { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
+ { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate }
);
} else if (ev.source === 'ical') {
showToast(t('event_readonly'), true);
@@ -1733,6 +1771,7 @@ function bindEventModal() {
location: loc, description: desc,
color: color || null,
rrule: rrule || null,
+ private: ev.source === 'local' ? isPrivate : ev.private,
});
showToast(t('event_updated'));
} else if (isGoogle) {
@@ -1747,7 +1786,7 @@ function bindEventModal() {
await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
- rrule: rrule || null,
+ rrule: rrule || null, private: isPrivate,
});
showToast(t('event_created'));
} else if (isHA) {
@@ -2025,6 +2064,100 @@ function bindICalSubModal() {
};
}
+// ── iCal Import ───────────────────────────────────────────
+// Open a file picker and import the chosen .ics into the given local calendar.
+function triggerIcsImport(calendarId) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.ics,text/calendar';
+ input.style.display = 'none';
+ document.body.appendChild(input);
+ input.addEventListener('change', async () => {
+ const file = input.files && input.files[0];
+ input.remove();
+ if (!file) return;
+ const form = new FormData();
+ form.append('file', file);
+ try {
+ showToast(t('importing'));
+ const res = await api.upload(`/local/calendars/${calendarId}/import`, form);
+ showToast(t('import_result', { imported: res.imported, skipped: res.skipped }));
+ fetchAndRender(true);
+ } catch (e) { showToast(e.message, true); }
+ });
+ input.click();
+}
+
+// ── Sharing ───────────────────────────────────────────────
+async function openShareModal(calendarId) {
+ const modal = document.getElementById('modal-share');
+ modal.dataset.calId = String(calendarId);
+ const search = document.getElementById('share-user-search');
+ search.value = '';
+ search.oninput = renderShareUserPicker;
+ document.getElementById('share-permission').value = 'read';
+ openModal('modal-share');
+ await refreshShareModal(calendarId);
+}
+
+async function refreshShareModal(calendarId) {
+ // Load current shares + the user directory (for the picker).
+ let shares = [], users = [];
+ try { shares = await api.get(`/local/calendars/${calendarId}/shares`); } catch (e) { showToast(e.message, true); }
+ try { users = await api.get('/users/directory'); } catch (e) { /* ignore */ }
+
+ const sharedIds = new Set(shares.map(s => s.user_id));
+ const listEl = document.getElementById('share-current-list');
+ listEl.innerHTML = shares.length
+ ? shares.map(s =>
+ `
+
${escHtml(s.display_name || '')}
+
+ ${s.permission === 'read_write' ? t('perm_read_write') : t('perm_read')}
+ ${t('remove')}
+
+
`
+ ).join('')
+ : `
${t('share_none')}`;
+
+ listEl.querySelectorAll('[data-share-remove]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try {
+ await api.delete(`/local/calendars/${calendarId}/shares/${btn.dataset.shareRemove}`);
+ await refreshShareModal(calendarId);
+ } catch (e) { showToast(e.message, true); }
+ });
+ });
+
+ // Store the directory (minus already-shared users) for the picker.
+ document.getElementById('modal-share').__users = users.filter(u => !sharedIds.has(u.id));
+ renderShareUserPicker();
+}
+
+function renderShareUserPicker() {
+ const modal = document.getElementById('modal-share');
+ const users = modal.__users || [];
+ const q = (document.getElementById('share-user-search').value || '').toLowerCase();
+ const filtered = users.filter(u => (u.display_name || '').toLowerCase().includes(q));
+ const picker = document.getElementById('share-user-picker');
+ picker.innerHTML = filtered.length
+ ? filtered.map(u =>
+ `
${escHtml(u.display_name || '')}
`
+ ).join('')
+ : `
${t('share_no_users')}`;
+ picker.querySelectorAll('.share-user-item').forEach(el => {
+ el.addEventListener('click', async () => {
+ const calId = parseInt(modal.dataset.calId);
+ const permission = document.getElementById('share-permission').value;
+ try {
+ await api.post(`/local/calendars/${calId}/shares`,
+ { user_id: parseInt(el.dataset.userId), permission });
+ await refreshShareModal(calId);
+ } catch (e) { showToast(e.message, true); }
+ });
+ });
+}
+
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
const s = state.settings;
@@ -2057,6 +2190,7 @@ function openSettingsModal() {
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
+ document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
// Set active contrast/hour-height buttons
[
@@ -2189,14 +2323,40 @@ function renderAllAccounts() {
if (!state.localCalendars.length) {
localList.innerHTML = `
${t('settings_no_local_cals')}`;
} else {
- localList.innerHTML = state.localCalendars.map(cal =>
- `
+ localList.innerHTML = state.localCalendars.map(cal => {
+ const owned = cal.owned !== false;
+ const sharedBadge = !owned
+ ? `
${t('shared_by', { name: cal.shared_by || '' })}`
+ : '';
+ const canWrite = owned || cal.permission === 'read_write';
+ const actions = [];
+ if (owned) actions.push(`
${t('share')}`);
+ if (canWrite) actions.push(`
${t('import')}`);
+ actions.push(`
${t('export')}`);
+ return `
${escHtml(cal.name)}
+ ${sharedBadge}
-
`
- ).join('');
+
${actions.join('')}
+
`;
+ }).join('');
+
+ localList.querySelectorAll('[data-local-export]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try {
+ await api.download(`/local/calendars/${btn.dataset.localExport}/export`,
+ `${btn.dataset.localName || 'calendar'}.ics`);
+ } catch (e) { showToast(e.message, true); }
+ });
+ });
+ localList.querySelectorAll('[data-local-import]').forEach(btn => {
+ btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.localImport)));
+ });
+ localList.querySelectorAll('[data-local-share]').forEach(btn => {
+ btn.addEventListener('click', () => openShareModal(parseInt(btn.dataset.localShare)));
+ });
}
}
@@ -2509,6 +2669,7 @@ function bindSettingsModal() {
dim_past_events: document.getElementById('cfg-dim-past').checked,
hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value,
+ private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
try {
await api.put('/settings/', settings);
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index c443647..55e9071 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -84,6 +84,29 @@ const translations = {
settings_week_start: 'Erster Wochentag',
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
settings_dim_past: 'Vergangene Termine ausgrauen',
+ settings_privacy: 'Privatsphäre',
+ settings_private_visibility: 'Private Termine für Gruppenmitglieder',
+ settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
+ private_visibility_busy: 'Als „Beschäftigt“ anzeigen',
+ private_visibility_hidden: 'Ausblenden',
+ created_by: 'Erstellt von: {name}',
+ event_private: 'Privat',
+ share: 'Teilen',
+ import: 'Importieren',
+ export: 'Exportieren',
+ importing: 'Importiere…',
+ import_result: '{imported} importiert, {skipped} übersprungen',
+ shared_by: 'geteilt von {name}',
+ share_title: 'Kalender teilen',
+ share_current: 'Aktuelle Freigaben',
+ share_add: 'Benutzer hinzufügen',
+ share_search: 'Benutzer suchen…',
+ share_none: 'Noch nicht geteilt',
+ share_no_users: 'Keine Benutzer gefunden',
+ perm_read: 'Nur lesen',
+ perm_read_write: 'Lesen & schreiben',
+ remove: 'Entfernen',
+ done: 'Fertig',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -299,6 +322,29 @@ const translations = {
settings_week_start: 'First day of week',
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
settings_dim_past: 'Dim past events',
+ settings_privacy: 'Privacy',
+ settings_private_visibility: 'Private events for group members',
+ settings_private_visibility_desc: 'How your private events appear to other group members',
+ private_visibility_busy: 'Show as "Busy"',
+ private_visibility_hidden: 'Hide completely',
+ created_by: 'Created by: {name}',
+ event_private: 'Private',
+ share: 'Share',
+ import: 'Import',
+ export: 'Export',
+ importing: 'Importing…',
+ import_result: '{imported} imported, {skipped} skipped',
+ shared_by: 'shared by {name}',
+ share_title: 'Share calendar',
+ share_current: 'Current shares',
+ share_add: 'Add user',
+ share_search: 'Search users…',
+ share_none: 'Not shared yet',
+ share_no_users: 'No users found',
+ perm_read: 'Read only',
+ perm_read_write: 'Read & write',
+ remove: 'Remove',
+ done: 'Done',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
--
2.47.3
From e8a13ba33c6c2da1e12b25340673fd664b5856fc Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 16:37:08 +0200
Subject: [PATCH 087/114] feat: Gruppen im Web-Frontend + Gruppenkalender in
/local/calendars
- Sidebar-Sektion "Gruppen": Liste, Erstellen (Name + Mitglieder-Picker),
Verwalten (Mitglieder hinzufuegen/entfernen), Loeschen.
- Gruppenansicht: laedt /api/groups/{id}/combined fuer den sichtbaren
Bereich; Event-Titel werden mit Besitzer-Initialen bzw. Gruppen-Icon
praefixt; Banner mit "Gruppenansicht verlassen".
- Server: GET /api/local/calendars liefert nun auch Gruppenkalender
(group:true, read_write) fuer Mitglieder, damit sie im Editor waehlbar
sind. Test ergaenzt (13 gruen).
Co-Authored-By: Claude Opus 4.8
---
backend/routers/local_router.py | 22 +++-
backend/tests/test_collaboration.py | 13 ++
frontend/css/app.css | 23 ++++
frontend/index.html | 41 +++++++
frontend/js/calendar.js | 183 ++++++++++++++++++++++++++++
frontend/js/i18n.js | 28 +++++
6 files changed, 309 insertions(+), 1 deletion(-)
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index 7144c8b..a4a9a5d 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -105,16 +105,36 @@ def list_calendars(
.filter(models.CalendarShare.user_id == current_user.id)
.all()
)
+ seen_ids = {c.id for c in own}
for share in shares:
cal = share.calendar
- if cal is None:
+ if cal is None or cal.id in seen_ids:
continue
+ seen_ids.add(cal.id)
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
result.append(_cal_dict(
cal, owned=False,
shared_by=owner.username if owner else None,
permission=share.permission,
))
+
+ # Group calendars the user can reach via membership (read_write), so members
+ # can select the group calendar in the editor and see it in their list.
+ group_cals = (
+ db.query(models.LocalCalendar, models.Group.name)
+ .join(models.GroupCalendar, models.GroupCalendar.calendar_id == models.LocalCalendar.id)
+ .join(models.Group, models.Group.id == models.GroupCalendar.group_id)
+ .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
+ .filter(models.GroupMember.user_id == current_user.id)
+ .all()
+ )
+ for cal, group_name in group_cals:
+ if cal.id in seen_ids:
+ continue
+ seen_ids.add(cal.id)
+ d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
+ d["group"] = True
+ result.append(d)
return result
diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py
index 740cc24..4f3eb45 100644
--- a/backend/tests/test_collaboration.py
+++ b/backend/tests/test_collaboration.py
@@ -134,6 +134,19 @@ def test_group_members_can_write_group_calendar(client):
assert r.status_code == 200, r.text
+def test_group_calendar_listed_for_member(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ group = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Team", "member_ids": [b_id]}).json()
+ gcal = group["group_calendar_id"]
+ # Bob (member, not owner) sees the group calendar in his local list, flagged.
+ cals = client.get("/api/local/calendars", headers=auth(b_tok)).json()
+ gc = [c for c in cals if c["id"] == gcal]
+ assert gc and gc[0].get("group") is True
+ assert gc[0]["permission"] == "read_write" and gc[0]["owned"] is False
+
+
def test_combined_view_marks_owner_and_group_event(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
diff --git a/frontend/css/app.css b/frontend/css/app.css
index e519df7..0072ec8 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1801,3 +1801,26 @@ a { color: var(--primary); text-decoration: none; }
color: var(--text-2);
font-style: italic;
}
+
+/* ── Groups ─────────────────────────────────────────────────── */
+.group-view-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 16px;
+ background: rgba(66, 133, 244, 0.12);
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+ color: var(--text-1);
+}
+.group-item-active {
+ background: rgba(66, 133, 244, 0.15);
+ border-radius: 8px;
+}
+.group-item .cal-item-name { cursor: pointer; flex: 1; }
+.cal-list-empty {
+ padding: 6px 4px;
+ font-size: 13px;
+ color: var(--text-3);
+}
diff --git a/frontend/index.html b/frontend/index.html
index df32297..a1f3bf8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -198,6 +198,17 @@
+
+
+
@@ -205,6 +216,10 @@
+
+
+ Gruppenansicht verlassen
+
@@ -349,6 +364,32 @@
+
+
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 2d2f748..587de95 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -48,6 +48,8 @@ let state = {
dimPast: false,
editingEvent: null, // null = new event
selectedEventColor: '', // '' = use calendar color
+ groups: [],
+ activeGroupId: null, // when set, the calendar shows the combined group view
};
// ── URL state ────────────────────────────────────────────
@@ -123,8 +125,10 @@ export async function initCalendar() {
bindHAAccountModal();
bindSettingsModal();
bindProfileModal();
+ bindGroupUI();
bindSwipeNavigation();
handleHAOAuthReturn();
+ loadGroups();
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
@@ -249,9 +253,43 @@ function prefetchIfNeeded(viewStart, viewEnd) {
}
// ── Data fetching ─────────────────────────────────────────
+function initials(name) {
+ if (!name) return '?';
+ const parts = String(name).trim().split(/\s+/);
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+}
+
async function fetchAndRender(force = false, silent = false) {
const { start, end } = getViewRange();
+ // ── Combined group view ────────────────────────────────
+ // No client cache here; reload the combined events for the visible range.
+ if (state.activeGroupId) {
+ const fStart = new Date(start.getTime() - CACHE_BUF);
+ const fEnd = new Date(end.getTime() + CACHE_BUF);
+ if (!silent) showLoading();
+ try {
+ const resp = await api.get(
+ `/groups/${state.activeGroupId}/combined?start=${fStart.toISOString()}&end=${fEnd.toISOString()}`);
+ const evs = (resp.events || []).map(ev => {
+ // Prefix the title so every renderer shows who an event belongs to.
+ const ownerName = ev.owner && ev.owner.display_name;
+ const tag = ev.is_group_event ? '👥' : (ownerName ? `[${initials(ownerName)}]` : '');
+ return { ...ev, title: tag ? `${tag} ${ev.title}` : ev.title };
+ });
+ eventCache.start = null; eventCache.end = null; // invalidate normal cache
+ state.events = evs;
+ } catch (e) {
+ showToast(e.message, true);
+ state.events = [];
+ }
+ renderView();
+ updateTitle();
+ renderMiniCal();
+ return;
+ }
+
// Cache hit: requested range is fully within what we already have
if (!force && eventCache.start && eventCache.end &&
start >= eventCache.start && end <= eventCache.end) {
@@ -2158,6 +2196,151 @@ function renderShareUserPicker() {
});
}
+// ── Groups ────────────────────────────────────────────────
+async function loadGroups() {
+ try {
+ state.groups = await api.get('/groups/') || [];
+ } catch (e) { state.groups = []; }
+ renderGroupList();
+}
+
+function renderGroupList() {
+ const el = document.getElementById('group-list-items');
+ if (!el) return;
+ if (!state.groups.length) {
+ el.innerHTML = `
${t('groups_none')}
`;
+ return;
+ }
+ el.innerHTML = state.groups.map(g =>
+ `
+
${escHtml(g.name)}
+
+
+
+
`
+ ).join('');
+ el.querySelectorAll('[data-group-open]').forEach(s => {
+ s.addEventListener('click', () => enterGroupView(parseInt(s.dataset.groupOpen)));
+ });
+ el.querySelectorAll('[data-group-edit]').forEach(b => {
+ b.addEventListener('click', e => { e.stopPropagation(); openGroupModal(parseInt(b.dataset.groupEdit)); });
+ });
+}
+
+function enterGroupView(groupId) {
+ state.activeGroupId = groupId;
+ const g = state.groups.find(x => x.id === groupId);
+ document.getElementById('group-view-banner').classList.remove('hidden');
+ document.getElementById('group-view-label').textContent =
+ t('group_view_label', { name: g ? g.name : '' });
+ renderGroupList();
+ fetchAndRender(true);
+}
+
+function exitGroupView() {
+ state.activeGroupId = null;
+ document.getElementById('group-view-banner').classList.add('hidden');
+ renderGroupList();
+ fetchAndRender(true);
+}
+
+// Open the group modal in create mode (no id) or manage mode (existing group).
+async function openGroupModal(groupId) {
+ const modal = document.getElementById('modal-group');
+ modal.dataset.groupId = groupId ? String(groupId) : '';
+ const isEdit = !!groupId;
+ document.getElementById('group-modal-title').textContent =
+ isEdit ? t('group_manage') : t('group_create');
+ document.getElementById('group-delete').classList.toggle('hidden', !isEdit);
+
+ let detail = null, directory = [];
+ try { directory = await api.get('/users/directory') || []; } catch (e) { /* ignore */ }
+ if (isEdit) {
+ try { detail = await api.get(`/groups/${groupId}`); } catch (e) { showToast(e.message, true); }
+ }
+ const me = JSON.parse(localStorage.getItem('user') || '{}');
+ const existingMemberIds = new Set((detail ? detail.members : []).map(m => m.id));
+
+ document.getElementById('group-name').value = detail ? detail.name : '';
+ document.getElementById('group-name').disabled = isEdit; // rename not supported by API
+
+ // Member picker: current members are checked; the owner (me) is excluded.
+ modal.__directory = directory;
+ modal.__memberIds = new Set([...existingMemberIds].filter(id => id !== me.id));
+ renderGroupMemberPicker();
+
+ openModal('modal-group');
+}
+
+function renderGroupMemberPicker() {
+ const modal = document.getElementById('modal-group');
+ const dir = modal.__directory || [];
+ const picked = modal.__memberIds || new Set();
+ const picker = document.getElementById('group-member-picker');
+ picker.innerHTML = dir.length
+ ? dir.map(u =>
+ `
`
+ ).join('')
+ : `
${t('share_no_users')}`;
+ picker.querySelectorAll('input[data-member-id]').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const id = parseInt(cb.dataset.memberId);
+ if (cb.checked) picked.add(id); else picked.delete(id);
+ });
+ });
+}
+
+function bindGroupUI() {
+ const addBtn = document.getElementById('btn-add-group');
+ if (addBtn) addBtn.onclick = () => openGroupModal(null);
+ const exitBtn = document.getElementById('group-view-exit');
+ if (exitBtn) exitBtn.onclick = exitGroupView;
+
+ document.getElementById('group-save').onclick = async () => {
+ const modal = document.getElementById('modal-group');
+ const groupId = modal.dataset.groupId;
+ const memberIds = [...(modal.__memberIds || new Set())];
+ try {
+ if (groupId) {
+ // Manage mode: sync member additions/removals.
+ const detail = await api.get(`/groups/${groupId}`);
+ const me = JSON.parse(localStorage.getItem('user') || '{}');
+ const current = new Set(detail.members.map(m => m.id).filter(id => id !== me.id));
+ for (const id of memberIds) if (!current.has(id)) await api.post(`/groups/${groupId}/members`, { user_id: id });
+ for (const id of current) if (!memberIds.includes(id)) await api.delete(`/groups/${groupId}/members/${id}`);
+ showToast(t('group_saved'));
+ } else {
+ const name = document.getElementById('group-name').value.trim();
+ if (!name) { showToast(t('error_enter_title'), true); return; }
+ await api.post('/groups/', { name, member_ids: memberIds });
+ showToast(t('group_created'));
+ }
+ closeModal('modal-group');
+ await loadGroups();
+ // Refresh local calendars too (a new group creates a group calendar).
+ try { state.localCalendars = await api.get('/local/calendars') || state.localCalendars; } catch (e) {}
+ renderCalendarList();
+ } catch (e) { showToast(e.message, true); }
+ };
+
+ document.getElementById('group-delete').onclick = async () => {
+ const modal = document.getElementById('modal-group');
+ const groupId = modal.dataset.groupId;
+ if (!groupId) return;
+ if (!confirm(t('group_delete_confirm'))) return;
+ try {
+ await api.delete(`/groups/${groupId}`);
+ if (state.activeGroupId === parseInt(groupId)) exitGroupView();
+ closeModal('modal-group');
+ await loadGroups();
+ showToast(t('group_deleted'));
+ } catch (e) { showToast(e.message, true); }
+ };
+}
+
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
const s = state.settings;
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 55e9071..61a498d 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -107,6 +107,20 @@ const translations = {
perm_read_write: 'Lesen & schreiben',
remove: 'Entfernen',
done: 'Fertig',
+ groups_title: 'Gruppen',
+ groups_none: 'Noch keine Gruppen',
+ group_create: 'Gruppe erstellen',
+ group_manage: 'Gruppe verwalten',
+ group_name: 'Name',
+ group_name_ph: 'Gruppenname',
+ group_members: 'Mitglieder',
+ group_delete: 'Gruppe löschen',
+ group_delete_confirm: 'Diese Gruppe und ihren Gruppenkalender wirklich löschen?',
+ group_deleted: 'Gruppe gelöscht',
+ group_saved: 'Gruppe gespeichert',
+ group_created: 'Gruppe erstellt',
+ group_view_label: 'Gruppenansicht: {name}',
+ group_exit: 'Gruppenansicht verlassen',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -345,6 +359,20 @@ const translations = {
perm_read_write: 'Read & write',
remove: 'Remove',
done: 'Done',
+ groups_title: 'Groups',
+ groups_none: 'No groups yet',
+ group_create: 'Create group',
+ group_manage: 'Manage group',
+ group_name: 'Name',
+ group_name_ph: 'Group name',
+ group_members: 'Members',
+ group_delete: 'Delete group',
+ group_delete_confirm: 'Really delete this group and its group calendar?',
+ group_deleted: 'Group deleted',
+ group_saved: 'Group saved',
+ group_created: 'Group created',
+ group_view_label: 'Group view: {name}',
+ group_exit: 'Exit group view',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
--
2.47.3
From f018f33f698b71a811596d0403238c3438ee84ec Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 16:42:21 +0200
Subject: [PATCH 088/114] fix: JS/CSS immer revalidieren (no-cache) + Version
v24
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bisher bekamen /static/js und /static/css max-age=7200. Da index.html
no-cache ist, konnte eine frische HTML mit 2h-altem, gecachtem JS/CSS
gepaart werden — neue Features (z.B. Gruppen-Button) ohne passenden
Handler. JS/CSS revalidieren jetzt bei jedem Load (304 wenn unveraendert);
Icons & uebrige Assets behalten 2h. Deploys greifen so sofort beim Reload.
Co-Authored-By: Claude Opus 4.8
---
backend/main.py | 7 ++++++-
frontend/js/version.js | 2 +-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 562fa3a..8d017ca 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -179,7 +179,12 @@ async def add_cache_headers(request: Request, call_next):
response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
- # 2h cache for the rest of the frontend (JS/CSS/icons/etc.)
+ # JS/CSS must revalidate on every load so a deploy takes effect on the next
+ # reload (returns a cheap 304 when unchanged). Without this, a fresh
+ # no-cache index.html could pair with stale 2h-cached scripts.
+ elif path.startswith("/static/js/") or path.startswith("/static/css/"):
+ response.headers["Cache-Control"] = NO_CACHE
+ # 2h cache for the rest of the frontend (icons, fonts, images, …)
elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML;
diff --git a/frontend/js/version.js b/frontend/js/version.js
index bb0b7ed..eea63e6 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 = 'v23';
+export const APP_VERSION = 'v24';
--
2.47.3
From 479da29bc428358cc91e9d78352fd87cdfe0e92e Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 16:47:58 +0200
Subject: [PATCH 089/114] fix: Gruppen-Mitgliederliste sauber ausrichten
(Checkbox links, Name links)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Eigene .group-member-item-Klasse statt der generischen Picker-Klasse —
behebt versetzte Checkboxen / rechtsbuendige Namen. Version v25.
Co-Authored-By: Claude Opus 4.8
---
frontend/css/app.css | 17 +++++++++++++++++
frontend/js/calendar.js | 4 ++--
frontend/js/version.js | 2 +-
3 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 0072ec8..c0cbb0a 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1824,3 +1824,20 @@ a { color: var(--primary); text-decoration: none; }
font-size: 13px;
color: var(--text-3);
}
+
+/* Group member picker rows — checkbox left, name left, one per line. */
+.group-member-item {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ padding: 10px 14px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ text-transform: none;
+}
+.group-member-item:last-child { border-bottom: none; }
+.group-member-item:hover { background: var(--bg-surface); }
+.group-member-item input[type="checkbox"] { flex: 0 0 auto; margin: 0; }
+.group-member-name { flex: 1 1 auto; color: var(--text-1); }
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 587de95..fe7739a 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -2279,9 +2279,9 @@ function renderGroupMemberPicker() {
const picker = document.getElementById('group-member-picker');
picker.innerHTML = dir.length
? dir.map(u =>
- `
+
Kalender
+
Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist
+
+
Stundenhöhe (Wochen- & Tagesansicht)
Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt
diff --git a/frontend/js/api.js b/frontend/js/api.js
index cc12b4a..6c2c1b6 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -50,8 +50,13 @@ async function uploadRequest(path, formData) {
return null;
}
if (!res.ok) {
- const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
- throw new Error(err.detail || `HTTP ${res.status}`);
+ // Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
+ // to the HTTP status so the message is diagnostic, not "unknown error".
+ const err = await res.json().catch(() => null);
+ const detail = (err && err.detail)
+ ? err.detail
+ : (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
+ throw new Error(detail);
}
if (res.status === 204) return null;
return res.json();
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index fe7739a..1a9c608 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -576,28 +576,38 @@ function renderCalendarList() {
}).join('');
}
- // ── Local calendars (own + shared with me) ─────────────
- if (state.localCalendars.length) {
+ // ── Local calendars: own ones, then a separate "shared with me" group ──
+ const ownLocal = state.localCalendars.filter(c => c.owned !== false);
+ const sharedLocal = state.localCalendars.filter(c => c.owned === false && !c.group);
+
+ const renderLocalItem = (cal, withRemove) => {
+ const removeBtn = withRemove
+ ? `
+
+ `
+ : '';
+ return `
+
+
+
${escHtml(cal.name)}
+ ${removeBtn}
+
`;
+ };
+
+ if (ownLocal.length) {
html += `
${t('cal_local')}
`;
- html += state.localCalendars.map(cal => {
- const owned = cal.owned !== false;
- // Shared calendars get an owner badge and no delete button (owner-only).
- const sharedBadge = !owned
- ? `
${escHtml(cal.shared_by || '')}`
- : '';
- const removeBtn = owned
- ? `
-
- `
- : '';
- return `
+ html += ownLocal.map(c => renderLocalItem(c, true)).join('');
+ }
+ if (sharedLocal.length) {
+ html += `
${t('shared_with_me')}
`;
+ html += sharedLocal.map(cal =>
+ `
`;
- }).join('');
+
${escHtml(cal.shared_by || '')}
+
`
+ ).join('');
}
// ── iCal subscriptions ─────────────────────────────────
@@ -677,8 +687,12 @@ function renderCalendarList() {
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);
+ // `enabled` is the owner's property — only the owner may PUT it.
+ // For shared/group calendars just toggle visibility client-side.
+ if (cal && cal.owned !== false) {
+ await api.put(`/local/calendars/${calId}`, { enabled: cb.checked });
+ }
if (cal) cal.enabled = cb.checked;
cacheCalId = `local-${calId}`;
} else if (source === 'ical') {
@@ -2341,6 +2355,25 @@ function bindGroupUI() {
};
}
+// Radio list of the user's OWN local calendars to pick the one visible to
+// group members (plus a "none" option). Selection is read on settings save.
+function renderGroupVisibleList(selectedId) {
+ const el = document.getElementById('cfg-group-visible-list');
+ if (!el) return;
+ const own = state.localCalendars.filter(c => c.owned !== false && !c.group);
+ const opt = (id, name, color) => {
+ const checked = (id === null && (selectedId == null)) || id === selectedId;
+ const dot = color ? `
` : '';
+ return `
+
+ ${dot}${escHtml(name)}
+ `;
+ };
+ el.innerHTML =
+ opt(null, t('group_visible_none'), null) +
+ own.map(c => opt(c.id, c.name, c.color)).join('');
+}
+
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
const s = state.settings;
@@ -2374,6 +2407,7 @@ function openSettingsModal() {
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
+ renderGroupVisibleList(s.group_visible_calendar_id);
// Set active contrast/hour-height buttons
[
@@ -2854,6 +2888,8 @@ function bindSettingsModal() {
language: document.getElementById('cfg-language').value,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
+ const gvSel = document.querySelector('input[name="cfg-group-visible"]:checked');
+ settings.group_visible_calendar_id = gvSel && gvSel.value ? parseInt(gvSel.value) : null;
try {
await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings };
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 61a498d..5823dee 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -121,6 +121,12 @@ const translations = {
group_created: 'Gruppe erstellt',
group_view_label: 'Gruppenansicht: {name}',
group_exit: 'Gruppenansicht verlassen',
+ upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
+ shared_with_me: 'Mit dir geteilt',
+ settings_calendars: 'Kalender',
+ settings_group_visible: 'Für Gruppen sichtbarer Kalender',
+ settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist',
+ group_visible_none: 'Keiner',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -373,6 +379,12 @@ const translations = {
group_created: 'Group created',
group_view_label: 'Group view: {name}',
group_exit: 'Exit group view',
+ upload_too_large: 'File too large (server limit). Please raise the upload limit.',
+ shared_with_me: 'Shared with me',
+ settings_calendars: 'Calendars',
+ settings_group_visible: 'Calendar visible to groups',
+ settings_group_visible_desc: 'Choose which of your calendars your group members can see',
+ group_visible_none: 'None',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index e6a7b0b..c0e691f 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 = 'v25';
+export const APP_VERSION = 'v26';
--
2.47.3
From c7185a128eb6331b7364aacad3e5595b2d91e057 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:15:17 +0200
Subject: [PATCH 092/114] fix: Import-500 bei doppelten UIDs, Picker-UI &
Settings-URL-State
- Import: Dedupe doppelter UIDs innerhalb der Datei (Nextcloud exportiert
wiederkehrende Termine als mehrere VEVENTs gleicher UID) -> kein
UNIQUE-constraint-500 mehr; Commit abgesichert. Test ergaenzt (15 gruen).
- Picker (Gruppen-Sichtbarkeit + Mitglieder): als -Zeilen statt
,
damit die globale ".form-group label"-Uppercase/Grau-Regel das Layout nicht
mehr zerschiesst. Saubere .pick-row-Optik (Checkbox/Radio links, Name links).
- Einstellungen haben jetzt eigenen URL-State (#...&settings=1): Reload/Cache-
leeren bleibt in den Einstellungen statt zur Kalenderansicht zu springen.
- Version v27.
Co-Authored-By: Claude Opus 4.8
---
backend/routers/local_router.py | 17 ++++++-
backend/tests/test_collaboration.py | 33 +++++++++++++
frontend/css/app.css | 33 +++++++++++++
frontend/js/calendar.js | 73 ++++++++++++++++++++---------
frontend/js/version.js | 2 +-
5 files changed, 133 insertions(+), 25 deletions(-)
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index a4a9a5d..d4e16c8 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -386,8 +386,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
parsed = ical_io.parse_ics(raw)
imported = 0
skipped = 0
+ errors = list(parsed["errors"])
+ # local_events.uid is globally unique. Dedupe against the DB AND within this
+ # file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a
+ # UID (RECURRENCE-ID overrides), which would otherwise violate the constraint.
+ seen_uids: set[str] = set()
for item in parsed["events"]:
uid = item.get("uid") or str(uuid.uuid4())
+ if uid in seen_uids:
+ skipped += 1
+ continue
+ seen_uids.add(uid)
existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if existing:
skipped += 1
@@ -407,8 +416,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
)
db.add(ev)
imported += 1
- db.commit()
- return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]}
+ try:
+ db.commit()
+ except Exception as exc:
+ db.rollback()
+ raise ValueError(f"Import fehlgeschlagen: {exc}")
+ return {"imported": imported, "skipped": skipped, "errors": errors}
@router.post("/calendars/{calendar_id}/import")
diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py
index aa22346..a93bae5 100644
--- a/backend/tests/test_collaboration.py
+++ b/backend/tests/test_collaboration.py
@@ -289,6 +289,39 @@ def test_import_dedupes_by_uid(client):
assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)"
+DUP_UID_ICS = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Nextcloud
+BEGIN:VEVENT
+UID:recurring@nc
+SUMMARY:Standup
+DTSTART:20260601T090000Z
+DTEND:20260601T091500Z
+RRULE:FREQ=WEEKLY;BYDAY=MO
+END:VEVENT
+BEGIN:VEVENT
+UID:recurring@nc
+RECURRENCE-ID:20260608T090000Z
+SUMMARY:Standup verschoben
+DTSTART:20260608T100000Z
+DTEND:20260608T101500Z
+END:VEVENT
+END:VCALENDAR
+"""
+
+
+def test_import_handles_duplicate_uid_in_file(client):
+ """Nextcloud exports recurring events as multiple VEVENTs sharing a UID;
+ importing must not 500 on the unique constraint."""
+ admin = register_admin(client)
+ cal_id = _make_calendar(client, admin, "NC")
+ files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")}
+ r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files)
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["imported"] == 1 and body["skipped"] == 1
+
+
def test_export_contains_organizer_and_rrule(client):
admin = register_admin(client)
cal_id = _make_calendar(client, admin, "Export-Test")
diff --git a/frontend/css/app.css b/frontend/css/app.css
index b1b3629..c20b8e9 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1859,3 +1859,36 @@ a { color: var(--primary); text-decoration: none; }
.cal-radio-item:last-child { border-bottom: none; }
.cal-radio-item:hover { background: var(--bg-surface); }
.cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; }
+
+/* Picker rows (group-visible calendar radio + group member checkboxes).
+ Deliberately NOT elements, so the global ".form-group label"
+ uppercase/grey styling never applies. */
+.cal-radio-list,
+#group-member-picker {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+ max-height: 260px;
+ overflow-y: auto;
+}
+.pick-row {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ padding: 10px 14px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ text-transform: none;
+ letter-spacing: normal;
+ font-size: 14px;
+ color: var(--text-1);
+}
+.pick-row:last-child { border-bottom: none; }
+.pick-row:hover { background: var(--bg-surface); }
+.pick-row-sel { background: rgba(66, 133, 244, 0.12); }
+.pick-mark { flex: 0 0 auto; width: 18px; text-align: center; color: var(--primary); }
+.pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; }
+.pick-dot-empty { background: transparent; }
+.pick-name { flex: 1 1 auto; text-align: left; }
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 1a9c608..cede4af 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -70,13 +70,19 @@ function readUrlState() {
const d = new Date(date + 'T00:00:00');
if (!isNaN(d.getTime())) out.date = d;
}
+ out.settings = params.get('settings') === '1';
return out;
}
+// Tracks whether the settings modal is open, so a reload returns to settings
+// instead of the calendar view.
+let uiSettingsOpen = false;
+
function writeUrlState() {
const d = state.currentDate;
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
- const newHash = `date=${dateStr}&view=${state.currentView}`;
+ let newHash = `date=${dateStr}&view=${state.currentView}`;
+ if (uiSettingsOpen) newHash += '&settings=1';
if (window.location.hash.replace(/^#/,'') !== newHash) {
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
// einzelnen Tag in den Browser-History-Stack drücken
@@ -130,6 +136,9 @@ export async function initCalendar() {
handleHAOAuthReturn();
loadGroups();
+ // Reopen the settings modal after a reload if the URL says we were in it.
+ if (readUrlState().settings) openSettingsModal();
+
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
const u = readUrlState();
@@ -2292,17 +2301,19 @@ function renderGroupMemberPicker() {
const picked = modal.__memberIds || new Set();
const picker = document.getElementById('group-member-picker');
picker.innerHTML = dir.length
- ? dir.map(u =>
- `
-
- ${escHtml(u.display_name || '')}
- `
- ).join('')
+ ? dir.map(u => {
+ const on = picked.has(u.id);
+ return `
+ ${on ? '☑' : '☐'}
+ ${escHtml(u.display_name || '')}
+
`;
+ }).join('')
: `${t('share_no_users')}`;
- picker.querySelectorAll('input[data-member-id]').forEach(cb => {
- cb.addEventListener('change', () => {
- const id = parseInt(cb.dataset.memberId);
- if (cb.checked) picked.add(id); else picked.delete(id);
+ picker.querySelectorAll('.pick-row').forEach(rowEl => {
+ rowEl.addEventListener('click', () => {
+ const id = parseInt(rowEl.dataset.memberId);
+ if (picked.has(id)) picked.delete(id); else picked.add(id);
+ renderGroupMemberPicker();
});
});
}
@@ -2360,22 +2371,36 @@ function bindGroupUI() {
function renderGroupVisibleList(selectedId) {
const el = document.getElementById('cfg-group-visible-list');
if (!el) return;
+ el.dataset.selected = (selectedId == null) ? '' : String(selectedId);
const own = state.localCalendars.filter(c => c.owned !== false && !c.group);
- const opt = (id, name, color) => {
- const checked = (id === null && (selectedId == null)) || id === selectedId;
- const dot = color ? `` : '';
- return `
-
- ${dot}${escHtml(name)}
- `;
+ const selVal = el.dataset.selected;
+ const row = (id, name, color) => {
+ const val = (id == null) ? '' : String(id);
+ const sel = val === selVal;
+ const dot = color
+ ? ``
+ : ``;
+ return `
+ ${sel ? '●' : '○'}
+ ${dot}
+ ${escHtml(name)}
+
`;
};
el.innerHTML =
- opt(null, t('group_visible_none'), null) +
- own.map(c => opt(c.id, c.name, c.color)).join('');
+ row(null, t('group_visible_none'), null) +
+ own.map(c => row(c.id, c.name, c.color)).join('');
+ el.querySelectorAll('.pick-row').forEach(r => {
+ r.addEventListener('click', () => {
+ el.dataset.selected = r.dataset.pick;
+ renderGroupVisibleList(r.dataset.pick === '' ? null : parseInt(r.dataset.pick));
+ });
+ });
}
// ── Settings Modal ────────────────────────────────────────
function openSettingsModal() {
+ uiSettingsOpen = true;
+ writeUrlState();
const s = state.settings;
document.getElementById('cfg-default-view').value = s.default_view || 'month';
document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
@@ -2888,8 +2913,8 @@ function bindSettingsModal() {
language: document.getElementById('cfg-language').value,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
- const gvSel = document.querySelector('input[name="cfg-group-visible"]:checked');
- settings.group_visible_calendar_id = gvSel && gvSel.value ? parseInt(gvSel.value) : null;
+ const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
+ settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
try {
await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings };
@@ -3205,6 +3230,10 @@ function openModal(id) {
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
+ if (id === 'modal-settings') {
+ uiSettingsOpen = false;
+ writeUrlState();
+ }
}
// Close button bindings (added once)
diff --git a/frontend/js/version.js b/frontend/js/version.js
index c0e691f..3a64095 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 = 'v26';
+export const APP_VERSION = 'v27';
--
2.47.3
From 8d2f48760786d2db309cf0c4f3b66deaf9faed09 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:23:28 +0200
Subject: [PATCH 093/114] feat: flache sortierbare Kalenderliste (Drag&Drop) +
Fixes
- Sidebar: eine flache Kalenderliste statt Quellen-Gruppen; Quelle/Konto
klein-grau inline rechts neben dem Namen; per Drag&Drop sortierbar
(Reihenfolge pro Geraet in localStorage).
- Gruppenkalender serverseitig auch beim Besitzer als group:true markiert
-> erscheint nicht mehr in der "Fuer Gruppen sichtbar"-Auswahl und nicht
in der normalen Kalenderliste (nur unter Gruppen).
- Settings-URL-State: uiSettingsOpen wird beim Init aus der URL gesetzt,
bevor das erste writeUrlState() es ueberschreibt -> Reload bleibt jetzt
wirklich in den Einstellungen.
- Auswahl-Markierungen (Mitglieder/Gruppen-Sichtbar) in Akzentfarbe,
CSS-gezeichnet statt blauer Emoji. Version v28.
Co-Authored-By: Claude Opus 4.8
---
backend/routers/local_router.py | 52 +++++---
frontend/css/app.css | 33 ++++-
frontend/js/calendar.js | 222 ++++++++++++++++----------------
frontend/js/i18n.js | 2 +
frontend/js/version.js | 2 +-
5 files changed, 181 insertions(+), 130 deletions(-)
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index d4e16c8..7060f18 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -91,13 +91,34 @@ def list_calendars(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
+ # Map calendar_id -> group name for every group the user belongs to, so we
+ # can flag group calendars as such even when the user owns them (the creator
+ # owns the group calendar — it must still be marked group:true).
+ group_cal_map = {
+ cal_id: name
+ for cal_id, name in (
+ db.query(models.GroupCalendar.calendar_id, models.Group.name)
+ .join(models.Group, models.Group.id == models.GroupCalendar.group_id)
+ .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
+ .filter(models.GroupMember.user_id == current_user.id)
+ .all()
+ )
+ }
+
# Own calendars
own = (
db.query(models.LocalCalendar)
.filter(models.LocalCalendar.user_id == current_user.id)
.all()
)
- result = [_cal_dict(c, owned=True) for c in own]
+ result = []
+ for c in own:
+ d = _cal_dict(c, owned=True)
+ if c.id in group_cal_map:
+ d["group"] = True
+ result.append(d)
+
+ seen_ids = {c.id for c in own}
# Calendars shared with this user
shares = (
@@ -105,33 +126,30 @@ def list_calendars(
.filter(models.CalendarShare.user_id == current_user.id)
.all()
)
- seen_ids = {c.id for c in own}
for share in shares:
cal = share.calendar
if cal is None or cal.id in seen_ids:
continue
seen_ids.add(cal.id)
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
- result.append(_cal_dict(
+ d = _cal_dict(
cal, owned=False,
shared_by=owner.username if owner else None,
permission=share.permission,
- ))
+ )
+ if cal.id in group_cal_map:
+ d["group"] = True
+ result.append(d)
- # Group calendars the user can reach via membership (read_write), so members
- # can select the group calendar in the editor and see it in their list.
- group_cals = (
- db.query(models.LocalCalendar, models.Group.name)
- .join(models.GroupCalendar, models.GroupCalendar.calendar_id == models.LocalCalendar.id)
- .join(models.Group, models.Group.id == models.GroupCalendar.group_id)
- .join(models.GroupMember, models.GroupMember.group_id == models.GroupCalendar.group_id)
- .filter(models.GroupMember.user_id == current_user.id)
- .all()
- )
- for cal, group_name in group_cals:
- if cal.id in seen_ids:
+ # Group calendars reached via membership (read_write) that aren't already
+ # listed, so members can select/see the group calendar.
+ for cal_id, group_name in group_cal_map.items():
+ if cal_id in seen_ids:
continue
- seen_ids.add(cal.id)
+ cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == cal_id).first()
+ if not cal:
+ continue
+ seen_ids.add(cal_id)
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
d["group"] = True
result.append(d)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index c20b8e9..20d61de 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1887,8 +1887,37 @@ a { color: var(--primary); text-decoration: none; }
}
.pick-row:last-child { border-bottom: none; }
.pick-row:hover { background: var(--bg-surface); }
-.pick-row-sel { background: rgba(66, 133, 244, 0.12); }
-.pick-mark { flex: 0 0 auto; width: 18px; text-align: center; color: var(--primary); }
+.pick-row-sel { box-shadow: inset 3px 0 0 var(--accent); }
+.pick-mark {
+ flex: 0 0 auto;
+ width: 18px; height: 18px;
+ border: 2px solid var(--text-3);
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 12px; line-height: 1; color: #fff;
+}
+.pick-check { border-radius: 4px; }
+.pick-radio { border-radius: 50%; }
+.pick-mark.on { background: var(--accent); border-color: var(--accent); }
.pick-dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%; }
.pick-dot-empty { background: transparent; }
.pick-name { flex: 1 1 auto; text-align: left; }
+
+/* Flat calendar list: inline source label + drag handle. */
+.cal-source {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-3);
+ white-space: nowrap;
+ max-width: 45%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding-left: 8px;
+}
+.cal-drag-handle {
+ flex: 0 0 auto;
+ cursor: grab;
+ color: var(--text-3);
+ font-size: 14px;
+ user-select: none;
+}
+.cal-item.cal-dragging { opacity: .5; }
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index cede4af..2cec07c 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -115,6 +115,9 @@ export async function initCalendar() {
const urlState = readUrlState();
if (urlState.date) state.currentDate = urlState.date;
if (urlState.view) state.currentView = urlState.view;
+ // Preserve the settings flag through the first writeUrlState() (fired by the
+ // initial fetchAndRender) so a reload reopens settings instead of stripping it.
+ uiSettingsOpen = urlState.settings === true;
setLang(settings.language || 'de');
applyTheme(settings);
@@ -137,7 +140,7 @@ export async function initCalendar() {
loadGroups();
// Reopen the settings modal after a reload if the URL says we were in it.
- if (readUrlState().settings) openSettingsModal();
+ if (urlState.settings) openSettingsModal();
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
@@ -562,122 +565,121 @@ function renderMiniCal() {
}
// ── Calendar List ─────────────────────────────────────────
+const CAL_ORDER_KEY = 'cal_order';
+function loadCalOrder() {
+ try { return JSON.parse(localStorage.getItem(CAL_ORDER_KEY) || '[]'); }
+ catch (e) { return []; }
+}
+function saveCalOrder(keys) {
+ localStorage.setItem(CAL_ORDER_KEY, JSON.stringify(keys));
+}
+
+// Drag & drop reordering of the flat calendar list (persisted per device).
+function bindCalDragReorder(container) {
+ let dragKey = null;
+ container.querySelectorAll('.cal-item').forEach(item => {
+ item.addEventListener('dragstart', e => {
+ // Don't start a drag from interactive children (checkbox, color dot, buttons).
+ if (e.target.closest('input, button, .cal-item-dot')) { e.preventDefault(); return; }
+ dragKey = item.dataset.key;
+ e.dataTransfer.effectAllowed = 'move';
+ item.classList.add('cal-dragging');
+ });
+ item.addEventListener('dragend', () => {
+ dragKey = null;
+ item.classList.remove('cal-dragging');
+ });
+ item.addEventListener('dragover', e => { e.preventDefault(); });
+ item.addEventListener('drop', e => {
+ e.preventDefault();
+ const targetKey = item.dataset.key;
+ if (!dragKey || dragKey === targetKey) return;
+ const keys = [...container.querySelectorAll('.cal-item')].map(el => el.dataset.key);
+ const from = keys.indexOf(dragKey);
+ const to = keys.indexOf(targetKey);
+ if (from === -1 || to === -1) return;
+ keys.splice(to, 0, keys.splice(from, 1)[0]);
+ saveCalOrder(keys);
+ renderCalendarList();
+ });
+ });
+}
+
function renderCalendarList() {
const container = document.getElementById('cal-list-items');
- let html = '';
+ // Eye-off (hide external calendar) and trash (delete local/ical) icons.
+ const EYE_OFF = ``;
+ const TRASH = ``;
- // ── CalDAV accounts ────────────────────────────────────
- if (state.accounts.length) {
- html += state.accounts.map(acc => {
- const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden);
- if (!visibleCals.length) return '';
- return `${escHtml(acc.name)}
` +
- visibleCals.map(cal =>
- `
-
-
-
${escHtml(cal.name)}
-
-
-
-
`
- ).join('');
- }).join('');
- }
+ // Build a single flat list of all calendars. The source/account is shown
+ // inline (small, grey) next to the name and section headers are gone, so the
+ // whole list can be freely reordered via drag & drop.
+ const entries = [];
+ state.accounts.forEach(acc => {
+ (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
+ entries.push({ key: `caldav:${cal.id}`, source: 'caldav', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled,
+ sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } });
+ });
+ });
+ state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => {
+ entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color, enabled: cal.enabled,
+ sourceLabel: t('cal_local'), remove: { icon: TRASH, title: t('remove_cal') } });
+ });
+ state.localCalendars.filter(c => c.owned === false && !c.group).forEach(cal => {
+ entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color, enabled: cal.enabled,
+ sourceLabel: `${t('shared_with_me')} · ${cal.shared_by || ''}`, remove: null });
+ });
+ state.icalSubscriptions.forEach(sub => {
+ entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
+ name: sub.name, color: sub.color, enabled: sub.enabled,
+ sourceLabel: t('cal_ical'), remove: { icon: TRASH, title: t('remove_ical_sub') } });
+ });
+ state.googleAccounts.forEach(acc => {
+ (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
+ entries.push({ key: `google:${cal.id}`, source: 'google', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color || '#4285f4', enabled: cal.enabled,
+ sourceLabel: acc.email, remove: { icon: EYE_OFF, title: t('hide_cal') } });
+ });
+ });
+ state.haAccounts.forEach(acc => {
+ (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
+ entries.push({ key: `homeassistant:${cal.id}`, source: 'homeassistant', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color || '#03a9f4', enabled: cal.enabled,
+ sourceLabel: acc.name, remove: { icon: EYE_OFF, title: t('hide_cal') } });
+ });
+ });
- // ── Local calendars: own ones, then a separate "shared with me" group ──
- const ownLocal = state.localCalendars.filter(c => c.owned !== false);
- const sharedLocal = state.localCalendars.filter(c => c.owned === false && !c.group);
+ // Apply the saved manual order (per device); unknown calendars append at end.
+ const order = loadCalOrder();
+ entries.sort((a, b) => {
+ const ia = order.indexOf(a.key), ib = order.indexOf(b.key);
+ if (ia === -1 && ib === -1) return 0;
+ if (ia === -1) return 1;
+ if (ib === -1) return -1;
+ return ia - ib;
+ });
+ saveCalOrder(entries.map(e => e.key));
- const renderLocalItem = (cal, withRemove) => {
- const removeBtn = withRemove
- ? `
-
- `
- : '';
- return `
-
-
-
${escHtml(cal.name)}
- ${removeBtn}
-
`;
- };
-
- if (ownLocal.length) {
- html += `${t('cal_local')}
`;
- html += ownLocal.map(c => renderLocalItem(c, true)).join('');
- }
- if (sharedLocal.length) {
- html += `${t('shared_with_me')}
`;
- html += sharedLocal.map(cal =>
- `
-
-
-
${escHtml(cal.name)}
-
${escHtml(cal.shared_by || '')}
-
`
- ).join('');
- }
-
- // ── iCal subscriptions ─────────────────────────────────
- if (state.icalSubscriptions.length) {
- html += `${t('cal_ical')}
`;
- html += state.icalSubscriptions.map(sub =>
- `
-
-
-
${escHtml(sub.name)}
-
-
-
-
`
- ).join('');
- }
-
- // ── Google accounts ───────────────────────────────────
- if (state.googleAccounts.length) {
- html += state.googleAccounts.map(acc => {
- const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden);
- if (!visibleCals.length) return `${escHtml(acc.email)}
`;
- return `${escHtml(acc.email)}
` +
- visibleCals.map(cal =>
- `
-
-
-
${escHtml(cal.name)}
-
-
-
-
`
- ).join('');
- }).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) {
+ if (!entries.length) {
container.innerHTML = `${t('error_no_calendars')}
`;
return;
}
- container.innerHTML = html;
+ container.innerHTML = entries.map(e =>
+ `
+
⠿
+
+
+
${escHtml(e.name)}
+
${escHtml(e.sourceLabel)}
+ ${e.remove ? `
${e.remove.icon}` : ''}
+
`
+ ).join('');
+
+ bindCalDragReorder(container);
// ── Checkbox handlers ──────────────────────────────────
container.querySelectorAll('input[type=checkbox]').forEach(cb => {
@@ -2304,7 +2306,7 @@ function renderGroupMemberPicker() {
? dir.map(u => {
const on = picked.has(u.id);
return `
- ${on ? '☑' : '☐'}
+ ${on ? '✓' : ''}
${escHtml(u.display_name || '')}
`;
}).join('')
@@ -2381,7 +2383,7 @@ function renderGroupVisibleList(selectedId) {
? ``
: ``;
return `
- ${sel ? '●' : '○'}
+
${dot}
${escHtml(name)}
`;
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 5823dee..f8df184 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -127,6 +127,7 @@ const translations = {
settings_group_visible: 'Für Gruppen sichtbarer Kalender',
settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist',
group_visible_none: 'Keiner',
+ drag_reorder: 'Zum Sortieren ziehen',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -385,6 +386,7 @@ const translations = {
settings_group_visible: 'Calendar visible to groups',
settings_group_visible_desc: 'Choose which of your calendars your group members can see',
group_visible_none: 'None',
+ drag_reorder: 'Drag to reorder',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 3a64095..3238676 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 = 'v27';
+export const APP_VERSION = 'v28';
--
2.47.3
From c62b3df33ac6902ea00f6bb723296cb3952da453 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:25:47 +0200
Subject: [PATCH 094/114] fix: Kalenderquelle als Tooltip statt inline (Namen
nicht mehr abgeschnitten)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Die Quelle/Konto stand inline rechts und hat die Kalendernamen abgeschnitten.
Jetzt im title-Tooltip des Eintrags ("Name · Quelle"); der Name nutzt die
volle Breite. Version v29.
Co-Authored-By: Claude Opus 4.8
---
frontend/js/calendar.js | 3 +--
frontend/js/version.js | 2 +-
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 2cec07c..d292ba3 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -669,12 +669,11 @@ function renderCalendarList() {
}
container.innerHTML = entries.map(e =>
- `
+ `
⠿
${escHtml(e.name)}
-
${escHtml(e.sourceLabel)}
${e.remove ? `
${e.remove.icon}` : ''}
`
).join('');
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 3238676..db99787 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 = 'v28';
+export const APP_VERSION = 'v29';
--
2.47.3
From cc4ccc7d81301e69956a62963b5609371eeaa47a Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:29:41 +0200
Subject: [PATCH 095/114] feat: Live-Vorschau beim Kalender-Sortieren
Beim Ziehen wandert die Zeile jetzt live zwischen die anderen, die Liste
macht sichtbar Platz an der Zielposition. Container-dragover wird nur einmal
gebunden (kein Listener-Stacking pro Render). Version v30.
Co-Authored-By: Claude Opus 4.8
---
frontend/js/calendar.js | 50 +++++++++++++++++++++++++++--------------
frontend/js/version.js | 2 +-
2 files changed, 34 insertions(+), 18 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index d292ba3..2604d21 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -575,34 +575,50 @@ function saveCalOrder(keys) {
}
// Drag & drop reordering of the flat calendar list (persisted per device).
+// The dragged row is moved live among its siblings during dragover, so the
+// list visibly "makes space" and you can see where it will land.
function bindCalDragReorder(container) {
- let dragKey = null;
+ // Find the row the cursor is currently above (by vertical midpoint), so we
+ // know before which sibling to insert the dragged row.
+ const rowAfter = (y) => {
+ const rows = [...container.querySelectorAll('.cal-item:not(.cal-dragging)')];
+ return rows.reduce((closest, row) => {
+ const box = row.getBoundingClientRect();
+ const offset = y - box.top - box.height / 2;
+ if (offset < 0 && offset > closest.offset) return { offset, el: row };
+ return closest;
+ }, { offset: Number.NEGATIVE_INFINITY, el: null }).el;
+ };
+
container.querySelectorAll('.cal-item').forEach(item => {
item.addEventListener('dragstart', e => {
// Don't start a drag from interactive children (checkbox, color dot, buttons).
if (e.target.closest('input, button, .cal-item-dot')) { e.preventDefault(); return; }
- dragKey = item.dataset.key;
e.dataTransfer.effectAllowed = 'move';
- item.classList.add('cal-dragging');
+ // Defer the class so the drag image is the full opaque row, then dim it.
+ requestAnimationFrame(() => item.classList.add('cal-dragging'));
});
item.addEventListener('dragend', () => {
- dragKey = null;
item.classList.remove('cal-dragging');
- });
- item.addEventListener('dragover', e => { e.preventDefault(); });
- item.addEventListener('drop', e => {
- e.preventDefault();
- const targetKey = item.dataset.key;
- if (!dragKey || dragKey === targetKey) return;
- const keys = [...container.querySelectorAll('.cal-item')].map(el => el.dataset.key);
- const from = keys.indexOf(dragKey);
- const to = keys.indexOf(targetKey);
- if (from === -1 || to === -1) return;
- keys.splice(to, 0, keys.splice(from, 1)[0]);
- saveCalOrder(keys);
- renderCalendarList();
+ // Persist the final DOM order; no full re-render needed.
+ saveCalOrder([...container.querySelectorAll('.cal-item')].map(el => el.dataset.key));
});
});
+
+ // Live reorder: move the dragged row to the hovered position as the cursor
+ // moves. Bound once on the (stable) container to avoid stacking listeners on
+ // every re-render.
+ if (!container.__dragBound) {
+ container.__dragBound = true;
+ container.addEventListener('dragover', e => {
+ e.preventDefault();
+ const dragging = container.querySelector('.cal-dragging');
+ if (!dragging) return;
+ const after = rowAfter(e.clientY);
+ if (after == null) container.appendChild(dragging);
+ else if (after !== dragging) container.insertBefore(dragging, after);
+ });
+ }
}
function renderCalendarList() {
diff --git a/frontend/js/version.js b/frontend/js/version.js
index db99787..04dc6d3 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 = 'v29';
+export const APP_VERSION = 'v30';
--
2.47.3
From 28a7cbe94e27500eec57d041f92681d5a8c1d24f Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:31:39 +0200
Subject: [PATCH 096/114] fix: kaputtes Plus-Icon + Kalendernamen nicht
vorzeitig abschneiden
- Plus-Icon (Kalender/Gruppen hinzufuegen): fehlerhafter SVG-Pfad (v11 statt
v6) -> korrekter, symmetrischer Plus-Pfad.
- Entfernen/Auge-Button per display:none statt opacity:0 -> reserviert keinen
Platz mehr; Kalendername nutzt volle Breite und kuerzt erst beim Hover
(wenn das Auge erscheint). Version v31.
Co-Authored-By: Claude Opus 4.8
---
frontend/css/app.css | 6 ++++--
frontend/index.html | 4 ++--
frontend/js/version.js | 2 +-
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 20d61de..5b62aaf 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -535,8 +535,10 @@ a { color: var(--primary); text-decoration: none; }
outline: none;
}
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
-.cal-item-remove { opacity: 0; }
-.cal-item:hover .cal-item-remove { opacity: 1; }
+/* Hide the remove/eye button until hover so the calendar name uses the full
+ width and only truncates while the button is visible. */
+.cal-item-remove { display: none; }
+.cal-item:hover .cal-item-remove { display: inline-flex; }
/* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
diff --git a/frontend/index.html b/frontend/index.html
index 54a1068..020e0b8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -185,7 +185,7 @@
Meine Kalender
-
+
Lokaler Kalender
@@ -204,7 +204,7 @@
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 04dc6d3..0ab4b87 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 = 'v30';
+export const APP_VERSION = 'v31';
--
2.47.3
From f9923b022e3e5bd0ba6a05e2aecdb7064c79e0aa Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:40:38 +0200
Subject: [PATCH 097/114] feat: Login-Name vs. Anzeigename (Server)
- Neue Spalte users.display_name (Original-Schreibweise); username bleibt der
lowercase Login-Name. Setup/Create setzen display_name aus der Eingabe.
- Login bleibt case-insensitive (Anzeigename eingeben funktioniert -> wird
lowercased -> trifft den Login-Namen).
- Profil: PUT /api/profile/ kann display_name UND username (Login-Name) aendern;
bei Login-Namen-Wechsel kommt ein frischer Token zurueck (JWT sub haengt am
Namen). Stabile interne ID (Integer-PK) traegt alle Verweise -> Umbenennen
bricht Shares/Gruppen/creator_id nicht.
- display_name ueberall ausgeliefert/genutzt (me, profile, users, directory,
shares, Gruppen-Mitglieder, creator/owner, ORGANIZER-Export).
- Migration + Backfill (display_name = username). Tests ergaenzt (17 gruen).
Co-Authored-By: Claude Opus 4.8
---
backend/local_events_util.py | 2 +-
backend/main.py | 12 +++++++++++
backend/models.py | 9 +++++---
backend/routers/auth_router.py | 9 +++++++-
backend/routers/caldav_router.py | 2 +-
backend/routers/groups_router.py | 4 ++--
backend/routers/local_router.py | 6 +++---
backend/routers/profile_router.py | 32 +++++++++++++++++++++++++++--
backend/routers/users_router.py | 11 ++++++++--
backend/tests/test_collaboration.py | 31 ++++++++++++++++++++++++++++
10 files changed, 103 insertions(+), 15 deletions(-)
diff --git a/backend/local_events_util.py b/backend/local_events_util.py
index 1a88c08..4dcd7ce 100644
--- a/backend/local_events_util.py
+++ b/backend/local_events_util.py
@@ -36,7 +36,7 @@ def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None)
if name_cache is not None:
display = name_cache.get(ev.creator_id)
if display is None and ev.creator is not None:
- display = ev.creator.username
+ display = ev.creator.display_name or ev.creator.username
if display is not None:
return {"id": ev.creator_id, "display_name": display}
if ev.creator_name_external:
diff --git a/backend/main.py b/backend/main.py
index 5fca9f3..027e1c4 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -163,6 +163,18 @@ def _migrate():
logging.info("Migration: added group_visible_calendar_id to user_settings")
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
+ conn.commit()
+ logging.info("Migration: added display_name to users")
+ except Exception:
+ pass
+ # Backfill display_name from username for existing rows (only where empty).
+ try:
+ conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
+ conn.commit()
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index a8a6011..2ace8c2 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -7,7 +7,10 @@ class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
+ # Login name: always lowercase, unique, used for authentication.
username = Column(String(50), unique=True, nullable=False)
+ # Human-facing name with original casing; editable. Falls back to username.
+ display_name = Column(String(100), nullable=True)
email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False)
@@ -35,9 +38,9 @@ class User(Base):
)
@property
- def display_name(self) -> str:
- """No dedicated display-name column exists — fall back to the username."""
- return self.username
+ def display(self) -> str:
+ """The name to show users: display_name if set, else the login name."""
+ return self.display_name or self.username
class CalDAVAccount(Base):
diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py
index 98ede29..3aa16bd 100644
--- a/backend/routers/auth_router.py
+++ b/backend/routers/auth_router.py
@@ -32,7 +32,12 @@ class LoginRequest(BaseModel):
def _user_dict(user: models.User) -> dict:
- return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
+ return {
+ "id": user.id,
+ "username": user.username,
+ "display_name": user.display_name or user.username,
+ "is_admin": user.is_admin,
+ }
@router.get("/setup-required")
@@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
raise HTTPException(400, "Setup already completed")
user = models.User(
username=req.username.lower(),
+ display_name=req.username.strip(), # keep the original casing for display
email=req.email,
password_hash=get_password_hash(req.password),
is_admin=True,
@@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
+ "display_name": current_user.display_name or current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,
diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py
index 63cdac8..d4501ae 100644
--- a/backend/routers/caldav_router.py
+++ b/backend/routers/caldav_router.py
@@ -334,7 +334,7 @@ def get_events(
)
.all()
) if readable_ids else []
- name_cache = {u.id: u.username for u in db.query(models.User).all()}
+ name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
for local_cal in local_calendars:
local_events = (
db.query(models.LocalEvent)
diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py
index c427866..bc72b65 100644
--- a/backend/routers/groups_router.py
+++ b/backend/routers/groups_router.py
@@ -149,7 +149,7 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
u = db.query(models.User).filter(models.User.id == m.user_id).first()
member_dicts.append({
"id": m.user_id,
- "display_name": u.username if u else None,
+ "display_name": (u.display_name or u.username) if u else None,
"role": m.role,
})
return {
@@ -264,7 +264,7 @@ def combined_events(
end_dt = end_dt.replace(tzinfo=timezone.utc)
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
- name_cache = {u.id: u.username for u in db.query(models.User).all()}
+ name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
visibility_cache: dict[int, str] = {}
def visibility_for(user_id: int) -> str:
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index 7060f18..cbb6e33 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -134,7 +134,7 @@ def list_calendars(
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict(
cal, owned=False,
- shared_by=owner.username if owner else None,
+ shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission,
)
if cal.id in group_cal_map:
@@ -330,7 +330,7 @@ def list_shares(
u = db.query(models.User).filter(models.User.id == s.user_id).first()
out.append({
"user_id": s.user_id,
- "display_name": u.username if u else None,
+ "display_name": (u.display_name or u.username) if u else None,
"permission": s.permission,
"created_at": s.created_at,
})
@@ -501,7 +501,7 @@ def export_calendar(
.all()
)
# Resolve creator display names for ORGANIZER.
- name_cache = {u.id: u.username for u in db.query(models.User).all()}
+ name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
return Response(
diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py
index 564410b..257aa67 100644
--- a/backend/routers/profile_router.py
+++ b/backend/routers/profile_router.py
@@ -11,8 +11,10 @@ from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
+from sqlalchemy import func
+
import models
-from auth import get_current_user, get_password_hash, verify_password
+from auth import create_access_token, get_current_user, get_password_hash, verify_password
from database import DATA_DIR, get_db
router = APIRouter()
@@ -26,6 +28,8 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel):
email: Optional[str] = None
+ display_name: Optional[str] = None
+ username: Optional[str] = None # login name (stored lowercase)
class PasswordChange(BaseModel):
@@ -47,6 +51,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
+ "display_name": current_user.display_name or current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,
@@ -60,10 +65,33 @@ def update_profile(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
+ result = {"ok": True}
if data.email is not None:
current_user.email = data.email or None
+ if data.display_name is not None:
+ dn = data.display_name.strip()
+ current_user.display_name = dn or current_user.username
+ if data.username is not None:
+ new_login = data.username.strip().lower()
+ if not new_login:
+ raise HTTPException(422, "Login name cannot be empty")
+ if new_login != current_user.username:
+ taken = (
+ db.query(models.User)
+ .filter(func.lower(models.User.username) == new_login,
+ models.User.id != current_user.id)
+ .first()
+ )
+ if taken:
+ raise HTTPException(400, "Username already taken")
+ current_user.username = new_login
+ db.commit()
+ # The JWT 'sub' is the login name — renaming it invalidates the old
+ # token, so hand back a fresh one for the client to store.
+ result["access_token"] = create_access_token({"sub": new_login})
+ return result
db.commit()
- return {"ok": True}
+ return result
# ── Avatar ────────────────────────────────────────────────
diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py
index 35fbd08..3c4831a 100644
--- a/backend/routers/users_router.py
+++ b/backend/routers/users_router.py
@@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel):
def _user_dict(u: models.User) -> dict:
- return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin}
+ return {
+ "id": u.id,
+ "username": u.username,
+ "display_name": u.display_name or u.username,
+ "email": u.email,
+ "is_admin": u.is_admin,
+ }
@router.get("/")
@@ -51,7 +57,7 @@ def user_directory(
.order_by(models.User.username)
.all()
)
- return [{"id": u.id, "display_name": u.username} for u in users]
+ return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
@router.post("/")
@@ -64,6 +70,7 @@ def create_user(
raise HTTPException(400, "Username already taken")
user = models.User(
username=req.username.lower(),
+ display_name=req.username.strip(), # keep the original casing for display
email=req.email,
password_hash=get_password_hash(req.password),
is_admin=req.is_admin,
diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py
index a93bae5..a44d0a3 100644
--- a/backend/tests/test_collaboration.py
+++ b/backend/tests/test_collaboration.py
@@ -225,6 +225,37 @@ def test_member_calendar_hidden_until_designated(client):
assert any(e["title"] == "Bobs Termin" for e in seen2)
+def test_display_name_case_preserved_and_login_case_insensitive(client):
+ # Setup with mixed-case name: login name lowercased, display name kept.
+ r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"})
+ assert r.status_code == 200, r.text
+ user = r.json()["user"]
+ assert user["username"] == "guido"
+ assert user["display_name"] == "Guido"
+
+ # Login is case-insensitive (typing the display name "GUIDO" works).
+ r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"})
+ assert r2.status_code == 200, r2.text
+ tok = r2.json()["access_token"]
+
+ # /me reflects the cased display name.
+ me = client.get("/api/auth/me", headers=auth(tok)).json()
+ assert me["display_name"] == "Guido" and me["username"] == "guido"
+
+
+def test_rename_login_name_returns_new_token(client):
+ admin = register_admin(client, "alice")
+ # Change display name (no token change).
+ r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."})
+ assert r.status_code == 200 and "access_token" not in r.json()
+ # Change login name -> fresh token, references survive (id is stable).
+ r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"})
+ assert r2.status_code == 200 and r2.json().get("access_token")
+ new_tok = r2.json()["access_token"]
+ me = client.get("/api/auth/me", headers=auth(new_tok)).json()
+ assert me["username"] == "alice2" and me["display_name"] == "Alice W."
+
+
def test_private_visibility_validation(client):
admin = register_admin(client)
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})
--
2.47.3
From 2033cf99d4f34c708853d2deb997a6d7a5c986c0 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 17:49:19 +0200
Subject: [PATCH 098/114] feat: Anzeigename im Web (Profil bearbeiten +
Anzeige)
- Profil: Anzeigename + Login-Name editierbar (vorher Benutzername read-only).
Login-Namenwechsel speichert den frisch zurueckgegebenen Token.
- Menue/Dropdown und "Erstellt von"/Picker zeigen den Anzeigenamen.
- localStorage-User um display_name ergaenzt. Version v32.
Co-Authored-By: Claude Opus 4.8
---
frontend/index.html | 13 +++++++++----
frontend/js/app.js | 2 +-
frontend/js/calendar.js | 24 ++++++++++++++++++++----
frontend/js/i18n.js | 10 ++++++++++
frontend/js/version.js | 2 +-
5 files changed, 41 insertions(+), 10 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index 020e0b8..f3125d1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -858,16 +858,21 @@
diff --git a/frontend/js/app.js b/frontend/js/app.js
index cf2c277..78773f8 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -60,7 +60,7 @@ async function launchApp() {
// User dropdown menu
const dropdown = document.getElementById('user-dropdown');
- document.getElementById('dropdown-username').textContent = user.username || 'Benutzer';
+ document.getElementById('dropdown-username').textContent = user.display_name || user.username || 'Benutzer';
avatar.addEventListener('click', e => {
e.stopPropagation();
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 2604d21..178c50c 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -2951,9 +2951,10 @@ function bindSettingsModal() {
export function openProfileModal() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
- // Username & email
+ // Names & email
document.getElementById('profile-username').value = user.username || '';
- document.getElementById('profile-display-name').textContent = user.username || '';
+ document.getElementById('profile-display-name-input').value = user.display_name || user.username || '';
+ document.getElementById('profile-display-name').textContent = user.display_name || user.username || '';
// Load fresh profile data
api.get('/profile/').then(profile => {
@@ -3021,11 +3022,26 @@ function renderProfileCalendars() {
}
function bindProfileModal() {
- // Save profile info (email)
+ // Save profile info (display name, login name, email)
document.getElementById('profile-save-info').onclick = async () => {
const email = document.getElementById('profile-email').value.trim();
+ const displayName = document.getElementById('profile-display-name-input').value.trim();
+ const loginName = document.getElementById('profile-username').value.trim();
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ const body = { email: email || null };
+ if (displayName) body.display_name = displayName;
+ if (loginName && loginName.toLowerCase() !== (user.username || '')) body.username = loginName;
try {
- await api.put('/profile/', { email: email || null });
+ const res = await api.put('/profile/', body);
+ // A login-name change returns a fresh token (the old one is now invalid).
+ if (res && res.access_token) localStorage.setItem('token', res.access_token);
+ // Keep the cached user (menu, "created by", etc.) in sync.
+ const updated = { ...user };
+ if (displayName) updated.display_name = displayName;
+ if (body.username) updated.username = body.username.toLowerCase();
+ localStorage.setItem('user', JSON.stringify(updated));
+ const dd = document.getElementById('dropdown-username');
+ if (dd) dd.textContent = updated.display_name || updated.username || 'Benutzer';
showToast(t('profile_saved'));
} catch (e) { showToast(e.message, true); }
};
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index f8df184..b26bd6c 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -128,6 +128,11 @@ const translations = {
settings_group_visible_desc: 'Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist',
group_visible_none: 'Keiner',
drag_reorder: 'Zum Sortieren ziehen',
+ profile_account: 'Konto',
+ profile_display_name: 'Anzeigename',
+ profile_display_name_ph: 'Anzeigename',
+ profile_login_name: 'Login-Name',
+ profile_login_name_desc: 'Klein geschrieben, fürs Anmelden. Groß-/Kleinschreibung egal.',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
@@ -387,6 +392,11 @@ const translations = {
settings_group_visible_desc: 'Choose which of your calendars your group members can see',
group_visible_none: 'None',
drag_reorder: 'Drag to reorder',
+ profile_account: 'Account',
+ profile_display_name: 'Display name',
+ profile_display_name_ph: 'Display name',
+ profile_login_name: 'Login name',
+ profile_login_name_desc: 'Lowercase, used to sign in. Case-insensitive.',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 0ab4b87..4e4227c 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 = 'v31';
+export const APP_VERSION = 'v32';
--
2.47.3
From 682f9613ecf495e11cce8f12d5377dbd4cfe1d04 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 18:01:55 +0200
Subject: [PATCH 099/114] =?UTF-8?q?feat:=20sch=C3=B6nere=20Gruppenansicht?=
=?UTF-8?q?=20=E2=80=93=20Vorname=20statt=20Initialen=20+=20Farbe=20pro=20?=
=?UTF-8?q?Person?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Kombinierte Ansicht: kryptisches "[SC]"-Initialen-Präfix ersetzt durch den
Vornamen ("Guido: Titel") für fremde Termine; eigene Termine ohne Präfix;
Gruppen-Termine mit 👥. Zusätzlich feste Farbkodierung pro Besitzer, damit
jedes Mitglied als Gruppe lesbar ist. Version v33.
Co-Authored-By: Claude Opus 4.8
---
frontend/js/calendar.js | 30 ++++++++++++++++++++++--------
frontend/js/version.js | 2 +-
2 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 178c50c..0eb2f13 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -265,11 +265,17 @@ function prefetchIfNeeded(viewStart, viewEnd) {
}
// ── Data fetching ─────────────────────────────────────────
-function initials(name) {
- if (!name) return '?';
- const parts = String(name).trim().split(/\s+/);
- if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+function firstName(name) {
+ if (!name) return '';
+ return String(name).trim().split(/\s+/)[0];
+}
+
+// Stable per-owner colour so each member's events read as a group in the
+// combined view (Google-Family style).
+const OWNER_PALETTE = ['#4285f4', '#ea4335', '#34a853', '#fbbc05', '#9c27b0', '#ff7043', '#46bdc6', '#7090c0'];
+function ownerColor(ownerId) {
+ if (ownerId == null) return null;
+ return OWNER_PALETTE[(Number(ownerId) >>> 0) % OWNER_PALETTE.length];
}
async function fetchAndRender(force = false, silent = false) {
@@ -284,11 +290,19 @@ async function fetchAndRender(force = false, silent = false) {
try {
const resp = await api.get(
`/groups/${state.activeGroupId}/combined?start=${fStart.toISOString()}&end=${fEnd.toISOString()}`);
+ const me = JSON.parse(localStorage.getItem('user') || '{}');
const evs = (resp.events || []).map(ev => {
- // Prefix the title so every renderer shows who an event belongs to.
const ownerName = ev.owner && ev.owner.display_name;
- const tag = ev.is_group_event ? '👥' : (ownerName ? `[${initials(ownerName)}]` : '');
- return { ...ev, title: tag ? `${tag} ${ev.title}` : ev.title };
+ const ownerId = ev.owner && ev.owner.id;
+ const isMine = ownerId != null && me.id != null && ownerId === me.id;
+ // Group events get a 👥 marker; others get the owner's first name (not
+ // cryptic initials); your own events stay unprefixed. Colour-code by
+ // owner so each member reads as a group.
+ let title = ev.title;
+ if (ev.is_group_event) title = `👥 ${ev.title}`;
+ else if (ownerName && !isMine) title = `${firstName(ownerName)}: ${ev.title}`;
+ const color = ev.is_group_event ? ev.color : (ownerColor(ownerId) || ev.color);
+ return { ...ev, title, color };
});
eventCache.start = null; eventCache.end = null; // invalidate normal cache
state.events = evs;
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 4e4227c..a1a28e2 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 = 'v32';
+export const APP_VERSION = 'v33';
--
2.47.3
From 7429a309c3a3870ba312f1560be95f79349967e4 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 18:34:59 +0200
Subject: [PATCH 100/114] =?UTF-8?q?feat:=20w=C3=A4hlbares=20Gruppen-Icon,?=
=?UTF-8?q?=20geteilter=20Kalender=20markiert,=20Ersteller=20bei=20Gruppen?=
=?UTF-8?q?-Terminen?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Gruppe: wählbares Emoji-Icon (groups.icon-Spalte + PUT /api/groups/{id});
wird in der Sidebar statt des Zahnrads angezeigt; Verwalten jetzt klares "⋯".
Gruppe umbenennen möglich (war vorher gesperrt).
- "Meine Kalender": der aktuell für Gruppen sichtbare Kalender wird mit 👥
gekennzeichnet.
- Gruppenansicht: Gruppenkalender-Termine zeigen, wer sie hinzugefügt hat
(👥 Vorname: Titel) und sind nach Ersteller eingefärbt; jeder kann weiterhin
Termine im Gruppenkalender anlegen. Version v34.
Co-Authored-By: Claude Opus 4.8
---
backend/main.py | 6 ++++
backend/models.py | 1 +
backend/routers/groups_router.py | 28 +++++++++++++++-
frontend/css/app.css | 16 ++++++++++
frontend/index.html | 4 +++
frontend/js/calendar.js | 55 ++++++++++++++++++++++++++------
frontend/js/i18n.js | 4 +++
frontend/js/version.js | 2 +-
8 files changed, 104 insertions(+), 12 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 027e1c4..2ce442c 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -175,6 +175,12 @@ def _migrate():
conn.commit()
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE groups ADD COLUMN icon VARCHAR(16)"))
+ conn.commit()
+ logging.info("Migration: added icon to groups")
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index 2ace8c2..d515637 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -268,6 +268,7 @@ class Group(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
+ icon = Column(String(16), nullable=True) # emoji shown for the group
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(String(50), nullable=True) # ISO 8601
diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py
index bc72b65..73fc163 100644
--- a/backend/routers/groups_router.py
+++ b/backend/routers/groups_router.py
@@ -34,6 +34,12 @@ def _now_iso() -> str:
class GroupCreate(BaseModel):
name: str
member_ids: List[int] = []
+ icon: Optional[str] = None
+
+
+class GroupUpdate(BaseModel):
+ name: Optional[str] = None
+ icon: Optional[str] = None
class MemberAdd(BaseModel):
@@ -86,7 +92,8 @@ def create_group(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
- group = models.Group(name=data.name, created_by=current_user.id, created_at=_now_iso())
+ group = models.Group(name=data.name, icon=(data.icon or None),
+ created_by=current_user.id, created_at=_now_iso())
db.add(group)
db.flush()
@@ -135,6 +142,7 @@ def list_groups(
out.append({
"id": group.id,
"name": group.name,
+ "icon": group.icon,
"role": m.role,
"member_count": member_count,
"group_calendar_id": _group_calendar_id(db, group.id),
@@ -155,12 +163,30 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
return {
"id": group.id,
"name": group.name,
+ "icon": group.icon,
"created_by": group.created_by,
"members": member_dicts,
"group_calendar_id": _group_calendar_id(db, group.id),
}
+@router.put("/{group_id}")
+def update_group(
+ group_id: int,
+ data: GroupUpdate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ _require_owner(db, group, current_user)
+ if data.name is not None and data.name.strip():
+ group.name = data.name.strip()
+ if data.icon is not None:
+ group.icon = data.icon or None
+ db.commit()
+ return _group_detail(db, group, current_user)
+
+
@router.get("/{group_id}")
def get_group(
group_id: int,
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 5b62aaf..80e40d1 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1923,3 +1923,19 @@ a { color: var(--primary); text-decoration: none; }
user-select: none;
}
.cal-item.cal-dragging { opacity: .5; }
+
+/* Group emoji + icon picker */
+.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
+.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
+.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
+.group-icon-opt {
+ width: 38px; height: 38px;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-size: 18px; line-height: 1;
+ background: var(--bg-app);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+}
+.group-icon-opt:hover { background: var(--bg-surface); }
+.group-icon-opt.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
diff --git a/frontend/index.html b/frontend/index.html
index f3125d1..97ecf10 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -376,6 +376,10 @@
Name
+
`
).join('');
@@ -2267,9 +2278,10 @@ function renderGroupList() {
}
el.innerHTML = state.groups.map(g =>
`
+
${escHtml(g.icon || '👥')}
${escHtml(g.name)}
-
+
`
).join('');
@@ -2316,7 +2328,11 @@ async function openGroupModal(groupId) {
const existingMemberIds = new Set((detail ? detail.members : []).map(m => m.id));
document.getElementById('group-name').value = detail ? detail.name : '';
- document.getElementById('group-name').disabled = isEdit; // rename not supported by API
+ document.getElementById('group-name').disabled = false; // rename supported via PUT
+
+ // Icon picker
+ modal.__icon = (detail && detail.icon) || '👥';
+ renderGroupIconPicker();
// Member picker: current members are checked; the owner (me) is excluded.
modal.__directory = directory;
@@ -2326,6 +2342,23 @@ async function openGroupModal(groupId) {
openModal('modal-group');
}
+const GROUP_ICONS = ['👥', '👨👩👧', '🏠', '❤️', '🧑🤝🧑', '⚽', '🎓', '💼', '🎉', '🐶', '✈️', '🎵', '🍕', '📚', '🌳', '⭐'];
+function renderGroupIconPicker() {
+ const modal = document.getElementById('modal-group');
+ const sel = modal.__icon || '👥';
+ const picker = document.getElementById('group-icon-picker');
+ if (!picker) return;
+ picker.innerHTML = GROUP_ICONS.map(ic =>
+ `
${ic}`
+ ).join('');
+ picker.querySelectorAll('.group-icon-opt').forEach(b => {
+ b.addEventListener('click', () => {
+ modal.__icon = b.dataset.icon;
+ renderGroupIconPicker();
+ });
+ });
+}
+
function renderGroupMemberPicker() {
const modal = document.getElementById('modal-group');
const dir = modal.__directory || [];
@@ -2360,8 +2393,12 @@ function bindGroupUI() {
const groupId = modal.dataset.groupId;
const memberIds = [...(modal.__memberIds || new Set())];
try {
+ const name = document.getElementById('group-name').value.trim();
+ const icon = modal.__icon || '👥';
+ if (!name) { showToast(t('error_enter_title'), true); return; }
if (groupId) {
- // Manage mode: sync member additions/removals.
+ // Manage mode: update name/icon, then sync member additions/removals.
+ await api.put(`/groups/${groupId}`, { name, icon });
const detail = await api.get(`/groups/${groupId}`);
const me = JSON.parse(localStorage.getItem('user') || '{}');
const current = new Set(detail.members.map(m => m.id).filter(id => id !== me.id));
@@ -2369,9 +2406,7 @@ function bindGroupUI() {
for (const id of current) if (!memberIds.includes(id)) await api.delete(`/groups/${groupId}/members/${id}`);
showToast(t('group_saved'));
} else {
- const name = document.getElementById('group-name').value.trim();
- if (!name) { showToast(t('error_enter_title'), true); return; }
- await api.post('/groups/', { name, member_ids: memberIds });
+ await api.post('/groups/', { name, member_ids: memberIds, icon });
showToast(t('group_created'));
}
closeModal('modal-group');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index b26bd6c..1adb819 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -121,6 +121,8 @@ const translations = {
group_created: 'Gruppe erstellt',
group_view_label: 'Gruppenansicht: {name}',
group_exit: 'Gruppenansicht verlassen',
+ group_icon: 'Icon',
+ group_visible_flag: 'Für deine Gruppen sichtbar',
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
shared_with_me: 'Mit dir geteilt',
settings_calendars: 'Kalender',
@@ -385,6 +387,8 @@ const translations = {
group_created: 'Group created',
group_view_label: 'Group view: {name}',
group_exit: 'Exit group view',
+ group_icon: 'Icon',
+ group_visible_flag: 'Visible to your groups',
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
shared_with_me: 'Shared with me',
settings_calendars: 'Calendars',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index a1a28e2..09e33fd 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 = 'v33';
+export const APP_VERSION = 'v34';
--
2.47.3
From fd7f7ddfe0fa9d9306c6f56be95dcdb2da44a585 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 18:38:35 +0200
Subject: [PATCH 101/114] feat: Gruppentermin-Button in der Gruppenansicht +
Banner in Akzentfarbe
- "+ Gruppentermin"-Button im Gruppenansicht-Banner: oeffnet den Editor mit
dem Gruppenkalender vorausgewaehlt -> jeder kann direkt eintragen.
- Banner + aktive Gruppe nutzen jetzt die Akzentfarbe (color-mix, Fallback)
statt Blau. Version v35.
Co-Authored-By: Claude Opus 4.8
---
frontend/css/app.css | 8 +++++---
frontend/index.html | 2 ++
frontend/js/calendar.js | 12 ++++++++++++
frontend/js/i18n.js | 2 ++
frontend/js/version.js | 2 +-
5 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 80e40d1..65982ff 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -1811,13 +1811,15 @@ a { color: var(--primary); text-decoration: none; }
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
- background: rgba(66, 133, 244, 0.12);
- border-bottom: 1px solid var(--border);
+ background: var(--bg-surface); /* fallback for browsers without color-mix */
+ background: color-mix(in srgb, var(--accent) 15%, var(--bg-app));
+ border-bottom: 1px solid var(--accent);
font-size: 14px;
color: var(--text-1);
}
.group-item-active {
- background: rgba(66, 133, 244, 0.15);
+ background: var(--bg-surface);
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 8px;
}
.group-item .cal-item-name { cursor: pointer; flex: 1; }
diff --git a/frontend/index.html b/frontend/index.html
index 97ecf10..5741d45 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -218,6 +218,8 @@
+
+
+ Gruppentermin
Gruppenansicht verlassen
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 212d1e3..44e36a9 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -2293,6 +2293,16 @@ function renderGroupList() {
});
}
+// Open the new-event editor preselected to the active group's shared calendar.
+function openGroupEventModal() {
+ const g = state.groups.find(x => x.id === state.activeGroupId);
+ if (!g || !g.group_calendar_id) { showToast(t('groups_none'), true); return; }
+ openNewEventModal(state.selectedDate || state.currentDate);
+ const sel = document.getElementById('ev-calendar');
+ sel.value = `local-${g.group_calendar_id}`;
+ updatePrivateRow(false);
+}
+
function enterGroupView(groupId) {
state.activeGroupId = groupId;
const g = state.groups.find(x => x.id === groupId);
@@ -2387,6 +2397,8 @@ function bindGroupUI() {
if (addBtn) addBtn.onclick = () => openGroupModal(null);
const exitBtn = document.getElementById('group-view-exit');
if (exitBtn) exitBtn.onclick = exitGroupView;
+ const newEvtBtn = document.getElementById('group-view-new-event');
+ if (newEvtBtn) newEvtBtn.onclick = openGroupEventModal;
document.getElementById('group-save').onclick = async () => {
const modal = document.getElementById('modal-group');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 1adb819..61b653d 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -123,6 +123,7 @@ const translations = {
group_exit: 'Gruppenansicht verlassen',
group_icon: 'Icon',
group_visible_flag: 'Für deine Gruppen sichtbar',
+ group_new_event: '+ Gruppentermin',
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
shared_with_me: 'Mit dir geteilt',
settings_calendars: 'Kalender',
@@ -389,6 +390,7 @@ const translations = {
group_exit: 'Exit group view',
group_icon: 'Icon',
group_visible_flag: 'Visible to your groups',
+ group_new_event: '+ Group event',
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
shared_with_me: 'Shared with me',
settings_calendars: 'Calendars',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 09e33fd..27ee7aa 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 = 'v34';
+export const APP_VERSION = 'v35';
--
2.47.3
From b0f1497bc840fabf6a65e6b1b6efeff1980d7a6e Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 18:45:49 +0200
Subject: [PATCH 102/114] fix: Monatsansicht-Layout bei Monatswechsel +
Gruppenkalender in Sidebar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Monatsmarker ("JUN") sitzt jetzt inline neben der Tageszahl ("1 JUN") statt
darüber -> einheitliche Zeilenhöhe; Termine der Woche rutschen nicht mehr
nach unten und überlappen nicht mehr mit "+X weitere".
- Gruppenkalender erscheint in "Meine Kalender" (mit 👥-Markierung) und kann
aus-/eingeblendet werden; Besitzer kann ihn umfärben. Recolor fremder
Kalender abgefangen (nur Besitzer). Version v36.
Co-Authored-By: Claude Opus 4.8
---
backend/routers/local_router.py | 1 +
frontend/css/app.css | 18 +++++++++---------
frontend/js/calendar.js | 9 +++++++++
frontend/js/i18n.js | 2 ++
frontend/js/version.js | 2 +-
frontend/js/views/month.js | 2 +-
6 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index cbb6e33..a47e70b 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -116,6 +116,7 @@ def list_calendars(
d = _cal_dict(c, owned=True)
if c.id in group_cal_map:
d["group"] = True
+ d["shared_by"] = group_cal_map[c.id] # group name, for labelling
result.append(d)
seen_ids = {c.id for c in own}
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 65982ff..328b908 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -593,10 +593,10 @@ a { color: var(--primary); text-decoration: none; }
}
.month-col.first-of-month {
display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 0;
- padding-top: 8px;
+ flex-direction: row; /* "1 JUN" inline — keeps the header height uniform */
+ align-items: center;
+ gap: 6px;
+ padding-top: 4px;
}
/* Dividers via pseudo-elements so they render above events (z-index 2) */
.month-col.month-divider-left::before {
@@ -647,8 +647,8 @@ a { color: var(--primary); text-decoration: none; }
letter-spacing: .5px;
color: var(--month-label-color, #7090c0);
line-height: 1;
- padding: 0 2px;
- margin: 0 0 2px 4px;
+ padding: 0;
+ margin: 0;
position: relative;
z-index: 3; /* above events overlay (z-index 2) */
}
@@ -657,10 +657,10 @@ a { color: var(--primary); text-decoration: none; }
position: relative;
z-index: 3;
}
-/* Push events overlay down when row contains a first-of-month cell so the
- day "1" (which sits below the month marker) isn't hidden by event bars */
+/* Month marker now sits inline next to the day number, so the header height is
+ uniform and the events overlay needs no extra offset for month-start weeks. */
.month-row.has-month-marker .month-events-overlay {
- top: 56px;
+ top: 30px;
}
/* Events overlay — pointer-events:none so clicks pass to columns */
.month-events-overlay {
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 44e36a9..19e5816 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -672,6 +672,13 @@ function renderCalendarList() {
name: cal.name, color: cal.color, enabled: cal.enabled,
sourceLabel: `${t('shared_with_me')} · ${cal.shared_by || ''}`, remove: null });
});
+ // Group calendars (owned by the creator or reached via membership) — shown so
+ // they can be toggled/recoloured; marked with the group emoji.
+ state.localCalendars.filter(c => c.group).forEach(cal => {
+ entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
+ name: cal.name, color: cal.color, enabled: cal.enabled,
+ sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`, isGroupCal: true, remove: null });
+ });
state.icalSubscriptions.forEach(sub => {
entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
name: sub.name, color: sub.color, enabled: sub.enabled,
@@ -714,6 +721,7 @@ function renderCalendarList() {
${escHtml(e.name)}
+ ${e.isGroupCal ? `👥` : ''}
${e.groupVisible ? `👥` : ''}
${e.remove ? `${e.remove.icon}` : ''}
`
@@ -794,6 +802,7 @@ function renderCalendarList() {
} else if (source === 'local') {
const calId = parseInt(dot.dataset.calId);
const cal = state.localCalendars.find(c => c.id === calId);
+ if (cal && cal.owned === false) { showToast(t('only_owner_color'), true); return; }
const picked = await openColorPicker(dot, cal?.color || '#34a853');
if (picked) {
await api.put(`/local/calendars/${calId}`, { color: picked });
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index 61b653d..bb9e67d 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -124,6 +124,7 @@ const translations = {
group_icon: 'Icon',
group_visible_flag: 'Für deine Gruppen sichtbar',
group_new_event: '+ Gruppentermin',
+ only_owner_color: 'Nur der Besitzer kann die Farbe ändern',
upload_too_large: 'Datei zu groß (Server-Limit). Bitte Upload-Limit erhöhen.',
shared_with_me: 'Mit dir geteilt',
settings_calendars: 'Kalender',
@@ -391,6 +392,7 @@ const translations = {
group_icon: 'Icon',
group_visible_flag: 'Visible to your groups',
group_new_event: '+ Group event',
+ only_owner_color: 'Only the owner can change the colour',
upload_too_large: 'File too large (server limit). Please raise the upload limit.',
shared_with_me: 'Shared with me',
settings_calendars: 'Calendars',
diff --git a/frontend/js/version.js b/frontend/js/version.js
index 27ee7aa..1355e8f 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 = 'v35';
+export const APP_VERSION = 'v36';
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index e213a75..dbc0fd4 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -152,8 +152,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
? `${monthsShort[cell.getMonth()]}
`
: '';
colsHtml += `
- ${monthLabel}
${cell.getDate()}
+ ${monthLabel}
`;
});
--
2.47.3
From a992d977967351287483bbad6808508d353045e2 Mon Sep 17 00:00:00 2001
From: Scarriffle
Date: Sun, 31 May 2026 18:52:40 +0200
Subject: [PATCH 103/114] =?UTF-8?q?feat:=20server-definierte=20Gruppenfarb?=
=?UTF-8?q?en=20(per=20API)=20+=20Gruppentermine=20=C3=BCberall=20erstelle?=
=?UTF-8?q?n?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Pro Mitglied eine Farbe (group_members.color, auto aus Palette, vom Owner
oder Mitglied selbst änderbar via PUT /groups/{id}/members/{uid}/color).
- Gruppentermin-Farbe = Farbe des Gruppenkalenders.
- API liefert Farben aus: GET /groups & /groups/{id} (member.color,
group_calendar_color), GET /groups/{id}/combined (display_color pro Event)
-> Apps können dieselben Farben anzeigen. Test ergänzt (18 grün).
- Web nutzt display_color; Gruppenkalender im Termin-Editor mit 👥 markiert
(Gruppentermine ohne Gruppenansicht erstellbar); Mitglieder-Farben im
Verwalten-Dialog editierbar. Version v37.
Co-Authored-By: Claude Opus 4.8
---
backend/main.py | 6 +++
backend/models.py | 1 +
backend/routers/groups_router.py | 72 ++++++++++++++++++++++++++---
backend/tests/test_collaboration.py | 31 +++++++++++++
frontend/index.html | 4 ++
frontend/js/calendar.js | 46 +++++++++++++++---
frontend/js/i18n.js | 2 +
frontend/js/version.js | 2 +-
8 files changed, 150 insertions(+), 14 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 2ce442c..013c9d4 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -181,6 +181,12 @@ def _migrate():
logging.info("Migration: added icon to groups")
except Exception:
pass
+ try:
+ conn.execute(text("ALTER TABLE group_members ADD COLUMN color VARCHAR(7)"))
+ conn.commit()
+ logging.info("Migration: added color to group_members")
+ except Exception:
+ pass
_migrate()
diff --git a/backend/models.py b/backend/models.py
index d515637..74184a5 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -291,6 +291,7 @@ class GroupMember(Base):
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String(10), default="member") # 'owner' | 'member'
+ color = Column(String(7), nullable=True) # this member's colour within the group
joined_at = Column(String(50), nullable=True) # ISO 8601
group = relationship("Group", back_populates="members")
diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py
index 73fc163..47adc04 100644
--- a/backend/routers/groups_router.py
+++ b/backend/routers/groups_router.py
@@ -25,6 +25,13 @@ logger = logging.getLogger(__name__)
router = APIRouter()
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
+# Distinct per-member colours (server-defined so every client shows the same).
+MEMBER_PALETTE = ["#4285f4", "#ea4335", "#34a853", "#fbbc05", "#9c27b0", "#ff7043", "#46bdc6", "#7090c0"]
+
+
+def _next_member_color(db: Session, group_id: int) -> str:
+ n = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).count()
+ return MEMBER_PALETTE[n % len(MEMBER_PALETTE)]
def _now_iso() -> str:
@@ -86,6 +93,13 @@ def _group_calendar_id(db: Session, group_id: int) -> Optional[int]:
return gc.calendar_id if gc else None
+def _group_calendar_color(db: Session, calendar_id: Optional[int]) -> Optional[str]:
+ if calendar_id is None:
+ return None
+ cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
+ return cal.color if cal else None
+
+
@router.post("/")
def create_group(
data: GroupCreate,
@@ -98,15 +112,20 @@ def create_group(
db.flush()
# Creator is owner; add the requested members (deduped, excluding creator).
- db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner", joined_at=_now_iso()))
+ # Each member gets a distinct colour from the palette by join order.
+ db.add(models.GroupMember(group_id=group.id, user_id=current_user.id, role="owner",
+ color=MEMBER_PALETTE[0], joined_at=_now_iso()))
seen = {current_user.id}
+ idx = 1
for uid in data.member_ids:
if uid in seen:
continue
if not db.query(models.User).filter(models.User.id == uid).first():
continue
- db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member", joined_at=_now_iso()))
+ db.add(models.GroupMember(group_id=group.id, user_id=uid, role="member",
+ color=MEMBER_PALETTE[idx % len(MEMBER_PALETTE)], joined_at=_now_iso()))
seen.add(uid)
+ idx += 1
# Auto-create the group calendar (a local calendar owned by the creator).
cal = models.LocalCalendar(
@@ -139,13 +158,15 @@ def list_groups(
if not group:
continue
member_count = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).count()
+ gcal_id = _group_calendar_id(db, group.id)
out.append({
"id": group.id,
"name": group.name,
"icon": group.icon,
"role": m.role,
"member_count": member_count,
- "group_calendar_id": _group_calendar_id(db, group.id),
+ "group_calendar_id": gcal_id,
+ "group_calendar_color": _group_calendar_color(db, gcal_id),
})
return out
@@ -153,20 +174,23 @@ def list_groups(
def _group_detail(db: Session, group: models.Group, current_user: models.User) -> dict:
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group.id).all()
member_dicts = []
- for m in members:
+ for i, m in enumerate(members):
u = db.query(models.User).filter(models.User.id == m.user_id).first()
member_dicts.append({
"id": m.user_id,
"display_name": (u.display_name or u.username) if u else None,
"role": m.role,
+ "color": m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)],
})
+ gcal_id = _group_calendar_id(db, group.id)
return {
"id": group.id,
"name": group.name,
"icon": group.icon,
"created_by": group.created_by,
"members": member_dicts,
- "group_calendar_id": _group_calendar_id(db, group.id),
+ "group_calendar_id": gcal_id,
+ "group_calendar_color": _group_calendar_color(db, gcal_id),
}
@@ -211,7 +235,34 @@ def add_member(
raise HTTPException(404, "User not found")
if _membership(db, group_id, data.user_id):
return {"ok": True} # already a member
- db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member", joined_at=_now_iso()))
+ db.add(models.GroupMember(group_id=group_id, user_id=data.user_id, role="member",
+ color=_next_member_color(db, group_id), joined_at=_now_iso()))
+ db.commit()
+ return {"ok": True}
+
+
+class MemberColorUpdate(BaseModel):
+ color: str
+
+
+@router.put("/{group_id}/members/{user_id}/color")
+def set_member_color(
+ group_id: int,
+ user_id: int,
+ data: MemberColorUpdate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ group = _get_group_or_404(db, group_id)
+ # Owner may recolour anyone; a member may recolour themselves.
+ if user_id != current_user.id:
+ _require_owner(db, group, current_user)
+ else:
+ _require_member(db, group, current_user)
+ m = _membership(db, group_id, user_id)
+ if not m:
+ raise HTTPException(404, "Member not found")
+ m.color = data.color
db.commit()
return {"ok": True}
@@ -300,6 +351,12 @@ def combined_events(
return visibility_cache[user_id]
group_cal_id = _group_calendar_id(db, group_id)
+ group_cal_color = _group_calendar_color(db, group_cal_id)
+ # Server-defined colours so every client renders members/group consistently.
+ member_color = {
+ m.user_id: (m.color or MEMBER_PALETTE[i % len(MEMBER_PALETTE)])
+ for i, m in enumerate(members)
+ }
all_events: list[dict] = []
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
@@ -337,6 +394,9 @@ def combined_events(
for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
b = _strip_busy(b)
+ # Colour to render with: the group calendar's colour for group
+ # events, otherwise the owning member's group colour.
+ b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
all_events.append(b)
# Each member shares exactly one calendar into their groups, chosen in their
diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py
index a44d0a3..2f2a89c 100644
--- a/backend/tests/test_collaboration.py
+++ b/backend/tests/test_collaboration.py
@@ -134,6 +134,37 @@ def test_group_members_can_write_group_calendar(client):
assert r.status_code == 200, r.text
+def test_group_member_colors_and_display_color(client):
+ admin = register_admin(client)
+ b_id, b_tok = create_user(client, admin, "bob")
+ group = client.post("/api/groups/", headers=auth(admin),
+ json={"name": "Team", "member_ids": [b_id]}).json()
+ gid = group["id"]
+ gcal = group["group_calendar_id"]
+
+ # Each member has a server-assigned colour; the group exposes its calendar colour.
+ detail = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
+ assert all(m.get("color") for m in detail["members"])
+ assert detail.get("group_calendar_color")
+
+ # Owner can recolour a member.
+ r = client.put(f"/api/groups/{gid}/members/{b_id}/color", headers=auth(admin),
+ json={"color": "#123456"})
+ assert r.status_code == 200, r.text
+ detail2 = client.get(f"/api/groups/{gid}", headers=auth(admin)).json()
+ assert any(m["id"] == b_id and m["color"] == "#123456" for m in detail2["members"])
+
+ # Bob shares a calendar with an event; combined events carry display_color.
+ b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
+ client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
+ _make_event(client, b_tok, b_cal, "Bobs Termin")
+ _make_event(client, admin, gcal, "Gruppentermin")
+ evs = client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]
+ by_title = {e["title"]: e for e in evs}
+ assert by_title["Bobs Termin"]["display_color"] == "#123456" # Bob's member colour
+ assert by_title["Gruppentermin"]["display_color"] == detail2["group_calendar_color"]
+
+
def test_group_calendar_listed_for_member(client):
admin = register_admin(client)
b_id, b_tok = create_user(client, admin, "bob")
diff --git a/frontend/index.html b/frontend/index.html
index 5741d45..a8c78b9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -386,6 +386,10 @@
Mitglieder
+