feat(calendar): add reminder functionality for events with notifications
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-03-04 10:18:43 +01:00
parent ce2cf499dc
commit 8823f8789e
9 changed files with 192 additions and 8 deletions

View File

@@ -77,6 +77,15 @@ export function NotificationProvider({ children }) {
seenIds.current.add(n.id);
const icon = notificationIcon(n.type);
toast(`${icon} ${n.title}`, { duration: 5000 });
// Browser notification for calendar reminders
if (n.type === 'calendar_reminder' && 'Notification' in window) {
const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' });
if (Notification.permission === 'granted') {
fire();
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => { if (p === 'granted') fire(); });
}
}
});
} catch {
/* silent server may not be reachable */
@@ -166,6 +175,7 @@ function notificationIcon(type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
case 'calendar_reminder': return '🔔';
default: return '🔔';
}
}

View File

@@ -510,6 +510,14 @@
"linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)",
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
"reminderLabel": "Erinnerung",
"reminderNone": "Keine Erinnerung",
"reminder5": "5 Minuten vorher",
"reminder15": "15 Minuten vorher",
"reminder30": "30 Minuten vorher",
"reminder60": "1 Stunde vorher",
"reminder120": "2 Stunden vorher",
"reminder1440": "1 Tag vorher",
"timezone": "Zeitzone",
"color": "Farbe",
"eventCreated": "Event erstellt!",

View File

@@ -510,6 +510,14 @@
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"reminderLabel": "Reminder",
"reminderNone": "No reminder",
"reminder5": "5 minutes before",
"reminder15": "15 minutes before",
"reminder30": "30 minutes before",
"reminder60": "1 hour before",
"reminder120": "2 hours before",
"reminder1440": "1 day before",
"timezone": "Timezone",
"color": "Color",
"eventCreated": "Event created!",

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Clock, Video,
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react';
import api from '../services/api';
@@ -32,7 +32,7 @@ export default function Calendar() {
// Create/Edit form
const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1',
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
const [saving, setSaving] = useState(false);
@@ -157,7 +157,7 @@ export default function Calendar() {
title: '', description: '',
start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
room_uid: '', color: '#6366f1', reminder_minutes: null,
});
setEditingEvent(null);
setShowCreate(true);
@@ -171,6 +171,7 @@ export default function Calendar() {
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '',
color: ev.color || '#6366f1',
reminder_minutes: ev.reminder_minutes ?? null,
});
setEditingEvent(ev);
setShowDetail(null);
@@ -494,7 +495,10 @@ export default function Calendar() {
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="truncate">{ev.title}</div>
<div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span>
</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
</div>
))}
@@ -570,6 +574,26 @@ export default function Calendar() {
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<div className="relative">
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
<select
value={form.reminder_minutes ?? ''}
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
className="input-field pl-9"
>
<option value="">{t('calendar.reminderNone')}</option>
<option value="5">{t('calendar.reminder5')}</option>
<option value="15">{t('calendar.reminder15')}</option>
<option value="30">{t('calendar.reminder30')}</option>
<option value="60">{t('calendar.reminder60')}</option>
<option value="120">{t('calendar.reminder120')}</option>
<option value="1440">{t('calendar.reminder1440')}</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2">