diff --git a/package.json b/package.json index 5875fdb..c4e695a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/globals.css b/src/app/globals.css index 9b45f5b..0f6b38b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 35975f8..1f960f1 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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(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 (
{ 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 && ( +
{ + e.stopPropagation(); + handleEventDragStart(e, event, 'resize-top'); + }} + /> + )}
📅 @@ -7612,6 +7757,44 @@ export default function WeeklyView() {
{timeStr}
+ {(event.isRecurring || profile.showCalendarProviderIcon) && ( +
+ {event.isRecurring && } + {profile.showCalendarProviderIcon && ( + {event.source} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+ )} + {/* Bottom resize handle */} + {event.editable && ( +
{ + e.stopPropagation(); + handleEventDragStart(e, event, 'resize-bottom'); + }} + /> + )}
); })} diff --git a/src/lib/google-calendar.ts b/src/lib/google-calendar.ts index f6ed225..4750fd6 100644 --- a/src/lib/google-calendar.ts +++ b/src/lib/google-calendar.ts @@ -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'); } diff --git a/src/lib/outlook-calendar.ts b/src/lib/outlook-calendar.ts index e627f31..ffe703a 100644 --- a/src/lib/outlook-calendar.ts +++ b/src/lib/outlook-calendar.ts @@ -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}`);