feat: add alerts, invitees, attachments, busy status, and visibility to calendar events

Support extended calendar event properties across all providers:
- Alerts/Reminders: VALARM for CalDAV, reminders API for Google, reminderMinutes for Outlook
- Invitees/Attendees: ATTENDEE for CalDAV, attendees API for Google/Outlook
- Attachments: ATTACH (URL-based) for CalDAV (Apple/Synology)
- Busy Status: TRANSP for CalDAV, transparency for Google, showAs for Outlook
- Visibility: CLASS for CalDAV, visibility for Google, sensitivity for Outlook

Updated CalendarEventModal with new UI fields including expandable
Invitees & Attachments section and reminder/status/visibility dropdowns.

v1.36.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-16 14:36:37 +01:00
parent c21fd0bd88
commit b9c05571be
8 changed files with 977 additions and 178 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.35.3",
"version": "1.36.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { calendarId, title, description, start, end, location, allDay, recurrence, url } = body;
const { calendarId, title, description, start, end, location, allDay, recurrence, url,
reminders, busyStatus, visibility, attendees, attachments } = body;
console.log('[API] Creating event:', { calendarId, title, start, end });
@ -63,6 +64,11 @@ export async function POST(request: NextRequest) {
allDay: !!allDay,
recurrence,
url,
reminders,
busyStatus,
visibility,
attendees,
attachments,
} as any);
// Update cache - await to ensure it's ready before client refreshes
@ -86,7 +92,8 @@ export async function PATCH(request: NextRequest) {
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url } = body;
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, url,
reminders, busyStatus, visibility, attendees, attachments } = body;
console.log('[API] Updating event:', { calendarId, eventId, title });
@ -110,6 +117,11 @@ export async function PATCH(request: NextRequest) {
allDay: allDay !== undefined ? !!allDay : undefined,
recurrence,
url,
reminders,
busyStatus,
visibility,
attendees,
attachments,
} as any);
// Update cache - await to ensure it's ready before client refreshes

View File

