Add a two-way application calendar (CalDAV / SOGo)

New lib/caldav.js speaks CalDAV over Basic auth (shared mail account):
reads events in a window, and creates/updates/deletes iCalendar VEVENTs
with a reminder alarm. DST-correct Europe/Berlin <-> UTC handling.

Appointments (Vorstellungsgespräch / general) are managed per application
in a new "Termine" section and mirrored to the SOGo calendar; recording a
"Vorstellungsgespräch" status suggests a prefilled calendar entry
(confirm + click). A dashboard widget lists upcoming appointments. A
background poller reconciles remote edits/deletions via the collection
ctag. Config: CALDAV_URL (+ CALDAV_ALARM_MIN, CALDAV_POLL_MS); disabled
gracefully when unset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:06:38 +02:00
co-authored by Claude Opus 4.8
parent 89c8c6db97
commit 95b5b8510d
5 changed files with 660 additions and 0 deletions
+34
View File
@@ -96,6 +96,40 @@
</form>
</div>
<!-- Upcoming appointments (from the application calendar) -->
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-center gap-2 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
</div>
<ul class="space-y-2">
<% kommendeTermine.forEach(function(t){
var when = t.ganztags
? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
: new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
%>
<li class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-3 py-2">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= t.typ === 'vorstellungsgespraech' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200' %>"><%= t.typ === 'vorstellungsgespraech' ? 'Gespräch' : 'Termin' %></span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-100 break-words"><%= t.titel %></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
<%= when %> Uhr<% if (t.bewerbung_firma) { %> · <span class="text-gray-500 dark:text-gray-400"><%= t.bewerbung_firma %></span><% } %>
</p>
</div>
<% if (t.bewerbung_id) { %>
<a href="/bewerbung/<%= t.bewerbung_id %>#termine" class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">Öffnen
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
<% } %>
</li>
<% }); %>
</ul>
</div>
<% } %>
<!-- Statistics Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>