feat: Kalender-Ansicht aktualisiert sich live via SSE

Backend:
Neuer Event-Typ 'calendar' im Broadcaster. Wird bei Event-CRUD,
Serien-Ausnahmen, Freigaben hinzufuegen/entfernen und beim
Loeschen ganzer Kalender emittiert. Empfaenger: Eigentuemer +
alle User mit CalendarShare auf dem jeweiligen Kalender.

Frontend:
CalendarView oeffnet beim Mount eine EventSource zu
/api/sync/events und reloaded Kalenderliste + Events bei jedem
'calendar'-Event (300ms debounced). Damit sehen beteiligte
Nutzer Aenderungen in praktisch Echtzeit - kein F5 mehr noetig.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-12 13:10:54 +02:00
parent ce4faedd88
commit 2170f4a7b1
3 changed files with 64 additions and 1 deletions

View File

@ -9,6 +9,11 @@ from app.api.auth import token_required
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
from app.models.calendar import Calendar, CalendarEvent, CalendarShare 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 _calendar_recipients(cal: Calendar):
return [s.shared_with_id for s in CalendarShare.query.filter_by(calendar_id=cal.id).all()]
def _redact_if_private(event_dict: dict, is_owner: bool) -> dict: def _redact_if_private(event_dict: dict, is_owner: bool) -> dict:
@ -149,8 +154,12 @@ def delete_calendar(cal_id):
if not cal or cal.owner_id != user.id: if not cal or cal.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404 return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
recipients = _calendar_recipients(cal)
owner_id = cal.owner_id
cal_id = cal.id
db.session.delete(cal) db.session.delete(cal)
db.session.commit() db.session.commit()
notify_calendar_change(owner_id, cal_id, 'deleted', shared_with=recipients)
return jsonify({'message': 'Kalender geloescht'}), 200 return jsonify({'message': 'Kalender geloescht'}), 200
@ -235,6 +244,8 @@ def create_event(cal_id):
) )
db.session.add(event) db.session.add(event)
db.session.commit() db.session.commit()
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_calendar_recipients(cal))
return jsonify(event.to_dict()), 201 return jsonify(event.to_dict()), 201
@ -281,6 +292,8 @@ def update_event(event_id):
) )
event.updated_at = datetime.now(timezone.utc) event.updated_at = datetime.now(timezone.utc)
db.session.commit() db.session.commit()
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_calendar_recipients(cal))
return jsonify(event.to_dict()), 200 return jsonify(event.to_dict()), 200
@ -369,8 +382,12 @@ def delete_event(event_id):
if err: if err:
return err return err
cal = db.session.get(Calendar, event.calendar_id)
db.session.delete(event) db.session.delete(event)
db.session.commit() db.session.commit()
if cal:
notify_calendar_change(cal.owner_id, cal.id, 'event',
shared_with=_calendar_recipients(cal))
return jsonify({'message': 'Event geloescht'}), 200 return jsonify({'message': 'Event geloescht'}), 200
@ -418,6 +435,9 @@ def share_calendar(cal_id):
except Exception: except Exception:
pass pass
notify_calendar_change(cal.owner_id, cal.id, 'share',
shared_with=[target.id, *_calendar_recipients(cal)])
return jsonify({'message': f'Kalender mit {username} geteilt'}), 200 return jsonify({'message': f'Kalender mit {username} geteilt'}), 200
@ -450,8 +470,11 @@ def remove_calendar_share(cal_id, share_id):
if not share or share.calendar_id != cal_id: if not share or share.calendar_id != cal_id:
return jsonify({'error': 'Freigabe nicht gefunden'}), 404 return jsonify({'error': 'Freigabe nicht gefunden'}), 404
target_id = share.shared_with_id
db.session.delete(share) db.session.delete(share)
db.session.commit() db.session.commit()
notify_calendar_change(cal.owner_id, cal.id, 'share',
shared_with=[target_id, *_calendar_recipients(cal)])
return jsonify({'message': 'Freigabe entfernt'}), 200 return jsonify({'message': 'Freigabe entfernt'}), 200

View File

@ -79,3 +79,16 @@ def notify_file_change(owner_id: int, file_id: int | None, change: str,
'change': change, # 'created' | 'updated' | 'deleted' | 'locked' | 'unlocked' 'change': change, # 'created' | 'updated' | 'deleted' | 'locked' | 'unlocked'
'file_id': file_id, 'file_id': file_id,
}) })
def notify_calendar_change(owner_id: int, calendar_id: int, change: str,
shared_with: Iterable[int] = ()) -> None:
"""Emit a calendar-level change event (event added/changed/deleted or
share membership changed). Sent to owner + all users the calendar is
shared with."""
recipients = [owner_id, *shared_with]
broadcaster.publish(recipients, {
'type': 'calendar',
'change': change, # 'event'|'share'|'deleted'
'calendar_id': calendar_id,
})

View File

@ -260,7 +260,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, reactive, watch } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client' import apiClient from '../api/client'
@ -858,9 +858,36 @@ watch(() => eventForm.value.all_day, (allDay) => {
eventForm.value.dtend = fix(eventForm.value.dtend) eventForm.value.dtend = fix(eventForm.value.dtend)
}) })
// Live updates via SSE: refresh calendar list + events when anything
// relevant changes (own or shared).
let eventSource = null
let reloadTimer = null
function scheduleReload() {
if (reloadTimer) return
reloadTimer = setTimeout(async () => {
reloadTimer = null
await loadCalendars()
refreshEvents()
}, 300)
}
onMounted(async () => { onMounted(async () => {
await loadCalendars() await loadCalendars()
refreshEvents() refreshEvents()
if (auth.accessToken) {
try {
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
eventSource.addEventListener('calendar', scheduleReload)
eventSource.addEventListener('message', scheduleReload)
eventSource.onerror = () => { /* auto-reconnects */ }
} catch { /* ignore */ }
}
})
onUnmounted(() => {
if (reloadTimer) { clearTimeout(reloadTimer); reloadTimer = null }
if (eventSource) { eventSource.close(); eventSource = null }
}) })
</script> </script>