feat: drag-to-move and resize handles for calendar events

Add resize handles (top/bottom) on hover to change event duration, and
drag-to-move support with live preview and 5-minute snapping. Includes
3px dead zone to distinguish clicks from drags, optimistic UI updates
with server rollback on failure, and recurring/provider icons on
time-grid events. Also fixes Google delete 410 Gone and Outlook delete
404/encodeURIComponent issues.

v1.66.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 11:35:01 +01:00
parent c040dd982d
commit 13de53fe0a
5 changed files with 260 additions and 19 deletions

View File

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

@ -3059,6 +3059,47 @@ h3 {
background: rgba(0, 154, 154, 0.15);
}
/* Event resize handles */
.event-resize-handle {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
}
.event-resize-handle::after {
content: '';
width: 10px;
height: 4px;
border-radius: 2px;
background: currentColor;
opacity: 0.6;
}
.event-resize-handle-top {
top: 0;
}
.event-resize-handle-bottom {
bottom: 0;
}
.time-slot-event:hover .event-resize-handle {
opacity: 1;
}
.time-slot-event.event-dragging {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Make day columns scrollable when using time grid */
.time-grid-wrapper .weekly-days-grid {
flex: 1;

View File

@ -2202,6 +2202,20 @@ export default function WeeklyView() {
initialStartTime?: string;
}>({ isOpen: false });
// Calendar event resize/drag state
const [eventDragState, setEventDragState] = useState<{
eventId: string;
mode: 'move' | 'resize-top' | 'resize-bottom';
startY: number;
originalStartTime: string;
originalEndTime: string;
currentStartTime: string;
currentEndTime: string;
calendarId?: string;
source?: string;
hasMoved?: boolean;
} | null>(null);
// Dark Mode Persistence & Class Toggle
const [mounted, setMounted] = useState(false);
@ -2232,6 +2246,7 @@ export default function WeeklyView() {
const profileLoadedRef = useRef(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const dragJustEndedRef = useRef(false);
const fetchProfile = async () => {
try {
@ -3686,6 +3701,114 @@ export default function WeeklyView() {
);
};
// Snap minutes to nearest 5-minute increment
const snapMinutes = (mins: number) => Math.round(mins / 5) * 5;
// Handle calendar event drag/resize
const handleEventDragStart = (e: React.MouseEvent, event: CalendarEvent, mode: 'move' | 'resize-top' | 'resize-bottom') => {
if (!event.editable) return;
e.preventDefault();
e.stopPropagation();
setEventDragState({
eventId: event.id,
mode,
startY: e.clientY,
originalStartTime: event.startTime,
originalEndTime: event.endTime,
currentStartTime: event.startTime,
currentEndTime: event.endTime,
calendarId: event.calendarId,
source: event.source,
});
};
useEffect(() => {
if (!eventDragState) return;
const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
const handleMouseMove = (e: MouseEvent) => {
const deltaY = e.clientY - eventDragState.startY;
// Require minimum 3px movement to start actual drag
if (!eventDragState.hasMoved && Math.abs(deltaY) < 3) return;
if (!eventDragState.hasMoved) {
setEventDragState(prev => prev ? { ...prev, hasMoved: true } : null);
}
const deltaMinutes = snapMinutes(deltaY / pixelsPerMinute);
const origStart = new Date(eventDragState.originalStartTime);
const origEnd = new Date(eventDragState.originalEndTime);
if (eventDragState.mode === 'move') {
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000);
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000);
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString(), currentEndTime: newEnd.toISOString() } : null);
} else if (eventDragState.mode === 'resize-bottom') {
const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000);
if (newEnd.getTime() > origStart.getTime() + 5 * 60000) {
setEventDragState(prev => prev ? { ...prev, currentEndTime: newEnd.toISOString() } : null);
}
} else if (eventDragState.mode === 'resize-top') {
const newStart = new Date(origStart.getTime() + deltaMinutes * 60000);
if (newStart.getTime() < origEnd.getTime() - 5 * 60000) {
setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString() } : null);
}
}
};
const handleMouseUp = async () => {
if (!eventDragState) return;
const startChanged = eventDragState.currentStartTime !== eventDragState.originalStartTime;
const endChanged = eventDragState.currentEndTime !== eventDragState.originalEndTime;
if (eventDragState.hasMoved && (startChanged || endChanged)) {
// Optimistically update the UI
setRawCalendarEvents(prev => prev.map(ev =>
ev.id === eventDragState.eventId
? { ...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 },
}),
});
if (!res.ok) {
// Revert on failure
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
));
}
}
const didMove = eventDragState.hasMoved;
setEventDragState(null);
if (didMove) {
dragJustEndedRef.current = true;
setTimeout(() => { dragJustEndedRef.current = false; }, 200);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [eventDragState, effectiveCellDuration]);
// Get all-day events for a specific date
const getAllDayEventsForDate = useCallback(
(date: Date): CalendarEvent[] => {
@ -7540,10 +7663,15 @@ export default function WeeklyView() {
)}
{/* Calendar Events in time slot */}
{slotEvents.map((event) => {
const eventHeight = getEventDuration(event);
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
const timeStr = `${startTime.getHours().toString().padStart(2, "0")}:${startTime.getMinutes().toString().padStart(2, "0")} - ${endTime.getHours().toString().padStart(2, "0")}:${endTime.getMinutes().toString().padStart(2, "0")}`;
// During drag, use the drag state's current times for position/height
const isDragging = eventDragState?.eventId === event.id && eventDragState?.hasMoved;
const displayStartTime = isDragging ? new Date(eventDragState!.currentStartTime) : new Date(event.startTime);
const displayEndTime = isDragging ? new Date(eventDragState!.currentEndTime) : new Date(event.endTime);
const durationMs = displayEndTime.getTime() - displayStartTime.getTime();
const durationMinutes = durationMs / (1000 * 60);
const pxPerMin = getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
const eventHeight = Math.max(durationMinutes * pxPerMin, 15);
const timeStr = `${displayStartTime.getHours().toString().padStart(2, "0")}:${displayStartTime.getMinutes().toString().padStart(2, "0")} - ${displayEndTime.getHours().toString().padStart(2, "0")}:${displayEndTime.getMinutes().toString().padStart(2, "0")}`;
// Calculate offset within the slot based on event start time
const [slotHour, slotMinute] = slot
@ -7551,13 +7679,11 @@ export default function WeeklyView() {
.map(Number);
const slotStartMinutes = slotHour * 60 + slotMinute;
const eventStartMinutes =
startTime.getHours() * 60 +
startTime.getMinutes();
displayStartTime.getHours() * 60 +
displayStartTime.getMinutes();
const offsetMinutes =
eventStartMinutes - slotStartMinutes;
const pixelsPerMinute =
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
const topOffset = offsetMinutes * pixelsPerMinute;
const topOffset = offsetMinutes * pxPerMin;
// Convert hex to rgba for background, or use default
const eventColor = event.calendarColor || "#009a9a";
@ -7575,16 +7701,16 @@ export default function WeeklyView() {
return (
<div
key={event.id}
className="time-slot-event"
className={`time-slot-event${isDragging ? ' event-dragging' : ''}`}
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
style={{
height: `${Math.max(eventHeight, 15)}px`,
height: `${eventHeight}px`,
minHeight: `15px`,
position: "absolute",
top: `${topOffset}px`,
left: `calc(${colLeft}% + 1px)`,
width: `calc(${colWidth}% - 3px)`,
zIndex: 1,
zIndex: isDragging ? 50 : 1,
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "flex-start",
@ -7592,11 +7718,15 @@ export default function WeeklyView() {
borderLeftColor: borderColor,
color: borderColor,
cursor: event.editable
? "pointer"
? (isDragging ? "grabbing" : "grab")
: "default",
opacity: isDragging && eventDragState?.mode === 'move' ? 0.7 : 1,
transition: isDragging ? 'none' : undefined,
userSelect: 'none',
}}
onClick={(e) => {
e.stopPropagation();
if (dragJustEndedRef.current) return; // Don't open modal after drag
if (event.editable) {
setCalendarEventModal({
isOpen: true,
@ -7604,7 +7734,22 @@ export default function WeeklyView() {
});
}
}}
onMouseDown={(e) => {
if (event.editable && e.button === 0) {
handleEventDragStart(e, event, 'move');
}
}}
>
{/* Top resize handle */}
{event.editable && (
<div
className="event-resize-handle event-resize-handle-top"
onMouseDown={(e) => {
e.stopPropagation();
handleEventDragStart(e, event, 'resize-top');
}}
/>
)}
<div className="event-title-row">
<span className="event-indicator">📅</span>
<span className="event-title">
@ -7612,6 +7757,44 @@ export default function WeeklyView() {
</span>
</div>
<div className="event-time-row">{timeStr}</div>
{(event.isRecurring || profile.showCalendarProviderIcon) && (
<div style={{
position: "absolute",
bottom: 2,
right: 4,
display: "flex",
alignItems: "center",
gap: 3,
opacity: 0.5,
pointerEvents: "none",
}}>
{event.isRecurring && <Repeat size={11} />}
{profile.showCalendarProviderIcon && (
<img
src={
event.source === "google" ? "/icons/google-calendar.svg"
: event.source === "apple" ? "/icons/apple-calendar.svg"
: event.source === "outlook" ? "/icons/outlook-calendar.svg"
: event.source === "synology" ? "/icons/synology-calendar.svg"
: undefined
}
alt={event.source}
style={{ width: 13, height: 13 }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
)}
{/* Bottom resize handle */}
{event.editable && (
<div
className="event-resize-handle event-resize-handle-bottom"
onMouseDown={(e) => {
e.stopPropagation();
handleEventDragStart(e, event, 'resize-bottom');
}}
/>
)}
</div>
);
})}

View File

@ -234,7 +234,12 @@ export const deleteEvent = async (
calendarId,
eventId,
});
} catch (error) {
} catch (error: any) {
// 410 Gone means already deleted — treat as success
if (error?.code === 410 || error?.status === 410) {
console.log('[GOOGLE] Event already deleted (410 Gone), treating as success');
return;
}
console.error('Error deleting event:', error);
throw new Error('Failed to delete event');
}

View File

@ -377,14 +377,17 @@ export const updateEvent = async (
'Content-Type': 'application/json'
};
const encodedEventId = encodeURIComponent(eventId);
const encodedCalendarId = encodeURIComponent(calendarId);
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
method: 'PATCH', headers: patchHeaders, body
});
if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
method: 'PATCH', headers: patchHeaders, body
});
}
@ -415,19 +418,28 @@ export const deleteEvent = async (
eventId: string
) => {
const headers = { 'Authorization': `Bearer ${accessToken}` };
// Encode the event ID for URL safety (base64 padding = may need encoding)
const encodedEventId = encodeURIComponent(eventId);
const encodedCalendarId = encodeURIComponent(calendarId);
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
method: 'DELETE', headers
});
if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
method: 'DELETE', headers
});
}
// 404/410 on delete = already gone, treat as success
if (response.status === 404 || response.status === 410) {
console.log('[OUTLOOK] Event already deleted or not found, treating as success');
return;
}
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete Outlook event: ${err}`);