feat(calendar): Live-Refresh ueber CalDAV, Tagklick-Navigation, Listen-Ansicht

- caldav.py sendet SSE-Notifications bei Event-PUT/DELETE und Kalender-Loeschung,
  damit das Web-UI auch auf Aenderungen aus DAVx5 sofort reagiert.
- FullCalendar navLinks: Klick auf Tagesnummer im Monatsraster wechselt in
  die Tagesansicht.
- Neue Listen-Ansicht mit Volltext-Suche, Datumsbereich, Kalender-Filter,
  Sortierung nach Datum/Titel und Loeschen-Button pro Zeile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-13 09:28:44 +02:00
parent 10a1dec448
commit e02c4f97c1
2 changed files with 178 additions and 2 deletions

View File

@ -26,8 +26,14 @@ from functools import wraps
from flask import Response, request from flask import Response, request
from app.extensions import db from app.extensions import db
from app.models.calendar import Calendar, CalendarEvent from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.user import User from app.models.user import User
from app.services.events import notify_calendar_change
def _cal_recipients(cal: 'Calendar'):
return [s.shared_with_id for s in
CalendarShare.query.filter_by(calendar_id=cal.id).all()]
from . import dav_bp from . import dav_bp
@ -513,6 +519,8 @@ def put_event(username, cal_part, filename):
existing.ical_data = _extract_vevent_block(raw) existing.ical_data = _extract_vevent_block(raw)
existing.updated_at = datetime.now(timezone.utc) existing.updated_at = datetime.now(timezone.utc)
db.session.commit() db.session.commit()
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_cal_recipients(cal))
status = 201 if request.method == 'PUT' and not if_match else 204 status = 201 if request.method == 'PUT' and not if_match else 204
return Response('', status, {'ETag': _etag_for_event(existing)}) return Response('', status, {'ETag': _etag_for_event(existing)})
@ -541,6 +549,8 @@ def delete_event(username, cal_part, filename):
return Response('', 404) return Response('', 404)
db.session.delete(ev) db.session.delete(ev)
db.session.commit() db.session.commit()
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_cal_recipients(cal))
return Response('', 204) return Response('', 204)
@ -558,8 +568,12 @@ def delete_calendar(username, cal_part):
cal = _calendar_for(user, cal_id) if cal_id else None cal = _calendar_for(user, cal_id) if cal_id else None
if not cal: if not cal:
return Response('', 404) return Response('', 404)
recipients = _cal_recipients(cal)
owner_id = cal.owner_id
cid = cal.id
db.session.delete(cal) db.session.delete(cal)
db.session.commit() db.session.commit()
notify_calendar_change(owner_id, cid, 'deleted', shared_with=recipients)
return Response('', 204) return Response('', 204)

View File

