Einige kleine verbesserungen #1

Open
Scarriffle wants to merge 115 commits from beta into master
10 changed files with 220 additions and 43 deletions
Showing only changes of commit 1638c9f631 - Show all commits

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import caldav import caldav
from icalendar import Calendar, Event, vRecur from icalendar import Calendar, Event, vDatetime, vRecur
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -213,9 +213,14 @@ def create_event(
def update_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) client = _client(url, username, password)
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 = caldav.Event(client=client, url=event_url)
resource.load() resource.load()
raw = resource.data raw = resource.data
@@ -258,14 +263,30 @@ def update_event(
elif "RRULE" in component: elif "RRULE" in component:
del component["RRULE"] 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) new_cal.add_component(component)
resource.data = new_cal.to_ical().decode("utf-8") resource.data = new_cal.to_ical().decode("utf-8")
resource.save() 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) client = _client(url, username, password)
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 = caldav.Event(client=client, url=event_url)
resource.delete() resource.delete()

View File

@@ -90,6 +90,13 @@ def _migrate():
except Exception: except Exception:
pass 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() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)

View File

@@ -113,6 +113,7 @@ class LocalEvent(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True) rrule = Column(Text, nullable=True)
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")

View File

@@ -57,6 +57,7 @@ class EventUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None rrule: Optional[str] = None
exdate: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict: 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): def _expand_recurring_local(ev, local_cal, range_start, range_end):
"""Expand a recurring LocalEvent into individual occurrences within the date range.""" """Expand a recurring LocalEvent into individual occurrences within the date range."""
results = [] results = []
# Parse excluded dates
excluded = set()
if ev.exdate:
for d in ev.exdate.split(","):
d = d.strip()
if d:
excluded.add(d)
try: try:
ev_start_str = ev.start.replace("Z", "+00:00") ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.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) occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences: for occ in occurrences:
occ_start = occ.date() occ_start = occ.date()
occ_key = occ_start.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ_start + duration occ_end = occ_start + duration
results.append({ results.append({
"id": ev.uid, "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) r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences: for occ in occurrences:
occ_key = occ.strftime("%Y%m%d")
if occ_key in excluded:
continue
occ_end = occ + duration occ_end = occ + duration
results.append({ results.append({
"id": ev.uid, "id": ev.uid,
@@ -548,6 +562,7 @@ def update_event(
.all() .all()
) )
account = None account = None
cal_url = None
if calendar_id is not None: if calendar_id is not None:
cal = ( cal = (
db.query(models.Calendar) db.query(models.Calendar)
@@ -557,8 +572,15 @@ def update_event(
) )
if cal: if cal:
account = next((a for a in accounts if a.id == cal.account_id), None) account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account: if not account:
account = _find_account_for_event_url(event_url, accounts) 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: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:
@@ -568,6 +590,7 @@ def update_event(
account.password, account.password,
event_url, event_url,
data.model_dump(exclude_none=True) if data else {}, data.model_dump(exclude_none=True) if data else {},
calendar_url=cal_url,
) )
return {"ok": True} return {"ok": True}
except Exception as exc: except Exception as exc:
@@ -588,6 +611,7 @@ def delete_event(
.all() .all()
) )
account = None account = None
cal_url = None
if calendar_id is not None: if calendar_id is not None:
cal = ( cal = (
db.query(models.Calendar) db.query(models.Calendar)
@@ -597,13 +621,20 @@ def delete_event(
) )
if cal: if cal:
account = next((a for a in accounts if a.id == cal.account_id), None) account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account: if not account:
account = _find_account_for_event_url(event_url, accounts) 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: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:
caldav_client.delete_event( 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} return {"ok": True}
except Exception as exc: except Exception as exc:

View File

@@ -44,6 +44,7 @@ class EventUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None rrule: Optional[str] = None
exdate: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict: 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 "", "description": ev.description or "",
"color": ev.color, "color": ev.color,
"rrule": ev.rrule, "rrule": ev.rrule,
"exdate": ev.exdate,
"calendar_id": f"local-{cal.id}", "calendar_id": f"local-{cal.id}",
"calendar_name": cal.name, "calendar_name": cal.name,
"calendarColor": cal.color, "calendarColor": cal.color,
@@ -225,6 +227,12 @@ def update_event(
ev.color = data.color ev.color = data.color
if data.rrule is not None: if data.rrule is not None:
ev.rrule = data.rrule if data.rrule else 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() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -487,7 +487,7 @@ a { color: var(--primary); text-decoration: none; }
.month-col:hover { background: var(--bg-hover); } .month-col:hover { background: var(--bg-hover); }
.month-col.today { background: rgba(66,133,244,.08); } .month-col.today { background: rgba(66,133,244,.08); }
.month-col.month-selected { background: var(--primary-dim); } .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); } .month-col.other-month .cell-day { color: var(--text-3); }
.cell-day { .cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2); font-size: 12px; font-weight: 500; color: var(--text-2);
@@ -802,8 +802,8 @@ a { color: var(--primary); text-decoration: none; }
/* ── Day Context Menu ──────────────────────────────────── */ /* ── Day Context Menu ──────────────────────────────────── */
.cal-context-menu { .cal-context-menu {
position: fixed; z-index: 1000; position: fixed; z-index: 1000;
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3); border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.5);
min-width: 180px; padding: 4px 0; min-width: 180px; padding: 4px 0;
} }
.ctx-item { .ctx-item {

View File

@@ -317,6 +317,32 @@
</div> </div>
</div> </div>
<!-- Delete Confirm Modal -->
<div id="modal-delete-confirm" class="modal-overlay hidden">
<div class="modal-card" style="max-width:400px">
<div class="modal-header">
<h3 id="delete-confirm-title">Termin löschen</h3>
<button class="icon-btn modal-close" data-modal="modal-delete-confirm">&times;</button>
</div>
<div class="modal-body">
<p id="delete-confirm-text"></p>
<div id="delete-series-options" class="hidden" style="margin-top:12px">
<label class="toggle-label" style="display:block;margin-bottom:8px">
<input type="radio" name="delete-scope" value="single" checked /> Nur diesen Termin
</label>
<label class="toggle-label" style="display:block">
<input type="radio" name="delete-scope" value="all" /> Alle Serienelemente
</label>
</div>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button class="btn btn-ghost" data-modal="modal-delete-confirm">Abbrechen</button>
<button class="btn btn-danger" id="delete-confirm-ok">Löschen</button>
</div>
</div>
</div>
<!-- Event Detail Popup --> <!-- Event Detail Popup -->
<div id="popup-event" class="event-popup hidden"> <div id="popup-event" class="event-popup hidden">
<div class="popup-header"> <div class="popup-header">

View File

@@ -26,6 +26,7 @@ let weekStartDay = 'monday';
let state = { let state = {
currentDate: new Date(), currentDate: new Date(),
selectedDate: null, // separate from currentDate; used for month-view selection
currentView: 'month', currentView: 'month',
events: [], events: [],
accounts: [], accounts: [],
@@ -253,22 +254,23 @@ function renderView() {
(date, action, mouseEvent) => { (date, action, mouseEvent) => {
if (action === 'navigate') { if (action === 'navigate') {
state.currentDate = date; state.currentDate = date;
state.selectedDate = date;
state.currentView = 'day'; state.currentView = 'day';
updateViewButtons(); updateViewButtons();
fetchAndRender(); fetchAndRender();
} else if (action === 'context') { } else if (action === 'context') {
state.currentDate = date; state.selectedDate = date;
showDayContextMenu(date, mouseEvent); showDayContextMenu(date, mouseEvent);
} else {
// 'select' — highlight day without navigating
state.currentDate = date;
renderMiniCal();
renderView(); renderView();
updateTitle(); } else {
// 'select' — only update selectedDate, don't shift the view
state.selectedDate = date;
renderView();
} }
}, },
showEventPopup, showEventPopup,
weekStartDay weekStartDay,
state.selectedDate
); );
} else if (state.currentView === 'week') { } else if (state.currentView === 'week') {
renderWeek(container, state.currentDate, evs, renderWeek(container, state.currentDate, evs,
@@ -780,7 +782,7 @@ function bindTopbar() {
}); });
document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-settings').onclick = openSettingsModal;
document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate); document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.selectedDate || state.currentDate);
// Mouse wheel / trackpad scroll navigation only for month & quarter // Mouse wheel / trackpad scroll navigation only for month & quarter
let _wheelLast = 0; let _wheelLast = 0;
@@ -854,6 +856,82 @@ function bindSidebar() {
} }
// ── Day Context Menu (month view) ──────────────────────── // ── Day Context Menu (month view) ────────────────────────
// ── Delete logic ──────────────────────────────────────────
async function deleteEventByScope(ev, scope) {
if (scope === 'all' || !ev.rrule) {
// Delete the entire event (or non-recurring)
if (ev.source === 'google') {
const accId = ev.calendar_id.replace('google-', '');
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'local') {
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'ical') {
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)}&calendar_id=${ev.calendar_id}`);
}
} else {
// Delete single occurrence: add EXDATE to exclude this date
const exdate = ev.start.slice(0, 10).replace(/-/g, '');
if (ev.source === 'local') {
// For local events: update rrule with EXDATE via a special field
const currentRrule = ev.rrule || '';
await api.put(`/local/events/${encodeURIComponent(ev.id)}`, {
exdate: exdate,
});
} else {
// For CalDAV: pass exdate to update
await api.put(
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,
{ exdate: exdate }
);
}
}
}
// ── Delete Confirm Dialog ─────────────────────────────────
function showDeleteConfirm(ev) {
return new Promise(resolve => {
const modal = document.getElementById('modal-delete-confirm');
const isRecurring = !!(ev.rrule);
document.getElementById('delete-confirm-title').textContent = t('confirm_delete_title');
document.getElementById('delete-confirm-text').textContent = t('confirm_delete_event', { title: ev.title });
document.getElementById('delete-series-options').classList.toggle('hidden', !isRecurring);
// Reset radio
const radios = modal.querySelectorAll('input[name="delete-scope"]');
radios[0].checked = true;
// Labels
const labels = modal.querySelectorAll('#delete-series-options label');
if (labels[0]) labels[0].lastChild.textContent = ' ' + t('delete_single');
if (labels[1]) labels[1].lastChild.textContent = ' ' + t('delete_all_series');
openModal('modal-delete-confirm');
const okBtn = document.getElementById('delete-confirm-ok');
const cleanup = () => {
okBtn.onclick = null;
modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => b.onclick = null);
};
okBtn.onclick = () => {
const scope = isRecurring
? modal.querySelector('input[name="delete-scope"]:checked')?.value || 'single'
: 'single';
cleanup();
closeModal('modal-delete-confirm');
resolve(scope);
};
modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => {
b.onclick = () => { cleanup(); closeModal('modal-delete-confirm'); resolve(null); };
});
});
}
function showDayContextMenu(date, mouseEvent) { function showDayContextMenu(date, mouseEvent) {
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove()); document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
@@ -953,20 +1031,11 @@ function showEventPopup(ev, anchor) {
}; };
document.getElementById('popup-delete').onclick = async () => { document.getElementById('popup-delete').onclick = async () => {
if (!confirm(t("confirm_delete_event", {title: ev.title}))) return; const scope = await showDeleteConfirm(ev);
if (!scope) return;
popup.classList.add('hidden'); popup.classList.add('hidden');
try { try {
if (ev.source === 'google') { await deleteEventByScope(ev, scope);
const accId = ev.calendar_id.replace('google-', '');
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'local') {
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'ical') {
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)}&calendar_id=${ev.calendar_id}`);
}
showToast(t('event_deleted')); showToast(t('event_deleted'));
fetchAndRender(true); fetchAndRender(true);
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
@@ -1015,6 +1084,16 @@ function populateCalendarSelect(selectedId) {
sel.appendChild(opt); sel.appendChild(opt);
}); });
}); });
// Home Assistant calendars
state.haAccounts.forEach(acc => {
acc.calendars.filter(c => c.enabled).forEach(cal => {
const opt = document.createElement('option');
opt.value = `homeassistant-${cal.id}`;
opt.textContent = `${acc.name} / ${cal.name}`;
if (`homeassistant-${cal.id}` === selectedId) opt.selected = true;
sel.appendChild(opt);
});
});
} }
// ── Date field helpers ──────────────────────────────────── // ── Date field helpers ────────────────────────────────────
@@ -1315,6 +1394,7 @@ function bindEventModal() {
const calVal = document.getElementById('ev-calendar').value; const calVal = document.getElementById('ev-calendar').value;
const isLocal = calVal.startsWith('local-'); const isLocal = calVal.startsWith('local-');
const isGoogle = calVal.startsWith('google-'); const isGoogle = calVal.startsWith('google-');
const isHA = calVal.startsWith('homeassistant-');
const loc = document.getElementById('ev-location').value.trim(); const loc = document.getElementById('ev-location').value.trim();
const desc = document.getElementById('ev-description').value.trim(); const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor; const color = state.selectedEventColor;
@@ -1373,6 +1453,9 @@ function bindEventModal() {
rrule: rrule || null, rrule: rrule || null,
}); });
showToast(t('event_created')); showToast(t('event_created'));
} else if (isHA) {
showToast(t('ha_create_not_supported'), true);
return;
} else { } else {
const calId = parseInt(calVal); const calId = parseInt(calVal);
await api.post('/caldav/events', { await api.post('/caldav/events', {
@@ -1392,19 +1475,10 @@ function bindEventModal() {
document.getElementById('ev-delete').onclick = async () => { document.getElementById('ev-delete').onclick = async () => {
const ev = state.editingEvent; const ev = state.editingEvent;
if (!ev) return; if (!ev) return;
if (!confirm(t("confirm_delete_event", {title: ev.title}))) return; const scope = await showDeleteConfirm(ev);
if (!scope) return;
try { try {
if (ev.source === 'google') { await deleteEventByScope(ev, scope);
const accId = ev.calendar_id.replace('google-', '');
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'local') {
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
} else if (ev.source === 'ical') {
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)}&calendar_id=${ev.calendar_id}`);
}
showToast(t('event_deleted')); showToast(t('event_deleted'));
closeModal('modal-event'); closeModal('modal-event');
fetchAndRender(true); fetchAndRender(true);

View File

@@ -145,6 +145,7 @@ const translations = {
error_end_before_start: 'Ende kann nicht vor dem Start liegen', error_end_before_start: 'Ende kann nicht vor dem Start liegen',
ctx_create_event: 'Neuen Termin erstellen', ctx_create_event: 'Neuen Termin erstellen',
event_readonly: 'Abonnierte Termine können nicht bearbeitet werden', event_readonly: 'Abonnierte Termine können nicht bearbeitet werden',
ha_create_not_supported: 'Termine können in Home Assistant Kalendern nicht direkt erstellt werden',
rec_label: 'Wiederholung', rec_label: 'Wiederholung',
rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich', rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…', rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
@@ -154,6 +155,9 @@ const translations = {
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?', confirm_delete_event: '"{title}" wirklich löschen?',
confirm_delete_title: 'Termin löschen',
delete_single: 'Nur diesen Termin',
delete_all_series: 'Alle Termine der Serie',
event_deleted: 'Termin gelöscht', event_deleted: 'Termin gelöscht',
error_fill_all: 'Bitte alle Felder ausfüllen', error_fill_all: 'Bitte alle Felder ausfüllen',
account_added: 'Konto "{name}" hinzugefügt', account_added: 'Konto "{name}" hinzugefügt',
@@ -349,6 +353,7 @@ const translations = {
error_end_before_start: 'End cannot be before start', error_end_before_start: 'End cannot be before start',
ctx_create_event: 'Create new event', ctx_create_event: 'Create new event',
event_readonly: 'Subscribed events cannot be edited', event_readonly: 'Subscribed events cannot be edited',
ha_create_not_supported: 'Events cannot be created directly in Home Assistant calendars',
rec_label: 'Recurrence', rec_label: 'Recurrence',
rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly', rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…', rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
@@ -358,6 +363,9 @@ const translations = {
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
event_updated: 'Event updated', event_created: 'Event created', event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?', confirm_delete_event: 'Really delete "{title}"?',
confirm_delete_title: 'Delete event',
delete_single: 'Only this occurrence',
delete_all_series: 'All events in series',
event_deleted: 'Event deleted', event_deleted: 'Event deleted',
error_fill_all: 'Please fill in all fields', error_fill_all: 'Please fill in all fields',
account_added: 'Account "{name}" added', account_added: 'Account "{name}" added',

View File

@@ -5,7 +5,7 @@ const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const DAY_H = 30; // day-number row height const DAY_H = 30; // day-number row height
const NUM_ROWS = 5; // rolling view: always 5 weeks const NUM_ROWS = 5; // rolling view: always 5 weeks
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday', selectedDate = null) {
// Dynamic lane limit: how many events fit in the actual row height // Dynamic lane limit: how many events fit in the actual row height
const containerH = container.clientHeight || 600; const containerH = container.clientHeight || 600;
const headerH = 34; // month-header DOW row const headerH = 34; // month-header DOW row
@@ -126,7 +126,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
const isOther = cell.getMonth() !== primaryMonth; const isOther = cell.getMonth() !== primaryMonth;
const todayCls = isToday(cell) ? 'today' : ''; const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : ''; const otherCls = isOther ? 'other-month' : '';
const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : ''; const selDate = selectedDate || currentDate;
const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : '';
const numCls = isToday(cell) ? 'today' : ''; const numCls = isToday(cell) ? 'today' : '';
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}"> colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}">
<div class="cell-day ${numCls}">${cell.getDate()}</div> <div class="cell-day ${numCls}">${cell.getDate()}</div>