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:
parent
ff3d458b6d
commit
74a8c9a9a9
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user