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:
mARTin 2026-03-25 01:56:16 +01:00
parent 131f92bd72
commit 08959c6ddb
6 changed files with 148 additions and 16 deletions

View File

@ -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": {

View File

@ -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);

View File

@ -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);
}; };

View File

@ -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}

View File

@ -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,

View File

@ -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) {