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",
|
||||
"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",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -2942,6 +2942,11 @@ h3 {
|
||||
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 {
|
||||
/* Subtle drop indicator */
|
||||
background: rgba(0, 154, 154, 0.04);
|
||||
|
||||
@ -39,6 +39,7 @@ interface CalendarEventModalProps {
|
||||
event?: any;
|
||||
initialDate?: Date;
|
||||
initialStartTime?: string;
|
||||
initialEndTime?: string;
|
||||
connections: any[];
|
||||
weekStartDay?: number; // 0=Sunday, 1=Monday
|
||||
language?: string;
|
||||
@ -51,6 +52,7 @@ export default function CalendarEventModal({
|
||||
event,
|
||||
initialDate,
|
||||
initialStartTime,
|
||||
initialEndTime,
|
||||
connections,
|
||||
weekStartDay = 0,
|
||||
language = 'en',
|
||||
@ -126,6 +128,12 @@ export default function CalendarEventModal({
|
||||
const getInitialEnd = () => {
|
||||
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
||||
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();
|
||||
return new Date(start.getTime() + 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
@ -2220,8 +2220,24 @@ export default function WeeklyView() {
|
||||
event?: CalendarEvent;
|
||||
initialDate?: Date;
|
||||
initialStartTime?: string;
|
||||
initialEndTime?: string;
|
||||
}>({ 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
|
||||
const [eventDragState, setEventDragState] = useState<{
|
||||
eventId: string;
|
||||
@ -3873,6 +3889,77 @@ export default function WeeklyView() {
|
||||
};
|
||||
}, [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)
|
||||
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
||||
if (!pendingRecurringDrag) return;
|
||||
@ -3967,16 +4054,17 @@ export default function WeeklyView() {
|
||||
const rollOverdueTasks = useCallback(
|
||||
async (currentTasks: Task[]) => {
|
||||
const autoRolling = profile.autoRolling ?? false;
|
||||
if (!autoRolling) return;
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = formatDateToISO(now);
|
||||
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(
|
||||
(t) =>
|
||||
!t.completed &&
|
||||
t.isRolling &&
|
||||
(t.isRolling || autoRolling) &&
|
||||
t.scheduledDate &&
|
||||
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
|
||||
);
|
||||
@ -4084,8 +4172,10 @@ export default function WeeklyView() {
|
||||
const rollingRanRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (rollingRanRef.current) return;
|
||||
if (!profile.autoRolling) 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;
|
||||
rollOverdueTasks(tasks);
|
||||
}, [profile.autoRolling, tasks, rollOverdueTasks]);
|
||||
@ -7683,6 +7773,7 @@ export default function WeeklyView() {
|
||||
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
|
||||
|
||||
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
|
||||
|
||||
// Alt+Click to Create Calendar Event
|
||||
@ -7724,16 +7815,38 @@ export default function WeeklyView() {
|
||||
dropPreview?.day === date.getDay() &&
|
||||
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 (
|
||||
<div
|
||||
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={{
|
||||
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
||||
position: "relative",
|
||||
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
|
||||
}}
|
||||
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) =>
|
||||
!isProtected && !isOccupiedByTask &&
|
||||
handleDragOver(e, date.getDay(), slot)
|
||||
@ -9162,6 +9275,7 @@ export default function WeeklyView() {
|
||||
event={calendarEventModal.event}
|
||||
initialDate={calendarEventModal.initialDate}
|
||||
initialStartTime={calendarEventModal.initialStartTime}
|
||||
initialEndTime={calendarEventModal.initialEndTime}
|
||||
connections={connections}
|
||||
weekStartDay={weekStartDay}
|
||||
language={language}
|
||||
|
||||
@ -116,7 +116,7 @@ function toOutlookRecurrence(recurrence?: string, startDate?: Date, recurrenceEn
|
||||
const days = recurrenceDays && recurrenceDays.length > 0
|
||||
? recurrenceDays.map(d => dayNames[d])
|
||||
: [dayNames[start.getDay()]];
|
||||
return { pattern: { type: 'weekly', interval, daysOfWeek: days }, range };
|
||||
return { pattern: { type: 'weekly', interval, daysOfWeek: days, firstDayOfWeek: 'monday' }, range };
|
||||
}
|
||||
case 'monthly':
|
||||
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 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, {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
@ -903,7 +905,7 @@ export const createCalendarEvent = async (
|
||||
end: { ...event.end, timeZone: outlookTz },
|
||||
location: event.location,
|
||||
allDay: event.allDay,
|
||||
recurrence: toOutlookRecurrence(event.recurrence, startDate, event.recurrenceEndDate, event.recurrenceCount, event.recurrenceInterval, event.recurrenceDays),
|
||||
recurrence: outlookRecurrence,
|
||||
reminders: event.reminders,
|
||||
busyStatus: event.busyStatus,
|
||||
visibility: event.visibility,
|
||||
|
||||
@ -288,14 +288,7 @@ export const createEvent = async (
|
||||
calendarId: string,
|
||||
event: any
|
||||
) => {
|
||||
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({
|
||||
const requestBody = {
|
||||
subject: event.summary,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
@ -324,7 +317,17 @@ export const createEvent = async (
|
||||
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user