feat(calendar): add reminder functionality for events with notifications
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
This commit is contained in:
@@ -513,6 +513,12 @@ export async function initDatabase() {
|
|||||||
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
|
||||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
||||||
}
|
}
|
||||||
|
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
|
||||||
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
|
||||||
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_sent_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar invitations (federated calendar events that must be accepted first)
|
// Calendar invitations (federated calendar events that must be accepted first)
|
||||||
if (isPostgres) {
|
if (isPostgres) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import caldavRoutes from './routes/caldav.js';
|
|||||||
import notificationRoutes from './routes/notifications.js';
|
import notificationRoutes from './routes/notifications.js';
|
||||||
import oauthRoutes from './routes/oauth.js';
|
import oauthRoutes from './routes/oauth.js';
|
||||||
import { startFederationSync } from './jobs/federationSync.js';
|
import { startFederationSync } from './jobs/federationSync.js';
|
||||||
|
import { startCalendarReminders } from './jobs/calendarReminders.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -93,6 +94,8 @@ async function start() {
|
|||||||
|
|
||||||
// Start periodic federation sync job (checks remote room settings every 60s)
|
// Start periodic federation sync job (checks remote room settings every 60s)
|
||||||
startFederationSync();
|
startFederationSync();
|
||||||
|
// Start calendar reminder job (sends in-app + browser notifications before events)
|
||||||
|
startCalendarReminders();
|
||||||
}
|
}
|
||||||
|
|
||||||
start().catch(err => {
|
start().catch(err => {
|
||||||
|
|||||||
90
server/jobs/calendarReminders.js
Normal file
90
server/jobs/calendarReminders.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { log } from '../config/logger.js';
|
||||||
|
import { createNotification } from '../config/notifications.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 60_000; // every minute
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for upcoming calendar events that need a reminder notification fired.
|
||||||
|
* Runs every minute. Updates `reminder_sent_at` after firing so reminders
|
||||||
|
* are never sent twice. Also resets `reminder_sent_at` to NULL when
|
||||||
|
* start_time or reminder_minutes is changed (handled in calendar route).
|
||||||
|
*/
|
||||||
|
async function runCheck() {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Fetch all events that have a reminder configured and haven't been sent yet
|
||||||
|
const pending = await db.all(`
|
||||||
|
SELECT ce.id, ce.uid, ce.title, ce.start_time, ce.reminder_minutes, ce.user_id,
|
||||||
|
ce.room_uid, ce.color
|
||||||
|
FROM calendar_events ce
|
||||||
|
WHERE ce.reminder_minutes IS NOT NULL
|
||||||
|
AND ce.reminder_sent_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const toFire = pending.filter(ev => {
|
||||||
|
const start = new Date(ev.start_time);
|
||||||
|
// Don't fire reminders for events that started more than 10 minutes ago (server downtime tolerance)
|
||||||
|
if (start < new Date(now.getTime() - 10 * 60_000)) return false;
|
||||||
|
const reminderTime = new Date(start.getTime() - ev.reminder_minutes * 60_000);
|
||||||
|
return reminderTime <= now;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ev of toFire) {
|
||||||
|
try {
|
||||||
|
// Mark as sent immediately to prevent double-fire even if notification creation fails
|
||||||
|
await db.run(
|
||||||
|
'UPDATE calendar_events SET reminder_sent_at = ? WHERE id = ?',
|
||||||
|
[now.toISOString(), ev.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = new Date(ev.start_time);
|
||||||
|
const timeStr = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const dateStr = start.toLocaleDateString([], { day: 'numeric', month: 'short' });
|
||||||
|
const body = `${dateStr} · ${timeStr}`;
|
||||||
|
const link = '/calendar';
|
||||||
|
|
||||||
|
// Notify the owner
|
||||||
|
await createNotification(ev.user_id, 'calendar_reminder', ev.title, body, link);
|
||||||
|
|
||||||
|
// Notify all accepted share users as well
|
||||||
|
const shares = await db.all(
|
||||||
|
'SELECT user_id FROM calendar_event_shares WHERE event_id = ?',
|
||||||
|
[ev.id],
|
||||||
|
);
|
||||||
|
for (const { user_id } of shares) {
|
||||||
|
await createNotification(user_id, 'calendar_reminder', ev.title, body, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.server.info(`Calendar reminder fired for event ${ev.uid} (id=${ev.id})`);
|
||||||
|
} catch (evErr) {
|
||||||
|
log.server.error(`Calendar reminder failed for event ${ev.id}: ${evErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Calendar reminder job error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCalendarReminders() {
|
||||||
|
if (timer) return;
|
||||||
|
// Slight delay on startup so DB is fully ready
|
||||||
|
setTimeout(() => {
|
||||||
|
runCheck();
|
||||||
|
timer = setInterval(runCheck, CHECK_INTERVAL_MS);
|
||||||
|
}, 5_000);
|
||||||
|
log.server.info('Calendar reminder job started');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopCalendarReminders() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,15 @@ function eventToICS(event, base, user) {
|
|||||||
if (joinUrl) {
|
if (joinUrl) {
|
||||||
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
|
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
|
||||||
}
|
}
|
||||||
|
if (event.reminder_minutes) {
|
||||||
|
lines.push(
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
`DESCRIPTION:${escapeICS(event.title)}`,
|
||||||
|
`TRIGGER:-PT${event.reminder_minutes}M`,
|
||||||
|
'END:VALARM',
|
||||||
|
);
|
||||||
|
}
|
||||||
lines.push('END:VEVENT', 'END:VCALENDAR');
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
return lines.map(foldICSLine).join('\r\n');
|
return lines.map(foldICSLine).join('\r\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const router = Router();
|
|||||||
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
|
||||||
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
|
||||||
|
|
||||||
|
// Allowed reminder intervals in minutes
|
||||||
|
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
|
||||||
|
|
||||||
// Rate limit for federation calendar receive
|
// Rate limit for federation calendar receive
|
||||||
const calendarFederationLimiter = rateLimit({
|
const calendarFederationLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
@@ -117,7 +120,7 @@ router.get('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
// ── POST /api/calendar/events — Create event ────────────────────────────────
|
// ── POST /api/calendar/events — Create event ────────────────────────────────
|
||||||
router.post('/events', authenticateToken, async (req, res) => {
|
router.post('/events', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, start_time, end_time, room_uid, color } = req.body;
|
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||||
|
|
||||||
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
|
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||||
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
|
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
|
||||||
@@ -146,9 +149,12 @@ router.post('/events', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uid = crypto.randomBytes(12).toString('hex');
|
const uid = crypto.randomBytes(12).toString('hex');
|
||||||
|
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
|
||||||
|
? Number(reminder_minutes) : null;
|
||||||
|
|
||||||
const result = await db.run(`
|
const result = await db.run(`
|
||||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color)
|
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
uid,
|
uid,
|
||||||
title.trim(),
|
title.trim(),
|
||||||
@@ -158,6 +164,7 @@ router.post('/events', authenticateToken, async (req, res) => {
|
|||||||
room_uid || null,
|
room_uid || null,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
color || '#6366f1',
|
color || '#6366f1',
|
||||||
|
validReminder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
|
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
|
||||||
@@ -175,7 +182,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||||
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
|
||||||
|
|
||||||
const { title, description, start_time, end_time, room_uid, color } = req.body;
|
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
|
||||||
|
|
||||||
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
|
||||||
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
|
||||||
@@ -197,6 +204,13 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
if (!room) return res.status(400).json({ error: 'Linked room not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validReminder = (reminder_minutes !== undefined)
|
||||||
|
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
|
||||||
|
: undefined;
|
||||||
|
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
|
||||||
|
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|
||||||
|
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
|
||||||
|
|
||||||
await db.run(`
|
await db.run(`
|
||||||
UPDATE calendar_events SET
|
UPDATE calendar_events SET
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
@@ -205,6 +219,8 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
end_time = COALESCE(?, end_time),
|
end_time = COALESCE(?, end_time),
|
||||||
room_uid = ?,
|
room_uid = ?,
|
||||||
color = COALESCE(?, color),
|
color = COALESCE(?, color),
|
||||||
|
reminder_minutes = COALESCE(?, reminder_minutes),
|
||||||
|
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -214,6 +230,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
|
|||||||
end_time || null,
|
end_time || null,
|
||||||
room_uid !== undefined ? (room_uid || null) : event.room_uid,
|
room_uid !== undefined ? (room_uid || null) : event.room_uid,
|
||||||
color || null,
|
color || null,
|
||||||
|
validReminder !== undefined ? validReminder : null,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -686,6 +703,15 @@ function generateICS(event, location, prodIdDomain) {
|
|||||||
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
|
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.reminder_minutes) {
|
||||||
|
ics.push(
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
|
||||||
|
`TRIGGER:-PT${event.reminder_minutes}M`,
|
||||||
|
'END:VALARM',
|
||||||
|
);
|
||||||
|
}
|
||||||
ics.push('END:VEVENT', 'END:VCALENDAR');
|
ics.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
return ics.join('\r\n');
|
return ics.join('\r\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ export function NotificationProvider({ children }) {
|
|||||||
seenIds.current.add(n.id);
|
seenIds.current.add(n.id);
|
||||||
const icon = notificationIcon(n.type);
|
const icon = notificationIcon(n.type);
|
||||||
toast(`${icon} ${n.title}`, { duration: 5000 });
|
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 {
|
} catch {
|
||||||
/* silent – server may not be reachable */
|
/* silent – server may not be reachable */
|
||||||
@@ -166,6 +175,7 @@ function notificationIcon(type) {
|
|||||||
case 'room_share_added': return '🔗';
|
case 'room_share_added': return '🔗';
|
||||||
case 'room_share_removed': return '🚫';
|
case 'room_share_removed': return '🚫';
|
||||||
case 'federation_invite_received': return '📩';
|
case 'federation_invite_received': return '📩';
|
||||||
|
case 'calendar_reminder': return '🔔';
|
||||||
default: return '🔔';
|
default: return '🔔';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,6 +510,14 @@
|
|||||||
"linkedRoom": "Verknüpfter Raum",
|
"linkedRoom": "Verknüpfter Raum",
|
||||||
"noRoom": "Kein Raum (kein Videomeeting)",
|
"noRoom": "Kein Raum (kein Videomeeting)",
|
||||||
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
"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",
|
"timezone": "Zeitzone",
|
||||||
"color": "Farbe",
|
"color": "Farbe",
|
||||||
"eventCreated": "Event erstellt!",
|
"eventCreated": "Event erstellt!",
|
||||||
|
|||||||
@@ -510,6 +510,14 @@
|
|||||||
"linkedRoom": "Linked Room",
|
"linkedRoom": "Linked Room",
|
||||||
"noRoom": "No room (no video meeting)",
|
"noRoom": "No room (no video meeting)",
|
||||||
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
"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",
|
"timezone": "Timezone",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"eventCreated": "Event created!",
|
"eventCreated": "Event created!",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Plus, Clock, Video,
|
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
|
||||||
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -32,7 +32,7 @@ export default function Calendar() {
|
|||||||
// Create/Edit form
|
// Create/Edit form
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', description: '', start_time: '', end_time: '',
|
title: '', description: '', start_time: '', end_time: '',
|
||||||
room_uid: '', color: '#6366f1',
|
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export default function Calendar() {
|
|||||||
title: '', description: '',
|
title: '', description: '',
|
||||||
start_time: toLocalDateTimeStr(start),
|
start_time: toLocalDateTimeStr(start),
|
||||||
end_time: toLocalDateTimeStr(end),
|
end_time: toLocalDateTimeStr(end),
|
||||||
room_uid: '', color: '#6366f1',
|
room_uid: '', color: '#6366f1', reminder_minutes: null,
|
||||||
});
|
});
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
setShowCreate(true);
|
setShowCreate(true);
|
||||||
@@ -171,6 +171,7 @@ export default function Calendar() {
|
|||||||
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
|
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
|
||||||
room_uid: ev.room_uid || '',
|
room_uid: ev.room_uid || '',
|
||||||
color: ev.color || '#6366f1',
|
color: ev.color || '#6366f1',
|
||||||
|
reminder_minutes: ev.reminder_minutes ?? null,
|
||||||
});
|
});
|
||||||
setEditingEvent(ev);
|
setEditingEvent(ev);
|
||||||
setShowDetail(null);
|
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"
|
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' }}
|
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 className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -570,6 +574,26 @@ export default function Calendar() {
|
|||||||
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
|
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user