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