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:
parent
e00834e0bb
commit
ba4f1ca280
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user