@ -1,16 +1,45 @@
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
// Reminder preset options (minutes)
const REMINDER_OPTIONS = [
{ label: 'None', value: -1 },
{ label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 },
{ label: '15 minutes before', value: 15 },
{ label: '30 minutes before', value: 30 },
{ label: '1 hour before', value: 60 },
{ label: '2 hours before', value: 120 },
{ label: '12 hours before', value: 720 },
{ label: '1 day before', value: 1440 },
{ label: '1 week before', value: 10080 },
];
const BUSY_STATUS_OPTIONS = [
{ label: 'Busy', value: 'busy' },
{ label: 'Free', value: 'free' },
{ label: 'Tentative', value: 'tentative' },
{ label: 'Out of Office', value: 'oof' },
{ label: 'Working Elsewhere', value: 'workingElsewhere' },
];
const VISIBILITY_OPTIONS = [
{ label: 'Default', value: 'default' },
{ label: 'Public', value: 'public' },
{ label: 'Private', value: 'private' },
{ label: 'Confidential', value: 'confidential' },
];
interface CalendarEventModalProps {
event?: any; // Existing event if editing
initialDate?: Date; // If creating new
initialStartTime?: string; // If creating new from slot
connections: any[]; // To select calendar
event?: any;
initialDate?: Date;
initialStartTime?: string;
connections: any[];
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
@ -25,7 +54,6 @@ export default function CalendarEventModal({
onSave,
onDelete
}: CalendarEventModalProps) {
// Flatten calendars from connections to get selectable options
const availableCalendars = connections
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
...cal,
@ -35,7 +63,7 @@ export default function CalendarEventModal({
conn.provider === 'synology' ? 'Synology Calendar' :
'Outlook Calendar'
})))
.filter((cal: any) => cal.editable); // Only editable calendars
.filter((cal: any) => cal.editable);
const [title, setTitle] = useState(event?.title || '');
const [description, setDescription] = useState(event?.description || '');
@ -44,13 +72,26 @@ export default function CalendarEventModal({
const [recurrence, setRecurrence] = useState(event?.recurrence || 'none');
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
// Date/Time State
// If event exists, use its start/end.
// If new, use initialDate + initialStartTime.
// Default duration: 1 hour.
// New fields
const [reminders, setReminders] = useState<Array<{ method: string; minutes: number }>>(
event?.reminders || [{ method: 'display', minutes: 15 }]
);
const [busyStatus, setBusyStatus] = useState<string>(event?.busyStatus || 'busy');
const [visibility, setVisibility] = useState<string>(event?.visibility || 'default');
const [attendees, setAttendees] = useState<Array<{ email: string; displayName?: string }>>(
event?.attendees || []
);
const [newAttendeeEmail, setNewAttendeeEmail] = useState('');
const [attachments, setAttachments] = useState<Array<{ url: string; title?: string }>>(
event?.attachments || []
);
const [newAttachmentUrl, setNewAttachmentUrl] = useState('');
const [showMoreOptions, setShowMoreOptions] = useState(
// Auto-expand if event has these fields set
!!(event?.attendees?.length || event?.attachments?.length || (event?.busyStatus && event.busyStatus !== 'busy') || (event?.visibility && event.visibility !== 'default'))
);
const getInitialStart = () => {
// Support both nested (start.dateTime) and flat (startTime) event formats
if (event?.start?.dateTime) return new Date(event.start.dateTime);
if (event?.startTime) return new Date(event.startTime);
if (initialDate) {
@ -59,7 +100,6 @@ export default function CalendarEventModal({
const [h, m] = initialStartTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
} else {
// Default to next hour if no time specified (though usually slot click gives time)
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
@ -69,11 +109,10 @@ export default function CalendarEventModal({
};
const getInitialEnd = () => {
// Support both nested (end.dateTime) and flat (endTime) event formats
if (event?.end?.dateTime) return new Date(event.end.dateTime);
if (event?.endTime) return new Date(event.endTime);
const start = getInitialStart();
return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour
return new Date(start.getTime() + 60 * 60 * 1000);
};
const [startDate, setStartDate] = useState(getInitialStart());
@ -85,7 +124,6 @@ export default function CalendarEventModal({
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
const calendarSelectorRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) {
@ -98,6 +136,8 @@ export default function CalendarEventModal({
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook');
const supportsAttachments = selectedCal && (selectedCal.provider === 'apple' || selectedCal.provider === 'synology');
const getProviderIcon = (provider: string) => {
switch (provider) {
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
@ -125,6 +165,9 @@ export default function CalendarEventModal({
setIsSaving(true);
setError('');
try {
// Filter out "none" reminders
const activeReminders = reminders.filter(r => r.minutes >= 0);
await onSave({
id: event?.id,
title,
@ -135,7 +178,12 @@ export default function CalendarEventModal({
calendarId,
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() }
end: { dateTime: endDate.toISOString() },
reminders: activeReminders.length > 0 ? activeReminders : undefined,
busyStatus: busyStatus !== 'busy' ? busyStatus : undefined,
visibility: visibility !== 'default' ? visibility : undefined,
attendees: attendees.length > 0 ? attendees : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
});
onClose();
} catch (err: any) {
@ -152,7 +200,7 @@ export default function CalendarEventModal({
if (!isDeleteConfirming) {
setIsDeleteConfirming(true);
setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds
setTimeout(() => setIsDeleteConfirming(false), 3000);
return;
}
@ -167,8 +215,6 @@ export default function CalendarEventModal({
}
};
// Helper to format date for input type="datetime-local"
// Format: YYYY-MM-DDThh:mm
const toLocalISOString = (date: Date) => {
const offset = date.getTimezoneOffset() * 60000;
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
@ -178,12 +224,60 @@ export default function CalendarEventModal({
const handleStartDateChange = (val: string) => {
const newStart = new Date(val);
setStartDate(newStart);
// Auto-adjust end date if it becomes before start
if (endDate <= newStart) {
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
}
};
const addAttendee = () => {
const email = newAttendeeEmail.trim();
if (!email || !email.includes('@')) return;
if (attendees.some(a => a.email === email)) return;
setAttendees([...attendees, { email }]);
setNewAttendeeEmail('');
};
const removeAttendee = (email: string) => {
setAttendees(attendees.filter(a => a.email !== email));
};
const addAttachment = () => {
const attachUrl = newAttachmentUrl.trim();
if (!attachUrl || (!attachUrl.startsWith('http://') && !attachUrl.startsWith('https://'))) return;
if (attachments.some(a => a.url === attachUrl)) return;
setAttachments([...attachments, { url: attachUrl }]);
setNewAttachmentUrl('');
};
const removeAttachment = (attachUrl: string) => {
setAttachments(attachments.filter(a => a.url !== attachUrl));
};
const updateReminder = (index: number, minutes: number) => {
if (minutes === -1) {
// Remove this reminder
setReminders(reminders.filter((_, i) => i !== index));
return;
}
const updated = [...reminders];
updated[index] = { ...updated[index], minutes };
setReminders(updated);
};
const addReminder = () => {
setReminders([...reminders, { method: 'display', minutes: 15 }]);
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500,
};
const selectStyle: React.CSSProperties = {
background: 'transparent', border: 'none', textAlign: 'right' as const,
fontSize: '0.9rem', cursor: 'pointer', outline: 'none', color: 'var(--weekly-text)',
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
@ -192,7 +286,9 @@ export default function CalendarEventModal({
padding: '20px',
borderRadius: '12px',
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
border: '1px solid var(--weekly-border)'
border: '1px solid var(--weekly-border)',
maxHeight: '90vh',
overflowY: 'auto',
}}>
{error && <div style={{ color: '#ef4444', marginBottom: '1rem', fontSize: '0.85rem', textAlign: 'center' }}>{error}</div>}
@ -236,9 +332,9 @@ export default function CalendarEventModal({
}}
/>
{/* Calendar row: Label + Custom Selector */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px', position: 'relative' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Calendar</span>
{/* Calendar row */}
<div style={{ ...rowStyle, padding: '4px 16px', position: 'relative' }}>
<span style={labelStyle}>Calendar</span>
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<button
@ -246,28 +342,18 @@ export default function CalendarEventModal({
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
background: 'transparent',
border: 'none',
fontWeight: 500,
cursor: event ? 'default' : 'pointer',
outline: 'none',
color: 'var(--weekly-text)',
fontSize: '0.95rem',
padding: '4px 0',
maxWidth: '200px',
justifyContent: 'flex-end'
display: 'flex', alignItems: 'center', gap: '8px',
background: 'transparent', border: 'none', fontWeight: 500,
cursor: event ? 'default' : 'pointer', outline: 'none',
color: 'var(--weekly-text)', fontSize: '0.95rem',
padding: '4px 0', maxWidth: '200px', justifyContent: 'flex-end'
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
</span>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
width: '12px', height: '12px', borderRadius: '50%',
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
}} />
{!event && (isCalendarSelectorOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
@ -275,39 +361,22 @@ export default function CalendarEventModal({
{isCalendarSelectorOpen && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
zIndex: 100,
minWidth: '220px',
backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
borderRadius: '10px',
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
border: '1px solid var(--weekly-border)',
marginTop: '5px',
padding: '6px',
maxHeight: '250px',
overflowY: 'auto'
position: 'absolute', top: '100%', right: 0, zIndex: 100,
minWidth: '220px', backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
borderRadius: '10px', boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
border: '1px solid var(--weekly-border)', marginTop: '5px',
padding: '6px', maxHeight: '250px', overflowY: 'auto'
}}>
{availableCalendars.map((cal: any) => (
<div
key={cal.id}
onClick={() => {
setCalendarId(cal.id);
setIsCalendarSelectorOpen(false);
}}
onClick={() => { setCalendarId(cal.id); setIsCalendarSelectorOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
color: 'var(--weekly-text)',
display: 'flex', alignItems: 'center', gap: '10px',
padding: '8px 12px', borderRadius: '6px', cursor: 'pointer',
fontSize: '0.9rem', color: 'var(--weekly-text)',
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
transition: 'background 0.2s',
textAlign: 'left'
transition: 'background 0.2s', textAlign: 'left'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--weekly-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent'}
@ -327,8 +396,8 @@ export default function CalendarEventModal({
</div>
{/* All Day row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>All Day</span>
<div style={{ ...rowStyle, padding: '4px 16px' }}>
<span style={labelStyle}>All Day</span>
<input
type="checkbox"
checked={allDay}
@ -339,35 +408,29 @@ export default function CalendarEventModal({
{/* Date/Time rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Starts</span>
<div style={rowStyle}>
<span style={labelStyle}>Starts</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
style={{
padding: '4px 8px',
border: 'none',
borderRadius: '6px',
padding: '4px 8px', border: 'none', borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.9rem',
textAlign: 'center'
fontSize: '0.9rem', textAlign: 'center'
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Ends</span>
<div style={rowStyle}>
<span style={labelStyle}>Ends</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
style={{
padding: '4px 8px',
border: 'none',
borderRadius: '6px',
padding: '4px 8px', border: 'none', borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.9rem',
textAlign: 'center'
fontSize: '0.9rem', textAlign: 'center'
}}
/>
</div>
@ -376,21 +439,9 @@ export default function CalendarEventModal({
{/* Meta Fields Group */}
<div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '4px' }}>
{/* Recurrence */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Repeat</span>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value)}
style={{
background: 'transparent',
border: 'none',
textAlign: 'right',
fontSize: '0.9rem',
cursor: 'pointer',
outline: 'none',
color: 'var(--weekly-text)'
}}
>
<div style={rowStyle}>
<span style={labelStyle}>Repeat</span>
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} style={selectStyle}>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
@ -399,6 +450,62 @@ export default function CalendarEventModal({
</select>
</div>
{/* Reminders */}
<div>
<div style={{ ...rowStyle, marginBottom: reminders.length > 0 ? '6px' : 0 }}>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px' }}>
<Bell size={14} /> Alert
</span>
{reminders.length === 0 && (
<button onClick={addReminder} style={{ ...selectStyle, color: '#3b82f6', cursor: 'pointer', background: 'none', border: 'none' }}>
Add
</button>
)}
</div>
{reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px', marginBottom: '4px' }}>
<select
value={reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
style={selectStyle}
>
{REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)' }}>
<X size={14} />
</button>
</div>
))}
{reminders.length > 0 && reminders.length < 2 && (
<button onClick={addReminder} style={{ fontSize: '0.85rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 0' }}>
+ Add another alert
</button>
)}
</div>
{/* Busy Status */}
<div style={rowStyle}>
<span style={labelStyle}>Status</span>
<select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} style={selectStyle}>
{BUSY_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Visibility */}
<div style={rowStyle}>
<span style={labelStyle}>Visibility</span>
<select value={visibility} onChange={e => setVisibility(e.target.value)} style={selectStyle}>
{VISIBILITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* URL (Conditional) */}
{supportsURL && (
<input
@ -407,17 +514,118 @@ export default function CalendarEventModal({
onChange={e => setUrl(e.target.value)}
placeholder="URL"
style={{
width: '100%',
padding: '6px 0',
border: 'none',
width: '100%', padding: '6px 0', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent',
fontSize: '0.9rem',
outline: 'none'
background: 'transparent', fontSize: '0.9rem', outline: 'none'
}}
/>
)}
{/* Expandable section: Attendees & Attachments */}
<button
onClick={() => setShowMoreOptions(!showMoreOptions)}
style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', fontSize: '0.9rem', padding: '4px 0',
}}
>
{showMoreOptions ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
Invitees & Attachments
</button>
{showMoreOptions && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Attendees */}
<div>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
<Users size={14} /> Invitees
</span>
{attendees.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '4px 8px', marginBottom: '4px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '6px',
fontSize: '0.85rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.displayName ? `${att.displayName} (${att.email})` : att.email}
</span>
<button onClick={() => removeAttendee(att.email)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={14} />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '6px' }}>
<input
type="email"
value={newAttendeeEmail}
onChange={e => setNewAttendeeEmail(e.target.value)}
placeholder="Add email address"
onKeyDown={e => e.key === 'Enter' && addAttendee()}
style={{
flex: 1, padding: '6px 8px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.85rem', outline: 'none',
}}
/>
<button onClick={addAttendee} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', padding: '4px',
}}>
<Plus size={16} />
</button>
</div>
</div>
{/* Attachments (URL-based, Apple/Synology only) */}
{supportsAttachments && (
<div>
<span style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
<Paperclip size={14} /> Attachments
</span>
{attachments.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '4px 8px', marginBottom: '4px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', borderRadius: '6px',
fontSize: '0.85rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.title || att.url}
</span>
<button onClick={() => removeAttachment(att.url)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={14} />
</button>
</div>
))}
<div style={{ display: 'flex', gap: '6px' }}>
<input
type="url"
value={newAttachmentUrl}
onChange={e => setNewAttachmentUrl(e.target.value)}
placeholder="Add attachment URL"
onKeyDown={e => e.key === 'Enter' && addAttachment()}
style={{
flex: 1, padding: '6px 8px', border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent', fontSize: '0.85rem', outline: 'none',
}}
/>
<button onClick={addAttachment} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#3b82f6', padding: '4px',
}}>
<Plus size={16} />
</button>
</div>
</div>
)}
</div>
)}
{/* Notes */}
<div style={{ marginTop: '4px' }}>
<Suspense fallback={
@ -447,15 +655,9 @@ export default function CalendarEventModal({
onClick={handleDelete}
disabled={isSaving || isDeleting}
style={{
padding: '8px 12px',
background: 'none',
color: '#ef4444',
border: 'none',
borderRadius: '6px',
fontSize: '0.9rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
padding: '8px 12px', background: 'none', color: '#ef4444',
border: 'none', borderRadius: '6px', fontSize: '0.9rem',
fontWeight: 500, cursor: 'pointer', transition: 'all 0.2s',
opacity: isSaving || isDeleting ? 0.5 : 1
}}
>
@ -480,14 +682,9 @@ export default function CalendarEventModal({
onClick={handleSubmit}
disabled={isSaving || isDeleting}
style={{
padding: '8px 20px',
borderRadius: '8px',
fontSize: '0.95rem',
fontWeight: 600,
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
cursor: 'pointer',
padding: '8px 20px', borderRadius: '8px', fontSize: '0.95rem',
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
border: 'none', cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1
}}
>

View File

@ -14,6 +14,11 @@ export interface AppleCalendarEvent {
url?: string;
recurringEventId?: string;
isRecurring?: boolean;
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
/**
@ -26,6 +31,122 @@ function formatDateToLocalISO(date: Date): string {
return `${year}-${month}-${day}`;
}
/**
* Convert reminder minutes to iCalendar TRIGGER duration string (e.g. -PT15M, -PT1H, -P1D, -P1W)
*/
function reminderMinutesToDuration(minutes: number): string {
if (minutes === 0) return 'PT0S'; // At time of event
const prefix = '-'; // before event
if (minutes % 10080 === 0) return `${prefix}P${minutes / 10080}W`;
if (minutes % 1440 === 0) return `${prefix}P${minutes / 1440}D`;
if (minutes % 60 === 0) return `${prefix}PT${minutes / 60}H`;
return `${prefix}PT${minutes}M`;
}
/**
* Extract extended properties (reminders, attendees, attachments, busy status, visibility) from a VEVENT component
*/
function extractExtendedProps(vevent: any): Pick<AppleCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> {
const result: Pick<AppleCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> = {};
// Extract VALARM components
const valarms = vevent.getAllSubcomponents('valarm');
if (valarms && valarms.length > 0) {
result.reminders = valarms.map((valarm: any) => {
const action = valarm.getFirstPropertyValue('action') || 'DISPLAY';
const trigger = valarm.getFirstProperty('trigger');
let minutes = 15; // default
if (trigger) {
const triggerVal = trigger.getFirstValue();
if (triggerVal && typeof triggerVal.toSeconds === 'function') {
// ICAL.Duration - convert to minutes (negative = before event)
minutes = Math.abs(Math.round(triggerVal.toSeconds() / 60));
} else if (typeof triggerVal === 'string') {
// Parse ISO duration like -PT15M
const match = triggerVal.match(/^-?PT?(\d+)([MHDS])/i);
if (match) {
const val = parseInt(match[1]);
switch (match[2].toUpperCase()) {
case 'M': minutes = val; break;
case 'H': minutes = val * 60; break;
case 'D': minutes = val * 1440; break;
case 'S': minutes = Math.round(val / 60); break;
}
}
// Handle -P1W (1 week)
const weekMatch = triggerVal.match(/^-?P(\d+)W/i);
if (weekMatch) minutes = parseInt(weekMatch[1]) * 10080;
// Handle -P1D (1 day)
const dayMatch = triggerVal.match(/^-?P(\d+)D/i);
if (dayMatch) minutes = parseInt(dayMatch[1]) * 1440;
}
}
return {
method: action.toString().toLowerCase() === 'email' ? 'email' : 'display',
minutes,
};
});
}
// Extract ATTENDEE properties
const attendeeProps = vevent.getAllProperties('attendee');
if (attendeeProps && attendeeProps.length > 0) {
result.attendees = attendeeProps.map((prop: any) => {
const val = prop.getFirstValue() || '';
const email = val.replace(/^mailto:/i, '');
const cn = prop.getParameter('cn');
const partstat = prop.getParameter('partstat');
const statusMap: Record<string, string> = {
'ACCEPTED': 'accepted',
'DECLINED': 'declined',
'TENTATIVE': 'tentative',
'NEEDS-ACTION': 'needsAction',
};
return {
email,
displayName: cn || undefined,
responseStatus: statusMap[partstat?.toUpperCase()] || 'needsAction',
};
});
}
// Extract ATTACH properties (URL-based)
const attachProps = vevent.getAllProperties('attach');
if (attachProps && attachProps.length > 0) {
result.attachments = attachProps
.map((prop: any) => {
const val = prop.getFirstValue();
if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) {
const fmttype = prop.getParameter('fmttype');
const filename = prop.getParameter('filename');
return { url: val, title: filename || undefined };
}
return null;
})
.filter(Boolean) as Array<{ url: string; title?: string }>;
if (result.attachments.length === 0) delete result.attachments;
}
// Extract TRANSP (busy status)
const transp = vevent.getFirstPropertyValue('transp');
if (transp) {
result.busyStatus = transp.toString().toUpperCase() === 'TRANSPARENT' ? 'free' : 'busy';
}
// Extract CLASS (visibility)
const cls = vevent.getFirstPropertyValue('class');
if (cls) {
const clsMap: Record<string, string> = {
'PUBLIC': 'public',
'PRIVATE': 'private',
'CONFIDENTIAL': 'confidential',
};
result.visibility = (clsMap[cls.toString().toUpperCase()] || 'default') as any;
}
return result;
}
export interface AppleCalendar {
id: string;
title: string;
@ -182,6 +303,7 @@ export const getUpcomingEvents = async (
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: exEvent.uid,
isRecurring: true,
...extractExtendedProps(exVevent),
});
}
});
@ -217,6 +339,7 @@ export const getUpcomingEvents = async (
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: event.uid,
isRecurring: true,
...extractExtendedProps(vevent),
});
}
} catch (expandErr: any) {
@ -238,6 +361,7 @@ export const getUpcomingEvents = async (
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
...extractExtendedProps(vevent),
});
}
});
@ -268,6 +392,11 @@ export const createEvent = async (
recurrence?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
): Promise<AppleCalendarEvent> => {
try {
@ -330,6 +459,44 @@ export const createEvent = async (
}
}
// Generate VALARM blocks for reminders
let valarmLines = '';
if (eventData.reminders && eventData.reminders.length > 0) {
for (const reminder of eventData.reminders) {
const action = reminder.method === 'email' ? 'EMAIL' : 'DISPLAY';
const dur = reminderMinutesToDuration(reminder.minutes);
valarmLines += `BEGIN:VALARM\r\nACTION:${action}\r\nTRIGGER:${dur}\r\n`;
if (action === 'DISPLAY') valarmLines += `DESCRIPTION:Reminder\r\n`;
valarmLines += `END:VALARM\r\n`;
}
}
// Generate ATTENDEE lines
let attendeeLines = '';
if (eventData.attendees && eventData.attendees.length > 0) {
for (const att of eventData.attendees) {
const cn = att.displayName ? `;CN=${att.displayName}` : '';
attendeeLines += `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION${cn}:mailto:${att.email}\r\n`;
}
}
// Generate ATTACH lines (URL-based)
let attachLines = '';
if (eventData.attachments && eventData.attachments.length > 0) {
for (const att of eventData.attachments) {
attachLines += `ATTACH:${att.url}\r\n`;
}
}
// TRANSP (busy status)
const transpLine = eventData.busyStatus === 'free' ? 'TRANSP:TRANSPARENT\r\n' : eventData.busyStatus ? 'TRANSP:OPAQUE\r\n' : '';
// CLASS (visibility)
let classLine = '';
if (eventData.visibility && eventData.visibility !== 'default') {
classLine = `CLASS:${eventData.visibility.toUpperCase()}\r\n`;
}
const iCalString = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//My Weekly ToDo List//EN
@ -339,7 +506,7 @@ DTSTAMP:${dtStamp}
DTSTART${dtStartParam}:${dtStart}
DTEND${dtEndParam}:${dtEnd}
SUMMARY:${eventData.title}
${description}${location}${url}${rruleLine}END:VEVENT
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
END:VCALENDAR`;
console.log('[APPLE CALENDAR] Creating event with iCal:', iCalString);
@ -382,6 +549,11 @@ export const updateEvent = async (
url?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
): Promise<AppleCalendarEvent> => {
try {
@ -473,6 +645,65 @@ export const updateEvent = async (
}
}
// Update reminders (VALARM)
if (eventData.reminders !== undefined) {
// Remove existing VALARMs
const existingAlarms = vevent.getAllSubcomponents('valarm');
existingAlarms.forEach((a: any) => vevent.removeSubcomponent(a));
// Add new ones
for (const reminder of eventData.reminders) {
const valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', reminder.method === 'email' ? 'EMAIL' : 'DISPLAY');
const dur = ICAL.Duration.fromString(reminderMinutesToDuration(reminder.minutes));
valarm.addPropertyWithValue('trigger', dur);
if (reminder.method !== 'email') {
valarm.addPropertyWithValue('description', 'Reminder');
}
vevent.addSubcomponent(valarm);
}
}
// Update attendees
if (eventData.attendees !== undefined) {
// Remove existing ATTENDEEs
vevent.removeAllProperties('attendee');
for (const att of eventData.attendees) {
const prop = new ICAL.Property('attendee');
prop.setValue(`mailto:${att.email}`);
prop.setParameter('cutype', 'INDIVIDUAL');
prop.setParameter('role', 'REQ-PARTICIPANT');
prop.setParameter('partstat', 'NEEDS-ACTION');
if (att.displayName) prop.setParameter('cn', att.displayName);
vevent.addProperty(prop);
}
}
// Update attachments
if (eventData.attachments !== undefined) {
vevent.removeAllProperties('attach');
for (const att of eventData.attachments) {
vevent.addPropertyWithValue('attach', att.url);
}
}
// Update TRANSP (busy status)
if (eventData.busyStatus !== undefined) {
if (eventData.busyStatus === 'free') {
vevent.updatePropertyWithValue('transp', 'TRANSPARENT');
} else if (eventData.busyStatus) {
vevent.updatePropertyWithValue('transp', 'OPAQUE');
}
}
// Update CLASS (visibility)
if (eventData.visibility !== undefined) {
if (eventData.visibility && eventData.visibility !== 'default') {
vevent.updatePropertyWithValue('class', eventData.visibility.toUpperCase());
} else {
vevent.removeProperty('class');
}
}
// Bump sequence
event.sequence = (event.sequence || 0) + 1;
if (vevent) {

View File

@ -6,6 +6,25 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export interface EventReminder {
method: 'popup' | 'email' | 'display'; // display = VALARM DISPLAY, popup = Google popup, email = email reminder
minutes: number; // minutes before event
}
export interface EventAttendee {
email: string;
displayName?: string;
responseStatus?: 'needsAction' | 'accepted' | 'declined' | 'tentative';
}
export interface EventAttachment {
url: string;
title?: string;
}
export type BusyStatus = 'free' | 'tentative' | 'busy' | 'oof' | 'workingElsewhere';
export type EventVisibility = 'default' | 'public' | 'private' | 'confidential';
export interface CalendarEvent {
id: string;
title: string;
@ -28,6 +47,11 @@ export interface CalendarEvent {
calendarTitle: string;
backgroundColor?: string;
allDay?: boolean;
reminders?: EventReminder[];
busyStatus?: BusyStatus;
visibility?: EventVisibility;
attendees?: EventAttendee[];
attachments?: EventAttachment[];
}
/**
@ -295,9 +319,17 @@ export const getCalendarEvents = async (
events = events.concat(calendarEvents.map((event: any) => {
const eventColor = event.colorId ? getGoogleEventColor(event.colorId) : calendarData?.backgroundColor;
// Map Google reminders to our format
const reminders: EventReminder[] | undefined = event.reminders?.overrides?.map((r: any) => ({
method: r.method === 'email' ? 'email' : 'popup',
minutes: r.minutes,
})) || undefined;
// Map Google transparency to busyStatus
const busyStatus: BusyStatus | undefined = event.transparency === 'transparent' ? 'free'
: event.transparency === 'opaque' ? 'busy' : undefined;
return {
id: event.id,
title: event.summary || '(No Title)', // Map summary to title
title: event.summary || '(No Title)',
description: event.description,
start: event.start,
end: event.end,
@ -305,7 +337,15 @@ export const getCalendarEvents = async (
source: 'google' as const,
calendarId,
calendarTitle: calendarData?.summary || calendarData?.title || 'Google Calendar',
backgroundColor: eventColor
backgroundColor: eventColor,
reminders,
busyStatus,
visibility: event.visibility as EventVisibility || undefined,
attendees: event.attendees?.map((a: any) => ({
email: a.email,
displayName: a.displayName,
responseStatus: a.responseStatus,
})) as EventAttendee[] || undefined,
};
}));
} catch (calError) {
@ -346,7 +386,6 @@ export const getCalendarEvents = async (
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
events = events.concat(calendarEvents.map((event: any) => {
// Date-only strings (YYYY-MM-DD) indicate all-day events
const isDateOnly = (s: string) => s && !s.includes('T');
const startIsAllDay = isDateOnly(event.startDate);
return {
@ -368,7 +407,12 @@ export const getCalendarEvents = async (
source: 'apple' as const,
calendarId,
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30'
backgroundColor: calendars.find(c => c.id === calendarId)?.color || '#FF3B30',
reminders: event.reminders as EventReminder[] || undefined,
busyStatus: event.busyStatus as BusyStatus || undefined,
visibility: event.visibility as EventVisibility || undefined,
attendees: event.attendees as EventAttendee[] || undefined,
attachments: event.attachments as EventAttachment[] || undefined,
};
}));
}
@ -440,7 +484,12 @@ export const getCalendarEvents = async (
source: 'synology' as const,
calendarId,
calendarTitle: calendarData?.title || 'Synology Calendar',
backgroundColor: calendarData?.color || '#1b85ff'
backgroundColor: calendarData?.color || '#1b85ff',
reminders: event.reminders as EventReminder[] || undefined,
busyStatus: event.busyStatus as BusyStatus || undefined,
visibility: event.visibility as EventVisibility || undefined,
attendees: event.attendees as EventAttendee[] || undefined,
attachments: event.attachments as EventAttachment[] || undefined,
};
}));
} catch (calError) {
@ -495,7 +544,11 @@ export const getCalendarEvents = async (
source: 'outlook' as const,
calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar',
backgroundColor: '#0078d4' // Outlook Blue
backgroundColor: '#0078d4',
reminders: event.reminders as EventReminder[] || undefined,
busyStatus: event.busyStatus as BusyStatus || undefined,
visibility: event.visibility as EventVisibility || undefined,
attendees: event.attendees as EventAttendee[] || undefined,
})));
} catch (calError) {
@ -641,6 +694,12 @@ export const createCalendarEvent = async (
...(rrule ? { recurrence: [rrule] } : {}),
...(event.allDay ? { allDay: true } : {}),
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
...(event.reminders?.length ? {
reminders: { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) }
} : {}),
...(event.busyStatus ? { transparency: event.busyStatus === 'free' ? 'transparent' : 'opaque' } : {}),
...(event.visibility ? { visibility: event.visibility } : {}),
...(event.attendees?.length ? { attendees: event.attendees.map(a => ({ email: a.email, displayName: a.displayName })) } : {}),
};
const createdEvent = await import('./google-calendar').then(m =>
@ -678,6 +737,10 @@ export const createCalendarEvent = async (
location: event.location,
allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
reminders: event.reminders,
busyStatus: event.busyStatus,
visibility: event.visibility,
attendees: event.attendees,
});
return {
@ -731,7 +794,12 @@ export const createCalendarEvent = async (
url: event.url,
recurrence: event.recurrence,
start,
end
end,
reminders: event.reminders,
attendees: event.attendees,
attachments: event.attachments,
busyStatus: event.busyStatus,
visibility: event.visibility,
})
);
@ -762,7 +830,12 @@ export const createCalendarEvent = async (
url: event.url,
recurrence: event.recurrence,
start: event.start!,
end: event.end!
end: event.end!,
reminders: event.reminders,
attendees: event.attendees,
attachments: event.attachments,
busyStatus: event.busyStatus,
visibility: event.visibility,
})
);
@ -816,6 +889,12 @@ export const updateCalendarEvent = async (
if (event.location !== undefined) googleEvent.location = event.location;
if (rrule) googleEvent.recurrence = [rrule];
if (event.url) googleEvent.source = { url: event.url, title: event.url };
if (event.reminders?.length) {
googleEvent.reminders = { useDefault: false, overrides: event.reminders.map(r => ({ method: r.method === 'email' ? 'email' : 'popup', minutes: r.minutes })) };
}
if (event.busyStatus) googleEvent.transparency = event.busyStatus === 'free' ? 'transparent' : 'opaque';
if (event.visibility) googleEvent.visibility = event.visibility;
if (event.attendees) googleEvent.attendees = event.attendees.map(a => ({ email: a.email, displayName: a.displayName }));
// Google adds _date suffix for instances. Editing base series only.
const baseEventId = eventId.split('_')[0];
@ -855,6 +934,10 @@ export const updateCalendarEvent = async (
location: event.location,
allDay: event.allDay,
recurrence: toOutlookRecurrence(event.recurrence, startDate),
reminders: event.reminders,
busyStatus: event.busyStatus,
visibility: event.visibility,
attendees: event.attendees,
});
return {
@ -897,7 +980,12 @@ export const updateCalendarEvent = async (
location: event.location,
url: event.url,
start: event.start,
end: event.end
end: event.end,
reminders: event.reminders,
attendees: event.attendees,
attachments: event.attachments,
busyStatus: event.busyStatus,
visibility: event.visibility,
})
);
@ -926,7 +1014,12 @@ export const updateCalendarEvent = async (
location: event.location,
url: event.url,
start: event.start,
end: event.end
end: event.end,
reminders: event.reminders,
attendees: event.attendees,
attachments: event.attachments,
busyStatus: event.busyStatus,
visibility: event.visibility,
})
);

View File

@ -16,9 +16,16 @@ export interface GoogleCalendarEvent {
attendees?: Array<{
email: string;
displayName?: string;
responseStatus?: string;
}>;
location?: string;
colorId?: string;
reminders?: {
useDefault: boolean;
overrides?: Array<{ method: string; minutes: number }>;
};
transparency?: string; // 'opaque' | 'transparent'
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
}
export interface GoogleCalendar {
@ -96,9 +103,16 @@ export const getUpcomingEvents = async (
description: item.description,
start: item.start,
end: item.end,
attendees: item.attendees,
attendees: item.attendees?.map((a: any) => ({
email: a.email,
displayName: a.displayName,
responseStatus: a.responseStatus,
})),
location: item.location,
colorId: item.colorId,
reminders: item.reminders,
transparency: item.transparency,
visibility: item.visibility,
})) || [];
} catch (error) {
console.error('Error fetching upcoming events:', error);
@ -127,6 +141,10 @@ export const createEvent = async (
};
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
if ((event as any).source) requestBody.source = (event as any).source;
if (event.reminders) requestBody.reminders = event.reminders;
if (event.transparency) requestBody.transparency = event.transparency;
if (event.visibility) requestBody.visibility = event.visibility;
if (event.attendees) requestBody.attendees = event.attendees;
const response = await calendar.events.insert({
calendarId,
requestBody,
@ -160,6 +178,10 @@ export const updateEvent = async (
};
if ((event as any).recurrence) requestBody.recurrence = (event as any).recurrence;
if ((event as any).source) requestBody.source = (event as any).source;
if (event.reminders) requestBody.reminders = event.reminders;
if (event.transparency) requestBody.transparency = event.transparency;
if (event.visibility) requestBody.visibility = event.visibility;
if (event.attendees) requestBody.attendees = event.attendees;
const response = await calendar.events.patch({
calendarId,
eventId,

View File

@ -147,7 +147,7 @@ export const getUpcomingEvents = async (
const params = new URLSearchParams({
startDateTime: startDateTime,
endDateTime: endDateTime,
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type',
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type,reminderMinutesBeforeStart,isReminderOn,showAs,sensitivity,attendees',
'$orderby': 'start/dateTime',
'$top': '50'
});
@ -169,7 +169,18 @@ export const getUpcomingEvents = async (
}
const data = await response.json();
return data.value.map((event: any) => ({
return data.value.map((event: any) => {
// Map Outlook showAs to our busyStatus
const showAsMap: Record<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
// Map Outlook sensitivity to our visibility
const sensitivityMap: Record<string, string> = {
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
};
return {
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
summary: event.subject,
description: event.body?.content || event.bodyPreview,
@ -183,8 +194,22 @@ export const getUpcomingEvents = async (
},
location: event.location?.displayName,
htmlLink: event.webLink,
allDay: event.isAllDay
}));
allDay: event.isAllDay,
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
: undefined,
busyStatus: showAsMap[event.showAs] || undefined,
visibility: sensitivityMap[event.sensitivity] || undefined,
attendees: event.attendees?.map((a: any) => ({
email: a.emailAddress?.address,
displayName: a.emailAddress?.name,
responseStatus: a.status?.response === 'accepted' ? 'accepted'
: a.status?.response === 'declined' ? 'declined'
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
: 'needsAction',
})),
};
});
};
const ensureTimeZone = (dateTimeObj: any) => {
@ -222,6 +247,22 @@ export const createEvent = async (
displayName: event.location || ''
},
...(event.recurrence ? { recurrence: event.recurrence } : {}),
...(event.reminders?.length ? {
isReminderOn: true,
reminderMinutesBeforeStart: event.reminders[0].minutes,
} : {}),
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
...(event.visibility ? {
sensitivity: event.visibility === 'private' ? 'private'
: event.visibility === 'confidential' ? 'confidential'
: 'normal'
} : {}),
...(event.attendees?.length ? {
attendees: event.attendees.map((a: any) => ({
emailAddress: { address: a.email, name: a.displayName || a.email },
type: 'required',
})),
} : {}),
})
});
@ -270,6 +311,22 @@ export const updateEvent = async (
displayName: event.location || ''
},
...(event.recurrence ? { recurrence: event.recurrence } : {}),
...(event.reminders?.length ? {
isReminderOn: true,
reminderMinutesBeforeStart: event.reminders[0].minutes,
} : {}),
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
...(event.visibility ? {
sensitivity: event.visibility === 'private' ? 'private'
: event.visibility === 'confidential' ? 'confidential'
: 'normal'
} : {}),
...(event.attendees?.length ? {
attendees: event.attendees.map((a: any) => ({
emailAddress: { address: a.email, name: a.displayName || a.email },
type: 'required',
})),
} : {}),
})
});

View File

@ -11,6 +11,11 @@ export interface SynologyCalendarEvent {
url?: string;
recurringEventId?: string;
isRecurring?: boolean;
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
/**
@ -23,6 +28,95 @@ function formatDateToLocalISO(date: Date): string {
return `${year}-${month}-${day}`;
}
/**
* Convert reminder minutes to iCalendar TRIGGER duration string
*/
function reminderMinutesToDuration(minutes: number): string {
if (minutes === 0) return 'PT0S';
const prefix = '-';
if (minutes % 10080 === 0) return `${prefix}P${minutes / 10080}W`;
if (minutes % 1440 === 0) return `${prefix}P${minutes / 1440}D`;
if (minutes % 60 === 0) return `${prefix}PT${minutes / 60}H`;
return `${prefix}PT${minutes}M`;
}
/**
* Extract extended properties from a VEVENT component
*/
function extractExtendedProps(vevent: any): Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> {
const result: Pick<SynologyCalendarEvent, 'reminders' | 'attendees' | 'attachments' | 'busyStatus' | 'visibility'> = {};
const valarms = vevent.getAllSubcomponents('valarm');
if (valarms && valarms.length > 0) {
result.reminders = valarms.map((valarm: any) => {
const action = valarm.getFirstPropertyValue('action') || 'DISPLAY';
const trigger = valarm.getFirstProperty('trigger');
let minutes = 15;
if (trigger) {
const triggerVal = trigger.getFirstValue();
if (triggerVal && typeof triggerVal.toSeconds === 'function') {
minutes = Math.abs(Math.round(triggerVal.toSeconds() / 60));
} else if (typeof triggerVal === 'string') {
const match = triggerVal.match(/^-?PT?(\d+)([MHDS])/i);
if (match) {
const val = parseInt(match[1]);
switch (match[2].toUpperCase()) {
case 'M': minutes = val; break;
case 'H': minutes = val * 60; break;
case 'D': minutes = val * 1440; break;
case 'S': minutes = Math.round(val / 60); break;
}
}
const weekMatch = triggerVal.match(/^-?P(\d+)W/i);
if (weekMatch) minutes = parseInt(weekMatch[1]) * 10080;
const dayMatch = triggerVal.match(/^-?P(\d+)D/i);
if (dayMatch) minutes = parseInt(dayMatch[1]) * 1440;
}
}
return { method: action.toString().toLowerCase() === 'email' ? 'email' : 'display', minutes };
});
}
const attendeeProps = vevent.getAllProperties('attendee');
if (attendeeProps && attendeeProps.length > 0) {
result.attendees = attendeeProps.map((prop: any) => {
const val = prop.getFirstValue() || '';
const email = val.replace(/^mailto:/i, '');
const cn = prop.getParameter('cn');
const partstat = prop.getParameter('partstat');
const statusMap: Record<string, string> = {
'ACCEPTED': 'accepted', 'DECLINED': 'declined', 'TENTATIVE': 'tentative', 'NEEDS-ACTION': 'needsAction',
};
return { email, displayName: cn || undefined, responseStatus: statusMap[partstat?.toUpperCase()] || 'needsAction' };
});
}
const attachProps = vevent.getAllProperties('attach');
if (attachProps && attachProps.length > 0) {
result.attachments = attachProps
.map((prop: any) => {
const val = prop.getFirstValue();
if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) {
return { url: val, title: prop.getParameter('filename') || undefined };
}
return null;
})
.filter(Boolean) as Array<{ url: string; title?: string }>;
if (result.attachments.length === 0) delete result.attachments;
}
const transp = vevent.getFirstPropertyValue('transp');
if (transp) result.busyStatus = transp.toString().toUpperCase() === 'TRANSPARENT' ? 'free' : 'busy';
const cls = vevent.getFirstPropertyValue('class');
if (cls) {
const clsMap: Record<string, string> = { 'PUBLIC': 'public', 'PRIVATE': 'private', 'CONFIDENTIAL': 'confidential' };
result.visibility = (clsMap[cls.toString().toUpperCase()] || 'default') as any;
}
return result;
}
export interface SynologyCalendar {
id: string;
title: string;
@ -214,6 +308,7 @@ export const getUpcomingEvents = async (
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: exEvent.uid,
isRecurring: true,
...extractExtendedProps(exVevent),
});
}
});
@ -243,6 +338,7 @@ export const getUpcomingEvents = async (
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: event.uid,
isRecurring: true,
...extractExtendedProps(vevent),
});
}
} catch (expandErr: any) {
@ -263,6 +359,7 @@ export const getUpcomingEvents = async (
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
...extractExtendedProps(vevent),
});
}
});
@ -301,6 +398,11 @@ export const createEvent = async (
recurrence?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
): Promise<SynologyCalendarEvent> => {
try {
@ -353,6 +455,39 @@ export const createEvent = async (
}
}
// Generate VALARM blocks for reminders
let valarmLines = '';
if (eventData.reminders && eventData.reminders.length > 0) {
for (const reminder of eventData.reminders) {
const action = reminder.method === 'email' ? 'EMAIL' : 'DISPLAY';
const dur = reminderMinutesToDuration(reminder.minutes);
valarmLines += `BEGIN:VALARM\r\nACTION:${action}\r\nTRIGGER:${dur}\r\n`;
if (action === 'DISPLAY') valarmLines += `DESCRIPTION:Reminder\r\n`;
valarmLines += `END:VALARM\r\n`;
}
}
let attendeeLines = '';
if (eventData.attendees && eventData.attendees.length > 0) {
for (const att of eventData.attendees) {
const cn = att.displayName ? `;CN=${att.displayName}` : '';
attendeeLines += `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION${cn}:mailto:${att.email}\r\n`;
}
}
let attachLines = '';
if (eventData.attachments && eventData.attachments.length > 0) {
for (const att of eventData.attachments) {
attachLines += `ATTACH:${att.url}\r\n`;
}
}
const transpLine = eventData.busyStatus === 'free' ? 'TRANSP:TRANSPARENT\r\n' : eventData.busyStatus ? 'TRANSP:OPAQUE\r\n' : '';
let classLine = '';
if (eventData.visibility && eventData.visibility !== 'default') {
classLine = `CLASS:${eventData.visibility.toUpperCase()}\r\n`;
}
const iCalString = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//My Weekly ToDo List//EN
@ -362,7 +497,7 @@ DTSTAMP:${dtStamp}
DTSTART${dtStartParam}:${dtStart}
DTEND${dtEndParam}:${dtEnd}
SUMMARY:${eventData.title}
${description}${location}${url}${rruleLine}END:VEVENT
${description}${location}${url}${rruleLine}${transpLine}${classLine}${attendeeLines}${attachLines}${valarmLines}END:VEVENT
END:VCALENDAR`;
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
@ -418,6 +553,11 @@ export const updateEvent = async (
url?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
reminders?: Array<{ method: string; minutes: number }>;
attendees?: Array<{ email: string; displayName?: string }>;
attachments?: Array<{ url: string; title?: string }>;
busyStatus?: string;
visibility?: string;
}
): Promise<SynologyCalendarEvent> => {
try {
@ -503,6 +643,53 @@ export const updateEvent = async (
}
}
// Update reminders (VALARM)
if (eventData.reminders !== undefined) {
const existingAlarms = vevent.getAllSubcomponents('valarm');
existingAlarms.forEach((a: any) => vevent.removeSubcomponent(a));
for (const reminder of eventData.reminders) {
const valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', reminder.method === 'email' ? 'EMAIL' : 'DISPLAY');
const dur = ICAL.Duration.fromString(reminderMinutesToDuration(reminder.minutes));
valarm.addPropertyWithValue('trigger', dur);
if (reminder.method !== 'email') valarm.addPropertyWithValue('description', 'Reminder');
vevent.addSubcomponent(valarm);
}
}
if (eventData.attendees !== undefined) {
vevent.removeAllProperties('attendee');
for (const att of eventData.attendees) {
const prop = new ICAL.Property('attendee');
prop.setValue(`mailto:${att.email}`);
prop.setParameter('cutype', 'INDIVIDUAL');
prop.setParameter('role', 'REQ-PARTICIPANT');
prop.setParameter('partstat', 'NEEDS-ACTION');
if (att.displayName) prop.setParameter('cn', att.displayName);
vevent.addProperty(prop);
}
}
if (eventData.attachments !== undefined) {
vevent.removeAllProperties('attach');
for (const att of eventData.attachments) {
vevent.addPropertyWithValue('attach', att.url);
}
}
if (eventData.busyStatus !== undefined) {
if (eventData.busyStatus === 'free') vevent.updatePropertyWithValue('transp', 'TRANSPARENT');
else if (eventData.busyStatus) vevent.updatePropertyWithValue('transp', 'OPAQUE');
}
if (eventData.visibility !== undefined) {
if (eventData.visibility && eventData.visibility !== 'default') {
vevent.updatePropertyWithValue('class', eventData.visibility.toUpperCase());
} else {
vevent.removeProperty('class');
}
}
event.sequence = (event.sequence || 0) + 1;
if (vevent) {
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());