feat: recurring event delete options (this/future/all instances)

When deleting a recurring calendar event, users now choose between
deleting just this instance, this and future instances, or all instances.
Supports Google, Apple (iCloud), and Synology CalDAV providers.

v1.62.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-23 22:06:23 +01:00
parent 1011aef639
commit 57eeb3d8e8
8 changed files with 395 additions and 31 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.61.1",
"version": "1.62.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

@ -156,8 +156,9 @@ export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const calendarId = searchParams.get('calendarId');
const eventId = searchParams.get('eventId');
const deleteMode = searchParams.get('deleteMode') || 'all';
console.log('[API] Deleting event:', { calendarId, eventId });
console.log('[API] Deleting event:', { calendarId, eventId, deleteMode });
if (!calendarId || !eventId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
@ -170,11 +171,14 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
}
await deleteCalendarEvent(connection, calendarId, eventId);
await deleteCalendarEvent(connection, calendarId, eventId, deleteMode);
// Remove from cache - await to ensure consistency
try {
await deleteCachedEvent(userId, eventId, connection.provider);
if (deleteMode === 'all') {
await deleteCachedEvent(userId, eventId, connection.provider);
}
// For 'this' and 'future', force a full cache refresh
} catch (e) {
console.error('[CACHE] Failed to delete cached event:', e);
}

View File

