feat: overlapping calendar events side by side + Outlook delete fix

- Overlapping calendar events on the same time slot are now rendered
  side by side instead of stacked on top of each other. Uses a greedy
  column assignment algorithm to compute layout per day.
- Outlook delete/update: use /me/events/{id} as primary endpoint
  (without encodeURIComponent) instead of calendar-scoped endpoint,
  with fallback. Fixes ErrorItemNotFound for recurring series masters.

v1.65.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-24 11:06:00 +01:00
parent e00834e0bb
commit ba4f1ca280
3 changed files with 94 additions and 21 deletions

View File

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

@ -3592,6 +3592,79 @@ export default function WeeklyView() {
[calendarEvents, effectiveCellDuration], [calendarEvents, effectiveCellDuration],
); );
// Precompute overlap layout: { [eventId]: { column, totalColumns } }
const eventOverlapLayout = useMemo(() => {
const layout: Record<string, { column: number; totalColumns: number }> = {};
// Group events by day
const dayMap = new Map<string, CalendarEvent[]>();
for (const event of calendarEvents) {
if (isAllDayEvent(event)) continue;
const d = new Date(event.startTime);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (!dayMap.has(key)) dayMap.set(key, []);
dayMap.get(key)!.push(event);
}
for (const events of dayMap.values()) {
// Sort by start time, then by duration (longer first)
events.sort((a, b) => {
const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
if (diff !== 0) return diff;
return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) -
(new Date(a.endTime).getTime() - new Date(a.startTime).getTime());
});
// Build overlap groups using a greedy column assignment
const columns: { end: number; eventId: string }[][] = [];
for (const event of events) {
const start = new Date(event.startTime).getTime();
const end = new Date(event.endTime).getTime();
// Find first column where this event doesn't overlap
let placed = false;
for (let col = 0; col < columns.length; col++) {
const lastInCol = columns[col][columns[col].length - 1];
if (lastInCol.end <= start) {
columns[col].push({ end, eventId: event.id });
placed = true;
break;
}
}
if (!placed) {
columns.push([{ end, eventId: event.id }]);
}
}
// Now find the max columns each event actually shares with
// For each event, find all events that overlap it and determine the group width
for (const event of events) {
const start = new Date(event.startTime).getTime();
const end = new Date(event.endTime).getTime();
// Count how many columns have events overlapping this time range
let overlappingCols = 0;
for (const col of columns) {
for (const item of col) {
const itemStart = calendarEvents.find(e => e.id === item.eventId);
if (itemStart) {
const iStart = new Date(itemStart.startTime).getTime();
const iEnd = new Date(itemStart.endTime).getTime();
if (iStart < end && iEnd > start) {
overlappingCols++;
break;
}
}
}
}
// Find which column this event is in
let eventCol = 0;
for (let col = 0; col < columns.length; col++) {
if (columns[col].some(item => item.eventId === event.id)) {
eventCol = col;
break;
}
}
layout[event.id] = { column: eventCol, totalColumns: Math.max(overlappingCols, 1) };
}
}
return layout;
}, [calendarEvents]);
// Calculate event duration in pixels for proper height display // Calculate event duration in pixels for proper height display
const getEventDuration = (event: CalendarEvent): number => { const getEventDuration = (event: CalendarEvent): number => {
if (isAllDayEvent(event)) return 0; // All-day events handled separately if (isAllDayEvent(event)) return 0; // All-day events handled separately
@ -7495,6 +7568,10 @@ export default function WeeklyView() {
? eventColor ? eventColor
: "var(--weekly-teal)"; : "var(--weekly-teal)";
const overlap = eventOverlapLayout[event.id] || { column: 0, totalColumns: 1 };
const colWidth = 100 / overlap.totalColumns;
const colLeft = overlap.column * colWidth;
return ( return (
<div <div
key={event.id} key={event.id}
@ -7505,8 +7582,8 @@ export default function WeeklyView() {
minHeight: `15px`, minHeight: `15px`,
position: "absolute", position: "absolute",
top: `${topOffset}px`, top: `${topOffset}px`,
left: "1px", left: `calc(${colLeft}% + 1px)`,
right: "2px", width: `calc(${colWidth}% - 3px)`,
zIndex: 1, zIndex: 1,
flexDirection: "column", flexDirection: "column",
alignItems: "flex-start", alignItems: "flex-start",

View File

@ -377,14 +377,14 @@ export const updateEvent = async (
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
// Try calendar-scoped endpoint first // Try non-calendar-scoped endpoint first (more reliable for all ID types)
let response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
method: 'PATCH', headers: patchHeaders, body method: 'PATCH', headers: patchHeaders, body
}); });
if (!response.ok && response.status === 404) { if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try non-calendar-scoped endpoint // Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodeURIComponent(eventId)}`, { response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
method: 'PATCH', headers: patchHeaders, body method: 'PATCH', headers: patchHeaders, body
}); });
} }
@ -414,21 +414,17 @@ export const deleteEvent = async (
calendarId: string, calendarId: string,
eventId: string eventId: string
) => { ) => {
// Try calendar-scoped endpoint first const headers = { 'Authorization': `Bearer ${accessToken}` };
let response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE', // Try non-calendar-scoped endpoint first (more reliable for all ID types)
headers: { let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
'Authorization': `Bearer ${accessToken}` method: 'DELETE', headers
}
}); });
if (!response.ok && response.status === 404) { if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try non-calendar-scoped endpoint (works better for series master IDs) // Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodeURIComponent(eventId)}`, { response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events/${eventId}`, {
method: 'DELETE', method: 'DELETE', headers
headers: {
'Authorization': `Bearer ${accessToken}`
}
}); });
} }