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",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -98,9 +98,9 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { calendarId, eventId, title, description, start, end, location, allDay, recurrence, recurrenceEndDate, recurrenceCount, recurrenceInterval, recurrenceDays, url,
|
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) {
|
if (!calendarId || !eventId) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
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 });
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
start,
|
start,
|
||||||
|
|||||||
@ -159,26 +159,9 @@ export default function CalendarEventModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildSavePayload = (editMode?: string) => {
|
||||||
if (!title.trim()) {
|
|
||||||
setError('Title is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!calendarId) {
|
|
||||||
setError('Please select a calendar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (endDate <= startDate) {
|
|
||||||
setError('End time must be after start time');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const activeReminders = reminders.filter(r => r.minutes >= 0);
|
const activeReminders = reminders.filter(r => r.minutes >= 0);
|
||||||
|
return {
|
||||||
await onSave({
|
|
||||||
id: event?.id,
|
id: event?.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -200,17 +183,46 @@ export default function CalendarEventModal({
|
|||||||
visibility: visibility !== 'default' ? visibility : undefined,
|
visibility: visibility !== 'default' ? visibility : undefined,
|
||||||
attendees: attendees.length > 0 ? attendees : undefined,
|
attendees: attendees.length > 0 ? attendees : undefined,
|
||||||
attachments: attachments.length > 0 ? attachments : 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;
|
||||||
|
}
|
||||||
|
if (!calendarId) {
|
||||||
|
setError('Please select a calendar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
setError('End time must be after start time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing recurring events, show options first
|
||||||
|
if (event?.id && event?.isRecurring && !editMode && !showRecurringEditOptions) {
|
||||||
|
setShowRecurringEditOptions(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await onSave(buildSavePayload(editMode));
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError(err.message || 'Failed to save event');
|
setError(err.message || 'Failed to save event');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
setShowRecurringEditOptions(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
|
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
|
||||||
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
|
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
|
||||||
|
const [showRecurringEditOptions, setShowRecurringEditOptions] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async (mode?: string) => {
|
const handleDelete = async (mode?: string) => {
|
||||||
if (!event?.id || !onDelete) return;
|
if (!event?.id || !onDelete) return;
|
||||||
@ -836,9 +848,10 @@ export default function CalendarEventModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!showRecurringEditOptions ? (
|
||||||
<button
|
<button
|
||||||
className="weekly-btn-primary"
|
className="weekly-btn-primary"
|
||||||
onClick={handleSubmit}
|
onClick={() => handleSubmit()}
|
||||||
disabled={isSaving || isDeleting}
|
disabled={isSaving || isDeleting}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px', borderRadius: '6px', fontSize: '0.85rem',
|
padding: '6px 16px', borderRadius: '6px', fontSize: '0.85rem',
|
||||||
@ -849,6 +862,38 @@ export default function CalendarEventModal({
|
|||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2237,6 +2237,17 @@ export default function WeeklyView() {
|
|||||||
hasMoved?: boolean;
|
hasMoved?: boolean;
|
||||||
} | null>(null);
|
} | 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
|
// Dark Mode Persistence & Class Toggle
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@ -3805,7 +3816,21 @@ export default function WeeklyView() {
|
|||||||
? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime }
|
? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime }
|
||||||
: ev
|
: ev
|
||||||
));
|
));
|
||||||
// Save to server
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Non-recurring: save immediately
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/calendar/events", {
|
const res = await fetch("/api/calendar/events", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@ -3818,7 +3843,6 @@ export default function WeeklyView() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Revert on failure
|
|
||||||
setRawCalendarEvents(prev => prev.map(ev =>
|
setRawCalendarEvents(prev => prev.map(ev =>
|
||||||
ev.id === eventDragState.eventId
|
ev.id === eventDragState.eventId
|
||||||
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime }
|
||||||
@ -3833,6 +3857,7 @@ export default function WeeklyView() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setEventDragState(null);
|
setEventDragState(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -3842,7 +3867,48 @@ export default function WeeklyView() {
|
|||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
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
|
// Get all-day events for a specific date
|
||||||
const getAllDayEventsForDate = useCallback(
|
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 */}
|
{/* Focus Mode Overlay */}
|
||||||
{
|
{
|
||||||
showFocusMode && (
|
showFocusMode && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user