feat: bidirectional Outlook/MS To-Do sync, busyStatus + star, list-delete dialog

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-03 19:53:09 +02:00
parent 55cc066707
commit 42905641cd
15 changed files with 342 additions and 93 deletions

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<string, number> = {
@ -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<string, any> = {};
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,

View File

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

View File

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

View File

@ -8150,14 +8150,64 @@ export default function WeeklyView() {
}}
>
{listToDelete === list.id ? (
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "6px", padding: "4px" }}>
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>
{profile.language === 'de' ? 'Diese Liste löschen?' : 'Delete this list?'}
</span>
{list.externalProvider ? (
<>
<span style={{ fontSize: "0.75rem", color: "#888" }}>
{profile.language === 'de'
? `Diese Liste ist mit ${list.externalProvider} verknüpft. Wie soll fortgefahren werden?`
: `This list is linked to ${list.externalProvider}. How would you like to proceed?`}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
<button onClick={(e) => {
e.stopPropagation();
setListToDelete(null);
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
<button onClick={async (e) => {
e.stopPropagation();
try {
await fetch(`/api/someday-lists?id=${list.id}&tasksOnly=true`, { method: "DELETE" });
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
setListToDelete(null);
} catch (err) { console.error(err); }
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#3b82f6", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
{profile.language === 'de'
? `Nur hier ausblenden (in ${list.externalProvider} bleibt sie)`
: `Hide here only (keep in ${list.externalProvider})`}
</button>
<button onClick={async (e) => {
e.stopPropagation();
try {
const res = await fetch(`/api/someday-lists?id=${list.id}&deleteExternal=true`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (data.externalDeleteFailed) {
alert(profile.language === 'de'
? `Hinweis: Die Liste wurde lokal gelöscht, aber nicht aus ${list.externalProvider} (Fehler beim Provider).`
: `Note: The list was deleted locally, but not from ${list.externalProvider} (provider error).`);
}
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
setListToDelete(null);
} catch (err) { console.error(err); }
}} style={{ padding: "6px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem", textAlign: "left" }}>
{profile.language === 'de'
? `Hier UND in ${list.externalProvider} löschen`
: `Delete here AND in ${list.externalProvider}`}
</button>
</div>
</>
) : (
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
<button onClick={(e) => {
e.stopPropagation();
setListToDelete(null);
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
{profile.language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
<button onClick={async (e) => {
e.stopPropagation();
try {
@ -8165,8 +8215,11 @@ export default function WeeklyView() {
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
setListToDelete(null);
} catch (err) { console.error(err); }
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>
{profile.language === 'de' ? 'Löschen' : 'Delete'}
</button>
</div>
)}
</div>
) : (
<>

View File

@ -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(),
},

View File

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

View File

@ -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<void> => {
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)
*/

View File

@ -87,6 +87,32 @@ export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTo
return (data.value || []) as MicrosoftTodoList[];
};
/**
* Delete a Microsoft To-Do task list.
*/
export const deleteMsTodoList = async (
accessToken: string,
listId: string
): Promise<void> => {
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<MicrosoftTodoTask> => {
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<MicrosoftTodoTask> => {
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)}`,

View File

@ -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<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
// Map Outlook sensitivity to our visibility
const sensitivityMap: Record<string, string> = {
'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<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
const sensitivityMap: Record<string, string> = {
'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);
};
/**