@ -42,7 +42,7 @@ interface CalendarEventModalProps {
connections: any[];
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
}
export default function CalendarEventModal({
@ -210,11 +210,19 @@ export default function CalendarEventModal({
};
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
const handleDelete = async () => {
const handleDelete = async (mode?: string) => {
if (!event?.id || !onDelete) return;
if (!isDeleteConfirming) {
// For recurring events, show options first
if (event.isRecurring && !mode && !showRecurringDeleteOptions) {
setShowRecurringDeleteOptions(true);
return;
}
// For non-recurring events, use confirm flow
if (!event.isRecurring && !isDeleteConfirming) {
setIsDeleteConfirming(true);
setTimeout(() => setIsDeleteConfirming(false), 3000);
return;
@ -222,12 +230,13 @@ export default function CalendarEventModal({
setIsDeleting(true);
try {
await onDelete(event.id, event.calendarId);
await onDelete(event.id, event.calendarId, mode || 'all');
onClose();
} catch (err: any) {
setError(err.message || 'Failed to delete event');
setIsDeleting(false);
setIsDeleteConfirming(false);
setShowRecurringDeleteOptions(false);
}
};
@ -765,10 +774,10 @@ export default function CalendarEventModal({
{/* Actions */}
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--weekly-border)', paddingTop: '10px' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{event && onDelete && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
{event && onDelete && !showRecurringDeleteOptions && (
<button
onClick={handleDelete}
onClick={() => handleDelete()}
disabled={isSaving || isDeleting}
style={{
padding: '6px 10px', background: 'none', color: '#ef4444',
@ -780,6 +789,39 @@ export default function CalendarEventModal({
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
</button>
)}
{event && onDelete && showRecurringDeleteOptions && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)', marginRight: '2px' }}>Delete:</span>
{[
{ mode: 'this', label: 'This event' },
{ mode: 'future', label: 'This & future' },
{ mode: 'all', label: 'All events' },
].map(({ mode, label }) => (
<button
key={mode}
onClick={() => handleDelete(mode)}
disabled={isDeleting}
style={{
padding: '4px 8px', fontSize: '0.75rem', fontWeight: 500,
color: '#ef4444', background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.2)', borderRadius: '5px',
cursor: 'pointer', opacity: isDeleting ? 0.5 : 1,
}}
>
{label}
</button>
))}
<button
onClick={() => setShowRecurringDeleteOptions(false)}
style={{
padding: '4px 6px', fontSize: '0.75rem', color: 'var(--weekly-text-light)',
background: 'none', border: 'none', cursor: 'pointer',
}}
>
<X size={14} />
</button>
</div>
)}
{!event && (
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
Cancel

View File

@ -168,6 +168,7 @@ interface CalendarEvent {
calendarTitle?: string;
calendarColor?: string;
editable?: boolean;
recurringEventId?: string;
}
interface SomedayList {
@ -2614,10 +2615,13 @@ export default function WeeklyView() {
}
};
const handleEventDelete = async (eventId: string, calendarId: string) => {
const handleEventDelete = async (eventId: string, calendarId: string, deleteMode?: string) => {
try {
const params = new URLSearchParams({ calendarId, eventId });
if (deleteMode) params.set('deleteMode', deleteMode);
const res = await fetch(
`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`,
`/api/calendar/events?${params.toString()}`,
{
method: "DELETE",
},
@ -2628,8 +2632,30 @@ export default function WeeklyView() {
throw new Error(err.error || "Failed to delete event");
}
// Optimistically remove, then re-read cache
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
// Optimistically remove affected events
if (deleteMode === 'this') {
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
} else if (deleteMode === 'future') {
// Remove this and future instances of the same recurring series
const targetEvent = calendarEvents.find(e => e.id === eventId);
if (targetEvent) {
const targetTime = new Date(targetEvent.startTime).getTime();
const seriesId = targetEvent.recurringEventId || eventId;
setRawCalendarEvents(prev => prev.filter(e => {
if (e.recurringEventId !== seriesId && e.id !== seriesId) return true;
return new Date(e.startTime).getTime() < targetTime;
}));
} else {
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
}
} else {
// 'all' — remove all instances of the series
const targetEvent = calendarEvents.find(e => e.id === eventId);
const seriesId = targetEvent?.recurringEventId || eventId;
setRawCalendarEvents(prev => prev.filter(e =>
e.id !== eventId && e.recurringEventId !== seriesId && e.id !== seriesId
));
}
setTimeout(() => fetchCalendarEvents(false), 2000);
} catch (error) {
console.error("Error deleting event:", error);

View File

@ -808,6 +808,114 @@ function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | nu
return null;
}
/**
* Delete a specific instance or future instances of a recurring CalDAV event.
* 'this' mode: adds EXDATE to exclude the specific occurrence.
* 'future' mode: modifies RRULE to add UNTIL just before the target occurrence.
*/
export const deleteRecurringInstance = async (
email: string,
appSpecificPassword: string,
calendarUrl: string,
eventId: string,
deleteMode: string
): Promise<void> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
// Parse the caldav ID to get objectUrl and occurrence date
const parts = eventId.split('::');
if (parts.length < 4 || parts[0] !== 'caldav') {
throw new Error('Cannot determine occurrence from event ID format');
}
const objectUrl = parts[1];
const occurrenceISO = parts[3]; // e.g. "2026-03-30T12:40:00.000Z"
// Fetch the calendar object
const objects = await client.fetchCalendarObjects({
calendar: { url: calendarUrl } as any,
objectUrls: [objectUrl],
});
const targetObject = objects?.[0];
if (!targetObject?.data) {
throw new Error('Event not found on server');
}
// Parse iCal data
const jcal = ICAL.parse(targetObject.data);
const comp = new ICAL.Component(jcal);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) {
throw new Error('No VEVENT found in calendar object');
}
const occDate = new Date(occurrenceISO);
if (deleteMode === 'this') {
// Add EXDATE to exclude this specific occurrence
const dtstart = vevent.getFirstProperty('dtstart');
const isAllDay = dtstart?.getParameter('value') === 'DATE';
if (isAllDay) {
const exDate = ICAL.Time.fromJSDate(occDate, true);
exDate.isDate = true;
const prop = new ICAL.Property('exdate');
prop.setParameter('value', 'DATE');
prop.setValue(exDate);
vevent.addProperty(prop);
} else {
// Use the same timezone as DTSTART if available
const tzid = dtstart?.getParameter('tzid');
const exDate = ICAL.Time.fromJSDate(occDate, false);
const prop = new ICAL.Property('exdate');
if (tzid) {
prop.setParameter('tzid', tzid);
}
prop.setValue(exDate);
vevent.addProperty(prop);
}
console.log('[APPLE CALENDAR] Added EXDATE for occurrence:', occurrenceISO);
} else if (deleteMode === 'future') {
// Modify RRULE to end just before this occurrence
const rruleProp = vevent.getFirstProperty('rrule');
if (!rruleProp) {
throw new Error('No RRULE found on event');
}
const rrule = rruleProp.getFirstValue() as any;
// Set UNTIL to one second before the occurrence
const untilDate = new Date(occDate.getTime() - 1000);
const until = ICAL.Time.fromJSDate(untilDate, true);
// Remove COUNT if present, set UNTIL
delete rrule.count;
rrule.until = until;
// Write modified RRULE back
rruleProp.setValue(rrule);
console.log('[APPLE CALENDAR] Modified RRULE UNTIL for future delete:', untilDate.toISOString());
}
// PUT the modified object back
const updatedData = comp.toString();
await client.updateObject({
url: objectUrl,
data: updatedData,
etag: targetObject.etag,
} as any);
console.log('[APPLE CALENDAR] Recurring instance delete complete (mode:', deleteMode, ')');
} catch (error) {
console.error('[APPLE CALENDAR] Error deleting recurring instance:', error);
throw error;
}
};
export const deleteEvent = async (
email: string,
appSpecificPassword: string,

View File

@ -1271,10 +1271,10 @@ export const updateCalendarEvent = async (
export const deleteCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
eventId: string
eventId: string,
deleteMode: string = 'all'
): Promise<void> => {
if (connection.provider === 'google') {
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
@ -1288,12 +1288,51 @@ export const deleteCalendarEvent = async (
process.env.GOOGLE_REDIRECT_URI || ''
);
// Default to deleting the whole series if repeating
const baseEventId = eventId.split('_')[0];
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
);
if (deleteMode === 'this') {
// Delete the specific instance (use full instance ID with _suffix)
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, eventId)
);
} else if (deleteMode === 'future') {
// Set UNTIL on the series to end before this instance
const instanceDate = eventId.split('_')[1]; // e.g. "20260401T114000Z"
if (instanceDate) {
const baseEventId = eventId.split('_')[0];
// Get the base event, modify its recurrence UNTIL
const { getEvent, updateEvent } = await import('./google-calendar');
try {
const baseEvent = await getEvent(oauth2Client, accessToken, calendarId, baseEventId);
if (baseEvent?.recurrence) {
const updatedRecurrence = baseEvent.recurrence.map((rule: string) => {
if (rule.startsWith('RRULE:')) {
// Remove existing UNTIL/COUNT, add new UNTIL
const cleaned = rule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
return `${cleaned};UNTIL=${instanceDate.replace(/[-:]/g, '')}`;
}
return rule;
});
await updateEvent(oauth2Client, accessToken, calendarId, baseEventId, { recurrence: updatedRecurrence });
}
} catch (e) {
console.error('[DELETE] Failed to modify Google series for future delete, falling back to full delete:', e);
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
);
}
} else {
// Can't determine instance date, delete all
const baseEventId = eventId.split('_')[0];
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
);
}
} else {
// 'all' — delete the whole series
const baseEventId = eventId.split('_')[0];
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
);
}
return;
} else if (connection.provider === 'outlook') {
let accessToken = connection.accessToken;
@ -1303,17 +1342,23 @@ export const deleteCalendarEvent = async (
else throw new Error('Failed to refresh token');
}
// Extract base series ID for Outlook
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
// Outlook: for simplicity, always delete the series for now
// (Outlook instance deletion requires fetching specific occurrence IDs)
await deleteOutlookEvent(accessToken, calendarId, baseEventId);
return;
} else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':');
await import('./apple-calendar').then(m =>
m.deleteEvent(email, appPassword, calendarId, eventId)
);
if (deleteMode === 'this' || deleteMode === 'future') {
await import('./apple-calendar').then(m =>
m.deleteRecurringInstance(email, appPassword, calendarId, eventId, deleteMode)
);
} else {
await import('./apple-calendar').then(m =>
m.deleteEvent(email, appPassword, calendarId, eventId)
);
}
return;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
@ -1321,9 +1366,15 @@ export const deleteCalendarEvent = async (
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
await import('./synology-calendar').then(m =>
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
);
if (deleteMode === 'this' || deleteMode === 'future') {
await import('./synology-calendar').then(m =>
m.deleteRecurringInstance(serverUrl, username, password, calendarId, eventId, deleteMode)
);
} else {
await import('./synology-calendar').then(m =>
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
);
}
return;
} else if (connection.provider === 'notion') {
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');

View File

@ -26,6 +26,7 @@ export interface GoogleCalendarEvent {
};
transparency?: string; // 'opaque' | 'transparent'
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
recurrence?: string[];
}
export interface GoogleCalendar {
@ -194,6 +195,29 @@ export const updateEvent = async (
}
};
/**
* Get a single event by ID
*/
export const getEvent = async (
oauth2Client: any,
accessToken: string,
calendarId: string,
eventId: string
): Promise<GoogleCalendarEvent | null> => {
oauth2Client.setCredentials({ access_token: accessToken });
try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.get({
calendarId,
eventId,
});
return response.data as any;
} catch (error) {
console.error('Error getting event:', error);
return null;
}
};
/**
* Delete an event
*/

View File

@ -765,6 +765,115 @@ export const updateEvent = async (
/**
* Delete an event
*/
/**
* Delete a specific instance or future instances of a recurring CalDAV event.
* 'this' mode: adds EXDATE to exclude the specific occurrence.
* 'future' mode: modifies RRULE to add UNTIL just before the target occurrence.
*/
export const deleteRecurringInstance = async (
serverUrl: string,
username: string,
password: string,
calendarUrl: string,
eventId: string,
deleteMode: string
): Promise<void> => {
try {
const client = createClient(serverUrl, username, password);
await loginClient(client, serverUrl, username);
// Parse the caldav ID to get objectUrl and occurrence date
const parts = eventId.split('::');
if (parts.length < 4 || parts[0] !== 'caldav') {
throw new Error('Cannot determine occurrence from event ID format');
}
const objectUrl = parts[1];
const occurrenceISO = parts[3]; // e.g. "2026-03-30T12:40:00.000Z"
// Fetch the calendar object
const objects = await client.fetchCalendarObjects({
calendar: { url: calendarUrl } as any,
objectUrls: [objectUrl],
});
const targetObject = objects?.[0];
if (!targetObject?.data) {
throw new Error('Event not found on server');
}
// Parse iCal data
const jcal = ICAL.parse(targetObject.data);
const comp = new ICAL.Component(jcal);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) {
throw new Error('No VEVENT found in calendar object');
}
const occDate = new Date(occurrenceISO);
if (deleteMode === 'this') {
// Add EXDATE to exclude this specific occurrence
const dtstart = vevent.getFirstProperty('dtstart');
const isAllDay = dtstart?.getParameter('value') === 'DATE';
if (isAllDay) {
const exDate = ICAL.Time.fromJSDate(occDate, true);
exDate.isDate = true;
const prop = new ICAL.Property('exdate');
prop.setParameter('value', 'DATE');
prop.setValue(exDate);
vevent.addProperty(prop);
} else {
// Use the same timezone as DTSTART if available
const tzid = dtstart?.getParameter('tzid');
const exDate = ICAL.Time.fromJSDate(occDate, false);
const prop = new ICAL.Property('exdate');
if (tzid) {
prop.setParameter('tzid', tzid);
}
prop.setValue(exDate);
vevent.addProperty(prop);
}
console.log('[SYNOLOGY CALENDAR] Added EXDATE for occurrence:', occurrenceISO);
} else if (deleteMode === 'future') {
// Modify RRULE to end just before this occurrence
const rruleProp = vevent.getFirstProperty('rrule');
if (!rruleProp) {
throw new Error('No RRULE found on event');
}
const rrule = rruleProp.getFirstValue() as any;
// Set UNTIL to one second before the occurrence
const untilDate = new Date(occDate.getTime() - 1000);
const until = ICAL.Time.fromJSDate(untilDate, true);
// Remove COUNT if present, set UNTIL
delete rrule.count;
rrule.until = until;
// Write modified RRULE back
rruleProp.setValue(rrule);
console.log('[SYNOLOGY CALENDAR] Modified RRULE UNTIL for future delete:', untilDate.toISOString());
}
// PUT the modified object back
const updatedData = comp.toString();
await client.updateObject({
url: objectUrl,
data: updatedData,
etag: targetObject.etag,
} as any);
console.log('[SYNOLOGY CALENDAR] Recurring instance delete complete (mode:', deleteMode, ')');
} catch (error) {
console.error('[SYNOLOGY CALENDAR] Error deleting recurring instance:', error);
throw error;
}
};
export const deleteEvent = async (
serverUrl: string,
username: string,