feat: drag-to-create calendar events, fix rolling tasks, fix Outlook recurring
- Drag on empty time slots to create calendar events with pre-filled time range - Rolling tasks now work per-task without requiring global autoRolling setting - Add firstDayOfWeek to Outlook weekly recurrence patterns - Add debug logging for Outlook recurrence creation v1.72.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
131f92bd72
commit
08959c6ddb
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.71.3",
|
"version": "1.72.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": {
|
||||||
|
|||||||
@ -2942,6 +2942,11 @@ h3 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-slot.slot-drag-selected {
|
||||||
|
background: rgba(66, 133, 244, 0.15);
|
||||||
|
border-left: 3px solid rgba(66, 133, 244, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
.time-slot.drop-target:hover {
|
.time-slot.drop-target:hover {
|
||||||
/* Subtle drop indicator */
|
/* Subtle drop indicator */
|
||||||
background: rgba(0, 154, 154, 0.04);
|
background: rgba(0, 154, 154, 0.04);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ interface CalendarEventModalProps {
|
|||||||
event?: any;
|
event?: any;
|
||||||
initialDate?: Date;
|
initialDate?: Date;
|
||||||
initialStartTime?: string;
|
initialStartTime?: string;
|
||||||
|
initialEndTime?: string;
|
||||||
connections: any[];
|
connections: any[];
|
||||||
weekStartDay?: number; // 0=Sunday, 1=Monday
|
weekStartDay?: number; // 0=Sunday, 1=Monday
|
||||||
language?: string;
|
language?: string;
|
||||||
@ -51,6 +52,7 @@ export default function CalendarEventModal({
|
|||||||
event,
|
event,
|
||||||
initialDate,
|
initialDate,
|
||||||
initialStartTime,
|
initialStartTime,
|
||||||
|
initialEndTime,
|
||||||
connections,
|
connections,
|
||||||
weekStartDay = 0,
|
weekStartDay = 0,
|
||||||
language = 'en',
|
language = 'en',
|
||||||
@ -126,6 +128,12 @@ export default function CalendarEventModal({
|
|||||||
const getInitialEnd = () => {
|
const getInitialEnd = () => {
|
||||||
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
||||||
if (event?.endTime) return new Date(event.endTime);
|
if (event?.endTime) return new Date(event.endTime);
|
||||||
|
if (initialEndTime && initialDate) {
|
||||||
|
const d = new Date(initialDate);
|
||||||
|
const [h, m] = initialEndTime.split(':').map(Number);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
const start = getInitialStart();
|
const start = getInitialStart();
|
||||||
return new Date(start.getTime() + 60 * 60 * 1000);
|
return new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2220,8 +2220,24 @@ export default function WeeklyView() {
|
|||||||
event?: CalendarEvent;
|
event?: CalendarEvent;
|
||||||
initialDate?: Date;
|
initialDate?: Date;
|
||||||
initialStartTime?: string;
|
initialStartTime?: string;
|
||||||
|
initialEndTime?: string;
|
||||||
}>({ isOpen: false });
|
}>({ isOpen: false });
|
||||||
|
|
||||||
|
// Slot drag-to-create calendar event state
|
||||||
|
const slotDragJustEndedRef = useRef(false);
|
||||||
|
const slotDragRef = useRef<{
|
||||||
|
active: boolean;
|
||||||
|
date: Date;
|
||||||
|
startSlot: string;
|
||||||
|
currentSlot: string;
|
||||||
|
startY: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [slotDragSelection, setSlotDragSelection] = useState<{
|
||||||
|
dateStr: string;
|
||||||
|
startSlot: string;
|
||||||
|
endSlot: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Calendar event resize/drag state
|
// Calendar event resize/drag state
|
||||||
const [eventDragState, setEventDragState] = useState<{
|
const [eventDragState, setEventDragState] = useState<{
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@ -3873,6 +3889,77 @@ export default function WeeklyView() {
|
|||||||
};
|
};
|
||||||
}, [eventDragState, effectiveCellDuration, calendarEvents]);
|
}, [eventDragState, effectiveCellDuration, calendarEvents]);
|
||||||
|
|
||||||
|
// Slot drag-to-create: mousemove + mouseup on document (always active, ref-gated)
|
||||||
|
const effectiveCellDurationRef = useRef(effectiveCellDuration);
|
||||||
|
effectiveCellDurationRef.current = effectiveCellDuration;
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const drag = slotDragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
// Require minimum movement to distinguish from click
|
||||||
|
if (!drag.active && Math.abs(e.clientY - drag.startY) < 5) return;
|
||||||
|
if (!drag.active) {
|
||||||
|
drag.active = true;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which slot the mouse is over
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const slotEl = el?.closest('[data-slot]') as HTMLElement | null;
|
||||||
|
if (slotEl) {
|
||||||
|
const slot = slotEl.getAttribute('data-slot');
|
||||||
|
if (slot) {
|
||||||
|
drag.currentSlot = slot;
|
||||||
|
const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`;
|
||||||
|
// Determine visual range (start <= end)
|
||||||
|
const startSlot = drag.startSlot <= slot ? drag.startSlot : slot;
|
||||||
|
const endSlot = drag.startSlot <= slot ? slot : drag.startSlot;
|
||||||
|
setSlotDragSelection({ dateStr, startSlot, endSlot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
const drag = slotDragRef.current;
|
||||||
|
slotDragRef.current = null;
|
||||||
|
if (!drag || !drag.active) {
|
||||||
|
setSlotDragSelection(null);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSlotDragSelection(null);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
// Suppress the click event that follows mouseup
|
||||||
|
slotDragJustEndedRef.current = true;
|
||||||
|
setTimeout(() => { slotDragJustEndedRef.current = false; }, 300);
|
||||||
|
|
||||||
|
// Calculate start and end times
|
||||||
|
const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot;
|
||||||
|
const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot;
|
||||||
|
|
||||||
|
// End time = endSlot + cellDuration
|
||||||
|
const [eh, em] = endSlot.split(':').map(Number);
|
||||||
|
const endMinutes = eh * 60 + em + effectiveCellDurationRef.current;
|
||||||
|
const endH = Math.floor(endMinutes / 60);
|
||||||
|
const endM = endMinutes % 60;
|
||||||
|
const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
setCalendarEventModal({
|
||||||
|
isOpen: true,
|
||||||
|
initialDate: drag.date,
|
||||||
|
initialStartTime: startSlot,
|
||||||
|
initialEndTime: endTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Handle recurring event drag confirm (this/all)
|
// Handle recurring event drag confirm (this/all)
|
||||||
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
||||||
if (!pendingRecurringDrag) return;
|
if (!pendingRecurringDrag) return;
|
||||||
@ -3967,16 +4054,17 @@ export default function WeeklyView() {
|
|||||||
const rollOverdueTasks = useCallback(
|
const rollOverdueTasks = useCallback(
|
||||||
async (currentTasks: Task[]) => {
|
async (currentTasks: Task[]) => {
|
||||||
const autoRolling = profile.autoRolling ?? false;
|
const autoRolling = profile.autoRolling ?? false;
|
||||||
if (!autoRolling) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = formatDateToISO(now);
|
const todayStr = formatDateToISO(now);
|
||||||
const today = new Date(todayStr);
|
const today = new Date(todayStr);
|
||||||
|
|
||||||
|
// Roll tasks that are explicitly marked as rolling (per-task flag),
|
||||||
|
// OR all incomplete overdue tasks if global autoRolling is enabled
|
||||||
const overdue = currentTasks.filter(
|
const overdue = currentTasks.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!t.completed &&
|
!t.completed &&
|
||||||
t.isRolling &&
|
(t.isRolling || autoRolling) &&
|
||||||
t.scheduledDate &&
|
t.scheduledDate &&
|
||||||
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
|
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
|
||||||
);
|
);
|
||||||
@ -4084,8 +4172,10 @@ export default function WeeklyView() {
|
|||||||
const rollingRanRef = useRef(false);
|
const rollingRanRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rollingRanRef.current) return;
|
if (rollingRanRef.current) return;
|
||||||
if (!profile.autoRolling) return;
|
|
||||||
if (tasks.length === 0) return;
|
if (tasks.length === 0) return;
|
||||||
|
// Run if global autoRolling is on, OR if any task has per-task isRolling enabled
|
||||||
|
const hasRollingTasks = tasks.some((t) => t.isRolling && !t.completed);
|
||||||
|
if (!profile.autoRolling && !hasRollingTasks) return;
|
||||||
rollingRanRef.current = true;
|
rollingRanRef.current = true;
|
||||||
rollOverdueTasks(tasks);
|
rollOverdueTasks(tasks);
|
||||||
}, [profile.autoRolling, tasks, rollOverdueTasks]);
|
}, [profile.autoRolling, tasks, rollOverdueTasks]);
|
||||||
@ -7683,6 +7773,7 @@ export default function WeeklyView() {
|
|||||||
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
|
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
|
||||||
|
|
||||||
const handleSlotClick = (e: React.MouseEvent) => {
|
const handleSlotClick = (e: React.MouseEvent) => {
|
||||||
|
if (slotDragJustEndedRef.current) return; // Suppress click after drag-to-create
|
||||||
if (isProtected || isOccupiedByAnyTask) return; // Don't allow adding tasks to protected or occupied slots
|
if (isProtected || isOccupiedByAnyTask) return; // Don't allow adding tasks to protected or occupied slots
|
||||||
|
|
||||||
// Alt+Click to Create Calendar Event
|
// Alt+Click to Create Calendar Event
|
||||||
@ -7724,16 +7815,38 @@ export default function WeeklyView() {
|
|||||||
dropPreview?.day === date.getDay() &&
|
dropPreview?.day === date.getDay() &&
|
||||||
dropPreview?.slot === slot;
|
dropPreview?.slot === slot;
|
||||||
|
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
const isInDragSelection = slotDragSelection &&
|
||||||
|
slotDragSelection.dateStr === dateStr &&
|
||||||
|
slot >= slotDragSelection.startSlot &&
|
||||||
|
slot <= slotDragSelection.endSlot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slot}
|
key={slot}
|
||||||
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""}`}
|
data-slot={slot}
|
||||||
|
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""} ${isInDragSelection ? "slot-drag-selected" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
|
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
|
||||||
}}
|
}}
|
||||||
onClick={handleSlotClick}
|
onClick={handleSlotClick}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Only start slot drag on empty slots, left button, no modifiers
|
||||||
|
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||||
|
if (isProtected || isOccupiedByAnyTask) return;
|
||||||
|
// Don't start drag if clicking on an event or task
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
|
||||||
|
slotDragRef.current = {
|
||||||
|
active: false,
|
||||||
|
date: new Date(date),
|
||||||
|
startSlot: slot,
|
||||||
|
currentSlot: slot,
|
||||||
|
startY: e.clientY,
|
||||||
|
};
|
||||||
|
}}
|
||||||
onDragOver={(e) =>
|
onDragOver={(e) =>
|
||||||
!isProtected && !isOccupiedByTask &&
|
!isProtected && !isOccupiedByTask &&
|
||||||
handleDragOver(e, date.getDay(), slot)
|
handleDragOver(e, date.getDay(), slot)
|
||||||
@ -9162,6 +9275,7 @@ export default function WeeklyView() {
|
|||||||
event={calendarEventModal.event}
|
event={calendarEventModal.event}
|
||||||
initialDate={calendarEventModal.initialDate}
|
initialDate={calendarEventModal.initialDate}
|
||||||
initialStartTime={calendarEventModal.initialStartTime}
|
initialStartTime={calendarEventModal.initialStartTime}
|
||||||
|
initialEndTime={calendarEventModal.initialEndTime}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
weekStartDay={weekStartDay}
|
weekStartDay={weekStartDay}
|
||||||
language={language}
|
language={language}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEn
|
|||||||
const days = recurrenceDays && recurrenceDays.length > 0
|
const days = recurrenceDays && recurrenceDays.length > 0
|
||||||
? recurrenceDays.map(d => dayNames[d])
|
? recurrenceDays.map(d => dayNames[d])
|
||||||
: [dayNames[start.getDay()]];
|
: [dayNames[start.getDay()]];
|
||||||
return { pattern: { type: 'weekly', interval, daysOfWeek: days }, range };
|
return { pattern: { type: 'weekly', interval, daysOfWeek: days, firstDayOfWeek: 'monday' }, range };
|
||||||
}
|
}
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
return { pattern: { type: 'absoluteMonthly', interval, dayOfMonth: start.getDate() }, range };
|
return { pattern: { type: 'absoluteMonthly', interval, dayOfMonth: start.getDate() }, range };
|
||||||
@ -896,6 +896,8 @@ export const createCalendarEvent = async (
|
|||||||
|
|
||||||
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
|
const startDate = event.start?.dateTime ? new Date(event.start.dateTime) : new Date();
|
||||||
const outlookTz = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const outlookTz = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
|
const outlookRecurrence = toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays);
|
||||||
|
console.log('[OUTLOOK] Creating event with recurrence:', JSON.stringify({ recurrence: event.recurrence, outlookRecurrence, outlookTz }));
|
||||||
const createdEvent = await createOutlookEvent(accessToken, calendarId, {
|
const createdEvent = await createOutlookEvent(accessToken, calendarId, {
|
||||||
summary: event.title,
|
summary: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
@ -903,7 +905,7 @@ export const createCalendarEvent = async (
|
|||||||
end: { ...event.end, timeZone: outlookTz },
|
end: { ...event.end, timeZone: outlookTz },
|
||||||
location: event.location,
|
location: event.location,
|
||||||
allDay: event.allDay,
|
allDay: event.allDay,
|
||||||
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
|
recurrence: outlookRecurrence,
|
||||||
reminders: event.reminders,
|
reminders: event.reminders,
|
||||||
busyStatus: event.busyStatus,
|
busyStatus: event.busyStatus,
|
||||||
visibility: event.visibility,
|
visibility: event.visibility,
|
||||||
|
|||||||
@ -288,14 +288,7 @@ export const createEvent = async (
|
|||||||
calendarId: string,
|
calendarId: string,
|
||||||
event: any
|
event: any
|
||||||
) => {
|
) => {
|
||||||
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events`, {
|
const requestBody = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Prefer': 'outlook.timezone="UTC"'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subject: event.summary,
|
subject: event.summary,
|
||||||
body: {
|
body: {
|
||||||
contentType: 'HTML',
|
contentType: 'HTML',
|
||||||
@ -324,7 +317,17 @@ export const createEvent = async (
|
|||||||
type: 'required',
|
type: 'required',
|
||||||
})),
|
})),
|
||||||
} : {}),
|
} : {}),
|
||||||
})
|
};
|
||||||
|
console.log('[OUTLOOK] createEvent request body:', JSON.stringify(requestBody, null, 2));
|
||||||
|
|
||||||
|
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'outlook.timezone="UTC"'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user