From 42905641cd3ef2248dd4b38821b24c84fecdaf57 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 3 May 2026 19:53:09 +0200 Subject: [PATCH] feat: bidirectional Outlook/MS To-Do sync, busyStatus + star, list-delete dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outlook recurring delete: also delete the specific instance after the master to clean up orphaned first occurrences. - CachedCalendarEvent gains busyStatus column (migration applied) so showAs changes from Outlook flow back into the weekly view. - Outlook create/update now round-trip the full event (busyStatus, visibility, attendees, reminders, recurrence link) via a shared response mapper. - MS To-Do importance ('high' star) ⇄ local importance flag in pull-sync, push-sync, initial import, and local-task POST/PATCH; dueDateTime ⇄ scheduledDate added to pull-sync. - Local task PATCH now fires a best-effort push to Outlook/Google/Synology so any field change keeps both sides aligned. - AnyDay list delete dialog now offers Cancel / hide locally / delete on both sides; new deleteMsTodoList + deleteGoogleTaskList helpers. v1.103.0 Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 4 +- package.json | 2 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/someday-lists/route.ts | 31 ++++- src/app/api/tasks/import/route.ts | 6 +- src/app/api/tasks/route.ts | 87 +++++++++++++- src/app/api/tasks/sync/route.ts | 29 ++++- src/components/CalendarEventModal.tsx | 2 +- src/components/WeeklyView.tsx | 85 +++++++++++--- src/lib/calendar-cache.ts | 4 + src/lib/calendar-events.ts | 26 ++++- src/lib/google-tasks.ts | 16 +++ src/lib/microsoft-todo.ts | 32 ++++++ src/lib/outlook-calendar.ts | 108 +++++++----------- 15 files changed, 342 insertions(+), 93 deletions(-) create mode 100644 prisma/migrations/20260503_add_busy_status_and_outlook_importance/migration.sql diff --git a/package-lock.json b/package-lock.json index 0356378..37c4eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.102.0", + "version": "1.103.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.102.0", + "version": "1.103.0", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index ce04262..cdc9a22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.102.0", + "version": "1.103.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": { diff --git a/prisma/migrations/20260503_add_busy_status_and_outlook_importance/migration.sql b/prisma/migrations/20260503_add_busy_status_and_outlook_importance/migration.sql new file mode 100644 index 0000000..d6ba09c --- /dev/null +++ b/prisma/migrations/20260503_add_busy_status_and_outlook_importance/migration.sql @@ -0,0 +1,2 @@ +-- CachedCalendarEvent: store busy/free/tentative/oof status for incoming Outlook/Google sync +ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "busyStatus" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17df18a..6a0085a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,7 @@ model CachedCalendarEvent { recurringEventId String? isRecurring Boolean @default(false) reminders Json? + busyStatus String? connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/someday-lists/route.ts b/src/app/api/someday-lists/route.ts index 8f4e191..6590da0 100644 --- a/src/app/api/someday-lists/route.ts +++ b/src/app/api/someday-lists/route.ts @@ -121,6 +121,8 @@ export async function DELETE(request: NextRequest) { const id = searchParams.get('id'); // tasksOnly=true: soft-disconnect (keep list record with tab, just remove tasks + external link) const tasksOnly = searchParams.get('tasksOnly') === 'true'; + // deleteExternal=true: also remove the list from the connected provider (Outlook/Google) + const deleteExternal = searchParams.get('deleteExternal') === 'true'; if (!id) { return NextResponse.json( @@ -141,6 +143,33 @@ export async function DELETE(request: NextRequest) { ); } + // If asked to remove the list from the external provider too, do so first + // (so a Graph/API failure doesn't orphan the local state). + let externalDeleteFailed = false; + if (deleteExternal && list.externalId && list.externalProvider) { + try { + if (list.externalProvider === 'outlook') { + const { getOutlookAccessToken } = await import('@/lib/outlook-token'); + const { deleteMsTodoList } = await import('@/lib/microsoft-todo'); + const token = await getOutlookAccessToken(userId); + if (token) await deleteMsTodoList(token, list.externalId); + } else if (list.externalProvider === 'google') { + const { createGoogleClient, deleteGoogleTaskList } = await import('@/lib/google-tasks'); + const account = await prisma.account.findFirst({ + where: { userId, provider: { in: ['google-calendar', 'google'] } } + }); + if (account?.access_token) { + const client = createGoogleClient(account.access_token, account.refresh_token || undefined); + await deleteGoogleTaskList(client, list.externalId); + } + } + // Note: Synology task list deletion not yet implemented at the provider level. + } catch (extErr) { + console.error('Failed to delete external list, proceeding with local cleanup:', extErr); + externalDeleteFailed = true; + } + } + // Soft-delete tasks in this list (they can be recovered from trash) await prisma.task.updateMany({ where: { somedayListId: id }, @@ -162,7 +191,7 @@ export async function DELETE(request: NextRequest) { }); notifyUser(userId, "list-changed", { action: "deleted" }); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, externalDeleteFailed }); } catch (error) { console.error('Error deleting someday list:', error); return NextResponse.json( diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index 28a6916..e389ed8 100644 --- a/src/app/api/tasks/import/route.ts +++ b/src/app/api/tasks/import/route.ts @@ -21,6 +21,7 @@ interface ImportedTask { status: string; sourceListTitle: string; parentExternalId?: string; + important?: boolean; } export async function POST(req: NextRequest) { @@ -126,6 +127,7 @@ export async function POST(req: NextRequest) { dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null, status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted', sourceListTitle: sourceList.title, + important: t.importance === 'high', }); // Fetch checklist items as sub-tasks @@ -293,6 +295,7 @@ export async function POST(req: NextRequest) { somedayListId: somedayList.id, lastSyncedAt: new Date(), deletedAt: null, // clear soft-delete from a previous disconnect + ...(task.important !== undefined ? { importance: task.important ? true : existingTask.importance } : {}), } }); externalToLocalId.set(task.externalId, existingTask.id); @@ -311,7 +314,8 @@ export async function POST(req: NextRequest) { externalId: task.externalId, externalProvider: provider, externalListId: task.externalListId, - lastSyncedAt: new Date() + lastSyncedAt: new Date(), + ...(task.important ? { importance: true } : {}), } }); externalToLocalId.set(task.externalId, newTask.id); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 8d18ca7..baa4a85 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -24,6 +24,71 @@ const generateVirtualId = (originalId: string, dateStr: string) => { return `virtual-${originalId}-${dateStr}`; }; +/** + * Push a single task field-update to its external provider (Outlook / Google / Synology). + * Best-effort: fire-and-forget from the caller, which logs errors. + */ +async function pushTaskToExternal( + task: Task, + fields: { title?: string; notes?: string; completed?: boolean; scheduledDate?: string | null; importance?: boolean | null } +): Promise { + if (!task.externalProvider || !task.externalId || !task.externalListId) return; + + if (task.externalProvider === 'outlook') { + const { getOutlookAccessToken } = await import('@/lib/outlook-token'); + const { updateMsTodoTask } = await import('@/lib/microsoft-todo'); + const token = await getOutlookAccessToken(task.userId); + if (!token) return; + const updates: any = {}; + if (fields.title !== undefined) updates.title = fields.title; + if (fields.notes !== undefined) updates.body = fields.notes ?? ''; + if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'notStarted'; + if (fields.scheduledDate !== undefined) { + updates.dueDateTime = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null; + } + if (fields.importance !== undefined) updates.importance = fields.importance ? 'high' : 'normal'; + if (Object.keys(updates).length > 0) { + await updateMsTodoTask(token, task.externalListId, task.externalId, updates); + } + } else if (task.externalProvider === 'google') { + const { createGoogleClient, updateGoogleTask } = await import('@/lib/google-tasks'); + const account = await prisma.account.findFirst({ + where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } } + }); + if (!account?.access_token) return; + const client = createGoogleClient(account.access_token, account.refresh_token || undefined); + const updates: any = {}; + if (fields.title !== undefined) updates.title = fields.title; + if (fields.notes !== undefined) updates.notes = fields.notes ?? ''; + if (fields.completed !== undefined) updates.status = fields.completed ? 'completed' : 'needsAction'; + if (fields.scheduledDate !== undefined) { + updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null; + } + // Google Tasks API has no native importance/star — silently ignored. + if (Object.keys(updates).length > 0) { + await updateGoogleTask(client, task.externalListId, task.externalId, updates); + } + } else if (task.externalProvider === 'synology') { + const { updateSynologyTask } = await import('@/lib/synology-tasks'); + const conn = await prisma.calendarConnection.findFirst({ + where: { userId: task.userId, provider: 'synology' } + }); + if (!conn?.accessToken || !conn?.refreshToken) return; + const [user, pw] = conn.accessToken.split(':'); + if (!user || !pw) return; + const updates: any = {}; + if (fields.title !== undefined) updates.title = fields.title; + if (fields.notes !== undefined) updates.notes = fields.notes ?? ''; + if (fields.completed !== undefined) updates.completed = fields.completed; + if (fields.scheduledDate !== undefined) { + updates.due = fields.scheduledDate ? new Date(fields.scheduledDate).toISOString() : null; + } + if (Object.keys(updates).length > 0) { + await updateSynologyTask(conn.refreshToken, user, pw, task.externalListId, task.externalId, updates); + } + } +} + // Max virtual instances generated per recurring series, keyed by recurrence unit. // Caps pathological cases (e.g. a daily task with 90-day horizon = 90 instances). const MAX_INSTANCES_PER_SERIES: Record = { @@ -308,7 +373,12 @@ export async function POST(request: NextRequest) { const { getOutlookAccessToken } = await import('@/lib/outlook-token'); const accessToken = await getOutlookAccessToken(userId); if (accessToken) { - const msTask = await createMsTodoTask(accessToken, somedayList.externalId, { title }); + const msTask = await createMsTodoTask(accessToken, somedayList.externalId, { + title, + body: description || undefined, + dueDateTime: scheduledDate || undefined, + importance: importance ? 'high' : undefined, + }); externalId = msTask.id; externalProvider = 'outlook'; externalListId = somedayList.externalId; @@ -509,6 +579,21 @@ export async function PATCH(request: NextRequest) { }, }); + // Push changes to external provider (fire-and-forget) when this task is linked + if (task.externalProvider && task.externalId && task.externalListId) { + const pushFields: Record = {}; + if (title !== undefined) pushFields.title = title; + if (description !== undefined) pushFields.notes = description; + if (completed !== undefined) pushFields.completed = completed; + if (scheduledDate !== undefined) pushFields.scheduledDate = scheduledDate; + if (importance !== undefined) pushFields.importance = importance; + if (Object.keys(pushFields).length > 0) { + pushTaskToExternal(task, pushFields).catch(e => + console.error('[TASK-SYNC] external push failed:', e) + ); + } + } + // NOTE: We REMOVED the "create next task on completion" logic block here. // Why? Because the projection system handles "next tasks" automatically. // If we kept it, completing a task would create a duplicate materialized task for the next date, diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index c64d135..c9116c3 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -315,6 +315,23 @@ export async function GET(req: NextRequest) { updateData.description = remoteNotes; } + // Outlook To-Do star ⇄ local importance flag + const remoteImportant = remote.importance === 'high'; + if (remoteImportant !== (localTask.importance === true)) { + updateData.importance = remoteImportant; + } + + // Outlook dueDateTime ⇄ local scheduledDate + const remoteDue = remote.dueDateTime?.dateTime + ? new Date(remote.dueDateTime.dateTime) + : null; + const localDue = localTask.scheduledDate ? new Date(localTask.scheduledDate) : null; + const remoteDueMs = remoteDue?.getTime() ?? null; + const localDueMs = localDue?.getTime() ?? null; + if (remoteDueMs !== localDueMs) { + updateData.scheduledDate = remoteDue; + } + if (Object.keys(updateData).length > 1) { await prisma.task.update({ where: { id: localTask.id }, @@ -343,6 +360,10 @@ export async function GET(req: NextRequest) { title: remote.title, description: remote.body?.content || null, completed: isMsTodoTaskCompleted(remote.status), + importance: remote.importance === 'high' ? true : null, + scheduledDate: remote.dueDateTime?.dateTime + ? new Date(remote.dueDateTime.dateTime) + : null, somedayListId: somedayListInfo.id, externalId: remote.id, externalProvider: 'outlook', @@ -484,7 +505,7 @@ export async function PATCH(req: NextRequest) { } const body = await req.json(); - const { taskId, completed, title, action, scheduledDate, notes } = body; + const { taskId, completed, title, action, scheduledDate, notes, importance } = body; if (!taskId) { return NextResponse.json({ error: 'Task ID required' }, { status: 400 }); @@ -538,13 +559,16 @@ export async function PATCH(req: NextRequest) { if (action === 'delete') { await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId); } else { - const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {}; + const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; importance?: 'low' | 'normal' | 'high' } = {}; if (title !== undefined) updates.title = title; if (notes !== undefined) updates.body = notes; if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted'; if (scheduledDate !== undefined) { updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null; } + if (importance !== undefined) { + updates.importance = importance ? 'high' : 'normal'; + } if (Object.keys(updates).length > 0) { await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates); } @@ -658,6 +682,7 @@ export async function POST(req: NextRequest) { title: task.title, body: task.description || undefined, dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined, + importance: task.importance ? 'high' : undefined, }); const updatedTask = await prisma.task.update({ diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index 931ae53..9934afc 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -234,7 +234,7 @@ export default function CalendarEventModal({ end: { dateTime: endDate.toISOString() }, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, reminders: activeReminders.length > 0 ? activeReminders : undefined, - busyStatus: busyStatus !== 'busy' ? busyStatus : undefined, + busyStatus: busyStatus, visibility: visibility !== 'default' ? visibility : undefined, attendees: attendees.length > 0 ? attendees : undefined, attachments: attachments.length > 0 ? attachments : undefined, diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 87baf02..204d9b1 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -8150,23 +8150,76 @@ export default function WeeklyView() { }} > {listToDelete === list.id ? ( -
- Delete this list? - {list.externalProvider && Note: This list is not deleted from {list.externalProvider}, just from this view.} -
- - + + +
+ + ) : ( +
+ -
+ }} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}> + {profile.language === 'de' ? 'Abbrechen' : 'Cancel'} + + +
+ )} ) : ( <> diff --git a/src/lib/calendar-cache.ts b/src/lib/calendar-cache.ts index 772023f..9bee463 100644 --- a/src/lib/calendar-cache.ts +++ b/src/lib/calendar-cache.ts @@ -78,6 +78,7 @@ export async function readCachedEvents( calendarId: row.calendarId, calendarTitle: row.calendarTitle, calendarColor: row.calendarColor, + busyStatus: row.busyStatus ?? undefined, })); } @@ -128,6 +129,7 @@ export async function refreshConnectionCache( endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null, endDate: ev.end.date ?? null, reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null, + busyStatus: ev.busyStatus ?? null, weekStart, syncedAt: now, })); @@ -183,6 +185,7 @@ export async function upsertCachedEvent( endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null, endDate: event.end.date ?? null, reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null, + busyStatus: event.busyStatus ?? null, weekStart, syncedAt: new Date(), }, @@ -200,6 +203,7 @@ export async function upsertCachedEvent( endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null, endDate: event.end.date ?? null, reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null, + busyStatus: event.busyStatus ?? null, weekStart, syncedAt: new Date(), }, diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index 9a52700..6ec822f 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -863,8 +863,13 @@ export const createCalendarEvent = async ( source: 'outlook', calendarId, calendarTitle: '', - isRecurring: !!event.recurrence, - recurringEventId: event.recurrence ? createdEvent.id : undefined, + isRecurring: createdEvent.isRecurring ?? !!event.recurrence, + recurringEventId: createdEvent.recurringEventId ?? (event.recurrence ? createdEvent.id : undefined), + reminders: createdEvent.reminders, + busyStatus: createdEvent.busyStatus as BusyStatus | undefined, + visibility: createdEvent.visibility as EventVisibility | undefined, + attendees: createdEvent.attendees as EventAttendee[] | undefined, + url: createdEvent.htmlLink, } as CalendarEvent; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); @@ -1114,6 +1119,13 @@ export const updateCalendarEvent = async ( source: 'outlook', calendarId, calendarTitle: '', + isRecurring: updatedEvent.isRecurring, + recurringEventId: updatedEvent.recurringEventId, + reminders: updatedEvent.reminders, + busyStatus: updatedEvent.busyStatus as BusyStatus | undefined, + visibility: updatedEvent.visibility as EventVisibility | undefined, + attendees: updatedEvent.attendees as EventAttendee[] | undefined, + url: updatedEvent.htmlLink, } as CalendarEvent; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); @@ -1342,6 +1354,16 @@ export const deleteCalendarEvent = async ( } else if (deleteMode === 'all' || !hasInstanceId) { // Delete the entire series (use series master ID) await deleteOutlookEvent(accessToken, calendarId, seriesMasterId); + // Safety net: Outlook sometimes leaves the first occurrence as an orphan + // after deleting the seriesMaster (especially when the master's start + // matches the first occurrence). Explicitly delete the instance ID too. + if (hasInstanceId && instanceId && instanceId !== seriesMasterId) { + try { + await deleteOutlookEvent(accessToken, calendarId, instanceId); + } catch (e) { + // Ignore — the master delete is the authoritative operation. + } + } } else { // 'future' or 'past' — Outlook doesn't support partial series delete easily // Fall back to deleting the series diff --git a/src/lib/google-tasks.ts b/src/lib/google-tasks.ts index bd7d35c..44046c0 100644 --- a/src/lib/google-tasks.ts +++ b/src/lib/google-tasks.ts @@ -147,6 +147,22 @@ export const deleteGoogleTask = async (client: OAuth2Client, taskListId: string, } }; +/** + * Delete a Google Tasks list. + * Returns silently on 404 (already deleted). + */ +export const deleteGoogleTaskList = async (client: OAuth2Client, taskListId: string): Promise => { + const service = google.tasks({ version: 'v1', auth: client }); + try { + await service.tasklists.delete({ tasklist: taskListId }); + } catch (error: any) { + const status = error?.code || error?.response?.status; + if (status === 404 || status === 410) return; + console.error(`Error deleting Google Task list ${taskListId}:`, error); + throw error; + } +}; + /** * Fetch tasks from a specific list including completed ones (for sync) */ diff --git a/src/lib/microsoft-todo.ts b/src/lib/microsoft-todo.ts index 0178b81..239f365 100644 --- a/src/lib/microsoft-todo.ts +++ b/src/lib/microsoft-todo.ts @@ -87,6 +87,32 @@ export const fetchMsTodoLists = async (accessToken: string): Promise => { + const response = await fetch( + `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${accessToken}` + } + } + ); + + // 404/410 mean it's already gone — treat as success + if (response.status === 404 || response.status === 410) return; + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Failed to delete To-Do list: ${err}`); + } +}; + /** * Fetch active tasks from a specific To-Do list (for import). */ @@ -168,6 +194,7 @@ export const createMsTodoTask = async ( title: string; body?: string; dueDateTime?: string; + importance?: 'low' | 'normal' | 'high'; } ): Promise => { const requestBody: any = { title: taskData.title }; @@ -181,6 +208,9 @@ export const createMsTodoTask = async ( timeZone: 'UTC' }; } + if (taskData.importance) { + requestBody.importance = taskData.importance; + } const response = await fetch( `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks`, @@ -214,6 +244,7 @@ export const updateMsTodoTask = async ( body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null; + importance?: 'low' | 'normal' | 'high'; } ): Promise => { const body: any = {}; @@ -236,6 +267,7 @@ export const updateMsTodoTask = async ( ? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' } : null; } + if (updates.importance !== undefined) body.importance = updates.importance; const response = await fetch( `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`, diff --git a/src/lib/outlook-calendar.ts b/src/lib/outlook-calendar.ts index e6b8d54..7182083 100644 --- a/src/lib/outlook-calendar.ts +++ b/src/lib/outlook-calendar.ts @@ -201,54 +201,7 @@ export const getUpcomingEvents = async ( } const data = await response.json(); - return data.value.map((event: any) => { - // Map Outlook showAs to our busyStatus - const showAsMap: Record = { - 'free': 'free', 'tentative': 'tentative', 'busy': 'busy', - 'oof': 'oof', 'workingElsewhere': 'workingElsewhere', - }; - // Map Outlook sensitivity to our visibility - const sensitivityMap: Record = { - 'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential', - }; - - // Outlook returns dateTime without Z suffix even when timeZone is UTC. - // Append Z so JS Date parsing treats it as UTC (not local time). - const fixUtc = (dt: string, tz: string) => - dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt; - - return { - id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id, - summary: event.subject, - description: event.body?.content || event.bodyPreview, - start: { - dateTime: fixUtc(event.start.dateTime, event.start.timeZone), - timeZone: event.start.timeZone - }, - end: { - dateTime: fixUtc(event.end.dateTime, event.end.timeZone), - timeZone: event.end.timeZone - }, - location: event.location?.displayName, - htmlLink: event.webLink, - allDay: event.isAllDay, - recurringEventId: event.seriesMasterId || undefined, - isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster', - reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null - ? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }] - : undefined, - busyStatus: showAsMap[event.showAs] || undefined, - visibility: sensitivityMap[event.sensitivity] || undefined, - attendees: event.attendees?.map((a: any) => ({ - email: a.emailAddress?.address, - displayName: a.emailAddress?.name, - responseStatus: a.status?.response === 'accepted' ? 'accepted' - : a.status?.response === 'declined' ? 'declined' - : a.status?.response === 'tentativelyAccepted' ? 'tentative' - : 'needsAction', - })), - }; - }); + return data.value.map((event: any) => mapOutlookEventResponse(event)); }; const ensureTimeZone = (dateTimeObj: any) => { @@ -283,6 +236,45 @@ const normalizeOutlookDateTime = (dtObj: any) => { return { dateTime: dt, timeZone: dtObj.timeZone }; }; +// Map a raw Microsoft Graph event response into our internal shape. +// Used after create/update to keep the full set of fields (busyStatus, visibility, +// attendees, reminders, recurrence info) flowing back to the cache + UI. +const mapOutlookEventResponse = (ev: any) => { + const showAsMap: Record = { + 'free': 'free', 'tentative': 'tentative', 'busy': 'busy', + 'oof': 'oof', 'workingElsewhere': 'workingElsewhere', + }; + const sensitivityMap: Record = { + 'normal': 'default', 'personal': 'default', + 'private': 'private', 'confidential': 'confidential', + }; + return { + id: ev.seriesMasterId ? `${ev.seriesMasterId}::${ev.id}` : ev.id, + summary: ev.subject, + description: ev.body?.content || ev.bodyPreview, + start: normalizeOutlookDateTime(ev.start), + end: normalizeOutlookDateTime(ev.end), + location: ev.location?.displayName, + htmlLink: ev.webLink, + allDay: ev.isAllDay, + recurringEventId: ev.seriesMasterId || undefined, + isRecurring: ev.type === 'occurrence' || ev.type === 'exception' || ev.type === 'seriesMaster', + reminders: ev.isReminderOn && ev.reminderMinutesBeforeStart != null + ? [{ method: 'popup', minutes: ev.reminderMinutesBeforeStart }] + : undefined, + busyStatus: showAsMap[ev.showAs] || undefined, + visibility: sensitivityMap[ev.sensitivity] || undefined, + attendees: ev.attendees?.map((a: any) => ({ + email: a.emailAddress?.address, + displayName: a.emailAddress?.name, + responseStatus: a.status?.response === 'accepted' ? 'accepted' + : a.status?.response === 'declined' ? 'declined' + : a.status?.response === 'tentativelyAccepted' ? 'tentative' + : 'needsAction', + })), + }; +}; + export const createEvent = async ( accessToken: string, calendarId: string, @@ -336,15 +328,7 @@ export const createEvent = async ( } const created = await response.json(); - return { - id: created.id, - summary: created.subject, - description: created.bodyPreview, - start: normalizeOutlookDateTime(created.start), - end: normalizeOutlookDateTime(created.end), - location: created.location?.displayName, - allDay: created.isAllDay - }; + return mapOutlookEventResponse(created); }; /** @@ -419,15 +403,7 @@ export const updateEvent = async ( } const updated = await response.json(); - return { - id: updated.id, - summary: updated.subject, - description: updated.bodyPreview, - start: normalizeOutlookDateTime(updated.start), - end: normalizeOutlookDateTime(updated.end), - location: updated.location?.displayName, - allDay: updated.isAllDay - }; + return mapOutlookEventResponse(updated); }; /**