feat: recurring event edit confirmation (this event / all events)

When editing a recurring calendar event (via modal or drag/resize),
the user is now asked whether changes apply to just this instance or
all events in the series.

- Modal: Save button shows "This event" / "All events" options for
  recurring events
- Drag/resize: centered overlay prompt with same options + cancel
- API PATCH: editMode=all routes update to the series master ID
  (recurringEventId) instead of the instance ID
- Cancel on drag prompt reverts to original position

v1.69.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 12:41:21 +01:00
parent ff3d458b6d
commit 74a8c9a9a9
4 changed files with 245 additions and 63 deletions

View File

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

@ -98,9 +98,9 @@ export async function PATCH(request: NextRequest) {
const body = await request.json();
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url,
reminders, busyStatus, visibility, attendees, attachments } = body;
reminders, busyStatus, visibility, attendees, attachments, editMode, recurringEventId } = body;
console.log('[API] Updating event:', { calendarId, eventId, title });
console.log('[API] Updating event:', { calendarId, eventId, title, editMode });
if (!calendarId || !eventId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
@ -113,7 +113,18 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
}
const event = await updateCalendarEvent(connection, calendarId, eventId, {
// For "all events" edit mode on recurring events, use the series master ID
let targetEventId = eventId;
if (editMode === 'all' && recurringEventId) {
// For Outlook composite IDs (seriesMasterId::instanceId), use just the seriesMasterId
if (recurringEventId.includes('::')) {
targetEventId = recurringEventId.split('::')[0];
} else {
targetEventId = recurringEventId;
}
}
const event = await updateCalendarEvent(connection, calendarId, targetEventId, {
title,
description,
start,

View File

@ -159,7 +159,35 @@ export default function CalendarEventModal({
}
};
const handleSubmit = async () => {
const buildSavePayload = (editMode?: string) => {
const activeReminders = reminders.filter(r => r.minutes >= 0);
return {
id: event?.id,
title,
description,
location,
url: url || undefined,
recurrence: recurrence === 'custom' ? customUnit.replace(/s$/, '') === 'day' ? 'daily' : customUnit.replace(/s$/, '') === 'week' ? 'weekly' : customUnit.replace(/s$/, '') === 'month' ? 'monthly' : 'yearly'
: recurrence !== 'none' ? recurrence : undefined,
recurrenceInterval: recurrence === 'custom' ? customInterval : undefined,
recurrenceDays: recurrence === 'custom' && customUnit === 'weeks' ? customDays : undefined,
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
calendarId,
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
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,
...(editMode ? { editMode, recurringEventId: event?.recurringEventId } : {}),
};
};
const handleSubmit = async (editMode?: string) => {
if (!title.trim()) {
setError('Title is required');
return;
@ -173,44 +201,28 @@ export default function CalendarEventModal({
return;
}
// For existing recurring events, show options first
if (event?.id && event?.isRecurring && !editMode && !showRecurringEditOptions) {
setShowRecurringEditOptions(true);
return;
}
setIsSaving(true);
setError('');
try {
const activeReminders = reminders.filter(r => r.minutes >= 0);
await onSave({
id: event?.id,
title,
description,
location,
url: url || undefined,
recurrence: recurrence === 'custom' ? customUnit.replace(/s$/, '') === 'day' ? 'daily' : customUnit.replace(/s$/, '') === 'week' ? 'weekly' : customUnit.replace(/s$/, '') === 'month' ? 'monthly' : 'yearly'
: recurrence !== 'none' ? recurrence : undefined,
recurrenceInterval: recurrence === 'custom' ? customInterval : undefined,
recurrenceDays: recurrence === 'custom' && customUnit === 'weeks' ? customDays : undefined,
recurrenceEndDate: recurrence !== 'none' && recurrenceEndType === 'date' && recurrenceEndDateValue ? recurrenceEndDateValue : undefined,
recurrenceCount: recurrence !== 'none' && recurrenceEndType === 'count' && recurrenceCount > 0 ? recurrenceCount : undefined,
calendarId,
allDay,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
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,
});
await onSave(buildSavePayload(editMode));
onClose();
} catch (err: any) {
console.error(err);
setError(err.message || 'Failed to save event');
setIsSaving(false);
setShowRecurringEditOptions(false);
}
};
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
const [showRecurringEditOptions, setShowRecurringEditOptions] = useState(false);
const handleDelete = async (mode?: string) => {
if (!event?.id || !onDelete) return;
@ -836,19 +848,52 @@ export default function CalendarEventModal({
Cancel
</button>
)}
<button
className="weekly-btn-primary"
onClick={handleSubmit}
disabled={isSaving || isDeleting}
style={{
padding: '6px 16px', borderRadius: '6px', fontSize: '0.85rem',
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
border: 'none', cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1
}}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
{!showRecurringEditOptions ? (
<button
className="weekly-btn-primary"
onClick={() => handleSubmit()}
disabled={isSaving || isDeleting}
style={{
padding: '6px 16px', borderRadius: '6px', fontSize: '0.85rem',
fontWeight: 600, backgroundColor: '#3b82f6', color: 'white',
border: 'none', cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1
}}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
) : (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)', marginRight: '2px' }}>Save:</span>
{[
{ mode: 'this', label: 'This event' },
{ mode: 'all', label: 'All events' },
].map(({ mode, label }) => (
<button
key={mode}
onClick={() => handleSubmit(mode)}
disabled={isSaving}
style={{
padding: '4px 10px', fontSize: '0.78rem', fontWeight: 600,
color: 'white', background: mode === 'this' ? '#3b82f6' : '#6366f1',
border: 'none', borderRadius: '5px',
cursor: 'pointer', opacity: isSaving ? 0.5 : 1,
}}
>
{isSaving ? '...' : label}
</button>
))}
<button
onClick={() => setShowRecurringEditOptions(false)}
style={{
padding: '4px 6px', fontSize: '0.75rem', color: 'var(--weekly-text-light)',
background: 'none', border: 'none', cursor: 'pointer',
}}
>
<X size={14} />
</button>
</div>
)}
</div>
</div>
</div>

View File

@ -2237,6 +2237,17 @@ export default function WeeklyView() {
hasMoved?: boolean;
} | null>(null);
// Pending recurring event edit after drag/resize — asks "this" or "all"
const [pendingRecurringDrag, setPendingRecurringDrag] = useState<{
eventId: string;
calendarId: string;
recurringEventId?: string;
startTime: string;
endTime: string;
originalStartTime: string;
originalEndTime: string;
} | null>(null);
// Dark Mode Persistence & Class Toggle
const [mounted, setMounted] = useState(false);
@ -3805,32 +3816,46 @@ export default function WeeklyView() {
? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime }
: ev
));
// Save to server
try {
const res = await fetch("/api/calendar/events", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
calendarId: eventDragState.calendarId,
eventId: eventDragState.eventId,
start: { dateTime: eventDragState.currentStartTime },
end: { dateTime: eventDragState.currentEndTime },
}),
// Check if this is a recurring event — if so, ask before saving
const draggedEvent = calendarEvents.find(e => e.id === eventDragState.eventId);
if (draggedEvent?.isRecurring) {
setPendingRecurringDrag({
eventId: eventDragState.eventId,
calendarId: eventDragState.calendarId || '',
recurringEventId: draggedEvent.recurringEventId,
startTime: eventDragState.currentStartTime,
endTime: eventDragState.currentEndTime,
originalStartTime: eventDragState.originalStartTime,
originalEndTime: eventDragState.originalEndTime,
});
if (!res.ok) {
// Revert on failure
} else {
// Non-recurring: save immediately
try {
const res = await fetch("/api/calendar/events", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
calendarId: eventDragState.calendarId,
eventId: eventDragState.eventId,
start: { dateTime: eventDragState.currentStartTime },
end: { dateTime: eventDragState.currentEndTime },
}),
});
if (!res.ok) {
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventDragState.eventId
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
: ev
));
}
} catch {
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventDragState.eventId
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
: ev
));
}
} catch {
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventDragState.eventId
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
: ev
));
}
}
setEventDragState(null);
@ -3842,7 +3867,48 @@ export default function WeeklyView() {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [eventDragState, effectiveCellDuration]);
}, [eventDragState, effectiveCellDuration, calendarEvents]);
// Handle recurring event drag confirm (this/all)
const handleRecurringDragConfirm = async (editMode: 'this' | 'all') => {
if (!pendingRecurringDrag) return;
const { eventId, calendarId, recurringEventId, startTime, endTime, originalStartTime, originalEndTime } = pendingRecurringDrag;
try {
const res = await fetch("/api/calendar/events", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
calendarId,
eventId,
start: { dateTime: startTime },
end: { dateTime: endTime },
editMode,
recurringEventId,
}),
});
if (!res.ok) {
// Revert
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
));
}
} catch {
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
));
}
setPendingRecurringDrag(null);
};
const handleRecurringDragCancel = () => {
if (!pendingRecurringDrag) return;
// Revert to original times
const { eventId, originalStartTime, originalEndTime } = pendingRecurringDrag;
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev
));
setPendingRecurringDrag(null);
};
// Get all-day events for a specific date
const getAllDayEventsForDate = useCallback(
@ -9095,6 +9161,66 @@ export default function WeeklyView() {
/>
)
}
{/* Recurring event drag/resize confirmation */}
{pendingRecurringDrag && (
<div style={{
position: 'fixed', inset: 0, zIndex: 2000,
display: 'flex', justifyContent: 'center', alignItems: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(2px)',
}}
onClick={handleRecurringDragCancel}
>
<div
style={{
background: darkMode ? '#1e1e1e' : 'white',
color: darkMode ? '#eee' : '#333',
borderRadius: 12, padding: '20px 24px',
boxShadow: '0 8px 30px rgba(0,0,0,0.25)',
minWidth: 260, textAlign: 'center',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>
{language === 'de' ? 'Wiederkehrendes Ereignis bearbeiten' : 'Edit recurring event'}
</div>
<div style={{ fontSize: '0.78rem', opacity: 0.7, marginBottom: 16 }}>
{language === 'de' ? 'Änderung anwenden auf:' : 'Apply change to:'}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button
onClick={() => handleRecurringDragConfirm('this')}
style={{
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600,
color: 'white', background: '#3b82f6',
border: 'none', borderRadius: 8, cursor: 'pointer',
}}
>
{language === 'de' ? 'Nur dieses' : 'This event'}
</button>
<button
onClick={() => handleRecurringDragConfirm('all')}
style={{
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600,
color: 'white', background: '#6366f1',
border: 'none', borderRadius: 8, cursor: 'pointer',
}}
>
{language === 'de' ? 'Alle Ereignisse' : 'All events'}
</button>
<button
onClick={handleRecurringDragCancel}
style={{
padding: '8px 16px', fontSize: '0.82rem', fontWeight: 500,
color: darkMode ? '#aaa' : '#666', background: darkMode ? '#333' : '#f0f0f0',
border: 'none', borderRadius: 8, cursor: 'pointer',
}}
>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
</div>
</div>
</div>
)}
{/* Focus Mode Overlay */}
{
showFocusMode && (