@ -3,6 +3,7 @@
<div class="view-header"> <div class="view-header">
<h2>Kalender</h2> <h2>Kalender</h2>
<div class="header-actions"> <div class="header-actions">
<SelectButton v-model="viewMode" :options="viewModeOptions" optionLabel="label" optionValue="value" size="small" />
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" /> <Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" /> <Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
</div> </div>
@ -21,7 +22,7 @@
</aside> </aside>
<div class="calendar-main"> <div class="calendar-main">
<FullCalendar ref="fcRef" :options="calendarOptions"> <FullCalendar v-show="viewMode === 'calendar'" ref="fcRef" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<div class="fc-event-content-inner" :title="eventTooltip(arg.event)"> <div class="fc-event-content-inner" :title="eventTooltip(arg.event)">
<span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span> <span v-if="arg.event.extendedProps.all_day" class="fc-icon">📅</span>
@ -31,6 +32,59 @@
</div> </div>
</template> </template>
</FullCalendar> </FullCalendar>
<div v-if="viewMode === 'list'" class="list-view">
<div class="list-filters">
<span class="p-input-icon-left" style="flex: 1;">
<i class="pi pi-search"></i>
<InputText v-model="listSearch" placeholder="Suchen (Titel, Ort, Beschreibung)..." fluid />
</span>
<InputText v-model="listFrom" type="date" title="Von" />
<InputText v-model="listTo" type="date" title="Bis" />
<Select v-model="listCalFilter" :options="listCalOptions"
optionLabel="label" optionValue="value" placeholder="Alle Kalender"
showClear style="min-width: 180px" />
</div>
<div class="list-meta">{{ filteredListEvents.length }} Termin(e)</div>
<table class="list-table">
<thead>
<tr>
<th @click="toggleListSort('dtstart')" class="sortable">
Datum <i v-if="listSort === 'dtstart'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
</th>
<th @click="toggleListSort('summary')" class="sortable">
Titel <i v-if="listSort === 'summary'" :class="listSortDir === 'asc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
</th>
<th>Kalender</th>
<th>Ort</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="ev in filteredListEvents" :key="ev.id" class="list-row" @click="openEditEvent(ev)">
<td class="col-date">
<div>{{ formatListDate(ev) }}</div>
<div v-if="!ev.all_day" class="meta-time">{{ formatListTime(ev) }}</div>
</td>
<td class="col-title">
<span class="cal-dot" :style="{ background: ev._cal?.color }"></span>
{{ ev.summary || '(ohne Titel)' }}
<i v-if="ev.recurrence_rule" class="pi pi-replay" title="Wiederholung" style="margin-left: 0.25rem; font-size: 0.75rem;"></i>
</td>
<td>{{ ev._cal?.name }}</td>
<td>{{ ev.location || '' }}</td>
<td class="col-actions" @click.stop>
<Button icon="pi pi-trash" text severity="danger" size="small"
:disabled="ev._cal?.permission === 'read'"
@click="confirmDeleteListEvent(ev)" title="Loeschen" />
</td>
</tr>
<tr v-if="!filteredListEvents.length">
<td colspan="5" class="empty-row">Keine Termine gefunden.</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
@ -282,6 +336,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Select from 'primevue/select' import Select from 'primevue/select'
import SelectButton from 'primevue/selectbutton'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
@ -301,6 +356,88 @@ const showNewCalendar = ref(false)
const newCalName = ref('') const newCalName = ref('')
const newCalColor = ref('#3788d8') const newCalColor = ref('#3788d8')
const viewMode = ref('calendar')
const viewModeOptions = [
{ label: 'Kalender', value: 'calendar' },
{ label: 'Liste', value: 'list' },
]
const listEvents = ref([])
const listSearch = ref('')
const listFrom = ref('')
const listTo = ref('')
const listCalFilter = ref(null)
const listSort = ref('dtstart')
const listSortDir = ref('asc')
const listCalOptions = computed(() => calendars.value.map(c => ({ label: c.name, value: c.id })))
const filteredListEvents = computed(() => {
const q = listSearch.value.trim().toLowerCase()
const fromDt = listFrom.value ? new Date(listFrom.value + 'T00:00:00') : null
const toDt = listTo.value ? new Date(listTo.value + 'T23:59:59') : null
let arr = listEvents.value.filter(e => {
if (listCalFilter.value && e.calendar_id !== listCalFilter.value) return false
if (fromDt && new Date(e.dtstart) < fromDt) return false
if (toDt && new Date(e.dtstart) > toDt) return false
if (q) {
const hay = `${e.summary || ''} ${e.location || ''} ${e.description || ''}`.toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
const dir = listSortDir.value === 'asc' ? 1 : -1
const key = listSort.value
arr = arr.slice().sort((a, b) => {
const av = key === 'dtstart' ? new Date(a.dtstart).getTime() : (a.summary || '').toLowerCase()
const bv = key === 'dtstart' ? new Date(b.dtstart).getTime() : (b.summary || '').toLowerCase()
return av < bv ? -dir : av > bv ? dir : 0
})
return arr
})
function toggleListSort(col) {
if (listSort.value === col) listSortDir.value = listSortDir.value === 'asc' ? 'desc' : 'asc'
else { listSort.value = col; listSortDir.value = 'asc' }
}
function formatListDate(ev) {
const d = new Date(ev.dtstart)
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
}
function formatListTime(ev) {
const fmt = d => new Date(d).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return ev.dtend ? `${fmt(ev.dtstart)}${fmt(ev.dtend)}` : fmt(ev.dtstart)
}
async function loadListEvents() {
// Lade alle Termine ueber die letzten 90 Tage und naechsten 365 Tage hinweg.
const start = new Date(); start.setDate(start.getDate() - 90)
const end = new Date(); end.setDate(end.getDate() + 365)
const all = []
for (const cal of calendars.value) {
if (visibleCalendars[cal.id] === false) continue
try {
const res = await apiClient.get(`/calendars/${cal.id}/events`, {
params: { start: start.toISOString(), end: end.toISOString() },
})
for (const e of res.data) all.push({ ...e, _cal: cal })
} catch { /* ignore */ }
}
listEvents.value = all
}
async function confirmDeleteListEvent(ev) {
if (!confirm(`Termin "${ev.summary || '(ohne Titel)'}" wirklich loeschen?`)) return
try {
await apiClient.delete(`/events/${ev.id}`)
toast.add({ severity: 'success', summary: 'Geloescht', life: 2000 })
await loadListEvents()
refreshEvents()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
const showEventDialog = ref(false) const showEventDialog = ref(false)
const editingEvent = ref(null) const editingEvent = ref(null)
const eventForm = ref({ const eventForm = ref({
@ -377,6 +514,8 @@ const calendarOptions = computed(() => ({
center: 'title', center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay', right: 'dayGridMonth,timeGridWeek,timeGridDay',
}, },
navLinks: true,
navLinkDayClick: (date) => fcRef.value?.getApi().changeView('timeGridDay', date),
locale: deLocale, locale: deLocale,
firstDay: 1, firstDay: 1,
nowIndicator: true, nowIndicator: true,
@ -600,6 +739,7 @@ async function onEventDrop(info) {
function refreshEvents() { function refreshEvents() {
fcRef.value?.getApi().refetchEvents() fcRef.value?.getApi().refetchEvents()
if (viewMode.value === 'list') loadListEvents()
} }
async function loadCalendars() { async function loadCalendars() {
@ -896,9 +1036,14 @@ function scheduleReload() {
reloadTimer = null reloadTimer = null
await loadCalendars() await loadCalendars()
refreshEvents() refreshEvents()
if (viewMode.value === 'list') await loadListEvents()
}, 300) }, 300)
} }
watch(viewMode, async (mode) => {
if (mode === 'list') await loadListEvents()
})
onMounted(async () => { onMounted(async () => {
await loadCalendars() await loadCalendars()
refreshEvents() refreshEvents()
@ -968,4 +1113,21 @@ onUnmounted(() => {
.fc-event-content-inner .fc-icon { flex-shrink: 0; font-size: 0.85em; } .fc-event-content-inner .fc-icon { flex-shrink: 0; font-size: 0.85em; }
.fc-event-content-inner .fc-time { flex-shrink: 0; font-weight: 600; font-size: 0.8em; opacity: 0.9; } .fc-event-content-inner .fc-time { flex-shrink: 0; font-weight: 600; font-size: 0.8em; opacity: 0.9; }
.fc-event-content-inner .fc-title { overflow: hidden; text-overflow: ellipsis; } .fc-event-content-inner .fc-title { overflow: hidden; text-overflow: ellipsis; }
.list-view { display: flex; flex-direction: column; gap: 0.75rem; }
.list-filters { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.list-meta { font-size: 0.8rem; color: var(--p-text-muted-color); }
.list-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.list-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; user-select: none; }
.list-table th.sortable { cursor: pointer; }
.list-table th.sortable:hover { background: var(--p-surface-50); }
.list-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
.list-row { cursor: pointer; }
.list-row:hover { background: var(--p-surface-50); }
.col-date { white-space: nowrap; min-width: 140px; }
.col-title { font-weight: 500; }
.col-actions { width: 60px; text-align: right; }
.cal-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; }
.meta-time { font-size: 0.75rem; color: var(--p-text-muted-color); }
.empty-row { text-align: center; padding: 2rem !important; color: var(--p-text-muted-color); }
</style> </style>