diff --git a/package-lock.json b/package-lock.json index ec7618f..d2f64d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.103.2", + "version": "1.104.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.103.2", + "version": "1.104.0", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index e3733ed..1253ea8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.103.2", + "version": "1.104.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_someday_list_sync_delta_token/migration.sql b/prisma/migrations/20260503_add_someday_list_sync_delta_token/migration.sql new file mode 100644 index 0000000..c2cdb1f --- /dev/null +++ b/prisma/migrations/20260503_add_someday_list_sync_delta_token/migration.sql @@ -0,0 +1,2 @@ +-- SomedayList: Microsoft Graph delta token for cheap incremental MS To-Do pulls +ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "syncDeltaToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a0085a..25fbfa6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -235,6 +235,7 @@ model SomedayList { externalId String? externalProvider String? lastSyncedAt DateTime? + syncDeltaToken String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) tasks Task[] diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index c7504a3..3a3bfac 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { prisma } from '@/lib/prisma'; import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks'; -import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; +import { fetchMsTodoTasksForSync, fetchMsTodoTasksDelta, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; import { getOutlookAccessToken } from '@/lib/outlook-token'; // GET - Pull changes from Google Tasks into local DB @@ -268,32 +268,109 @@ export async function GET(req: NextRequest) { outlookByList.get(task.externalListId)!.push(task); } + // Pre-load delta tokens for synced lists so we can do incremental pulls + const syncedListIdToRow = new Map(); + for (const sl of outlookSyncedLists) { + if (sl.externalId) { + syncedListIdToRow.set(sl.externalId, { + id: sl.id, + title: sl.title, + syncDeltaToken: (sl as any).syncDeltaToken ?? null, + }); + } + } + for (const listId of outlookListIds) { const localTasks = outlookByList.get(listId) || []; try { - const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); - const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); + const syncedListRow = syncedListIdToRow.get(listId); + const previousDeltaToken = syncedListRow?.syncDeltaToken ?? null; + // Try delta-first when we have a stored token and a linked someday list + // (delta only makes sense when we can also create local tasks for new + // remote ones — otherwise we risk losing creations on a stale chain). + let remoteTasks: any[]; + let isDelta = false; + let newDeltaToken: string | null = null; + + if (syncedListRow && previousDeltaToken) { + try { + const result = await fetchMsTodoTasksDelta(outlookToken, listId, previousDeltaToken); + remoteTasks = result.tasks; + newDeltaToken = result.deltaToken; + isDelta = true; + } catch (e: any) { + if (e?.code === 'DELTA_EXPIRED') { + console.log(`[SYNC] Delta token expired for ${listId}, falling back to full fetch`); + remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); + // Establish a fresh delta chain on the next sync + const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null); + newDeltaToken = fresh?.deltaToken ?? null; + } else { + throw e; + } + } + } else if (syncedListRow) { + // First time we sync this list — do a full fetch AND prime the delta chain + remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); + const fresh = await fetchMsTodoTasksDelta(outlookToken, listId, null).catch(() => null); + newDeltaToken = fresh?.deltaToken ?? null; + } else { + // Not a synced list — just touching individual tasks; full fetch is fine + remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); + } + + const remoteMap = new Map(remoteTasks.map((t: any) => [t.id, t])); const existingExternalIds = new Set(localTasks.map(t => t.externalId)); - for (const localTask of localTasks) { - const remote = remoteMap.get(localTask.externalId!); - - if (!remote) { - await prisma.task.update({ - where: { id: localTask.id }, - data: { deletedAt: new Date() } + // -- Delta deletions: remote tasks marked _deleted are gone in Outlook -- + if (isDelta) { + const deletedIds = remoteTasks.filter((r: any) => r._deleted).map((r: any) => r.id); + if (deletedIds.length > 0) { + const result = await prisma.task.updateMany({ + where: { + userId: user.id, + externalProvider: 'outlook', + externalListId: listId, + externalId: { in: deletedIds }, + deletedAt: null, + }, + data: { deletedAt: new Date() }, }); - deleted++; - continue; + deleted += result.count; } + } + + // For full fetches, detect locally-known tasks that vanished from Outlook + if (!isDelta) { + for (const localTask of localTasks) { + const remote = remoteMap.get(localTask.externalId!); + if (!remote) { + await prisma.task.update({ + where: { id: localTask.id }, + data: { deletedAt: new Date() } + }); + deleted++; + } + } + } + + // -- Reconcile updates -- + // For delta: iterate the (small) returned list and reconcile each. + // For full: iterate local tasks (we already handled deletions above). + const localById = new Map(localTasks.map(t => [t.externalId!, t])); + const reconcileTargets = isDelta + ? remoteTasks.filter((r: any) => !r._deleted) + : remoteTasks; + + for (const remote of reconcileTargets) { + const localTask = localById.get(remote.id); + if (!localTask) continue; // new task — handled below // Field-by-field reconciliation: always check each field and update if it // differs from remote. Local-side mutations are pushed eagerly via // /api/tasks PATCH, so a remote-side change is the authoritative source - // when fields disagree at pull time. (A timestamp gate here was previously - // too strict — bumping lastSyncedAt on no-op pulls hid genuine remote - // changes such as the importance "star" being toggled in Outlook.) + // when fields disagree at pull time. const updateData: any = {}; const remoteCompleted = isMsTodoTaskCompleted(remote.status); @@ -337,10 +414,9 @@ export async function GET(req: NextRequest) { } } - // Create new local tasks for remote tasks not yet in local DB - const somedayListInfo = outlookListIdToSomedayList.get(listId); - if (somedayListInfo) { - const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id)); + // -- Create new local tasks for remote tasks we don't have yet -- + if (syncedListRow) { + const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id)); for (const remote of newRemoteTasks) { if (!remote.title || !remote.title.trim()) continue; @@ -355,7 +431,7 @@ export async function GET(req: NextRequest) { scheduledDate: remote.dueDateTime?.dateTime ? new Date(remote.dueDateTime.dateTime) : null, - somedayListId: somedayListInfo.id, + somedayListId: syncedListRow.id, externalId: remote.id, externalProvider: 'outlook', externalListId: listId, @@ -366,6 +442,14 @@ export async function GET(req: NextRequest) { created++; } } + + // Persist the new delta token for next call (only when we got one) + if (syncedListRow && newDeltaToken) { + await prisma.somedayList.update({ + where: { id: syncedListRow.id }, + data: { syncDeltaToken: newDeltaToken }, + }); + } } catch (listError) { console.error(`Error syncing Outlook list ${listId}:`, listError); } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index f00429e..1be02cd 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -2002,19 +2002,31 @@ export default function WeeklyView() { } }, []); - // Pull-sync from external task providers: once on mount + every 15 minutes. - // The eager mount call makes remote-side changes (e.g. Outlook To-Do star) - // visible immediately on next page load instead of after a 15-minute wait. + // Pull-sync from external task providers. + // + // Strategy: trigger on user-visible moments (tab gains focus, page becomes + // visible, app mounts) plus a slow safety-net interval. The pull uses the + // Microsoft Graph delta endpoint where possible, so each call is a few hundred + // bytes when nothing changed — cheap to run on every focus event. + // + // Throttle: at most one pull every 5 seconds to absorb rapid focus toggles. useEffect(() => { if (!session) return; - const runPullSync = async () => { + + let lastSyncAt = 0; + const MIN_INTERVAL_MS = 5_000; + + const runPullSync = async (reason: string) => { + const now = Date.now(); + if (now - lastSyncAt < MIN_INTERVAL_MS) return; + lastSyncAt = now; try { const res = await fetch("/api/tasks/sync"); if (res.ok) { const data = await res.json(); if (data.updated > 0 || data.deleted > 0 || data.created > 0) { console.log( - `[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`, + `[SYNC] (${reason}) Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`, ); fetchTasks(); } @@ -2023,9 +2035,28 @@ export default function WeeklyView() { console.error("[SYNC] Task sync error:", e); } }; - runPullSync(); - const interval = setInterval(runPullSync, 15 * 60 * 1000); - return () => clearInterval(interval); + + // Eager pull on mount + runPullSync('mount'); + + // Pull when the tab becomes visible or the window regains focus — + // this is when the user actually expects fresh data. + const onVisible = () => { + if (document.visibilityState === 'visible') runPullSync('visibility'); + }; + const onFocus = () => runPullSync('focus'); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onFocus); + + // Slow safety-net interval (covers the case of the tab being open & focused + // for a long time while changes happen on another device). + const interval = setInterval(() => runPullSync('interval'), 15 * 60 * 1000); + + return () => { + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onFocus); + clearInterval(interval); + }; }, [session]); // SSE real-time sync: listen for server-pushed task/list changes diff --git a/src/lib/microsoft-todo.ts b/src/lib/microsoft-todo.ts index 239f365..3d01a01 100644 --- a/src/lib/microsoft-todo.ts +++ b/src/lib/microsoft-todo.ts @@ -184,6 +184,78 @@ export const fetchMsTodoTasksForSync = async ( return allTasks; }; +/** + * Microsoft Graph delta sync: returns only the tasks that changed (created, + * updated, or deleted) since the previous delta token, plus a fresh + * `deltaToken` to use on the next call. Pass `null` to start a new chain. + * + * Deleted tasks come back with an `@removed` field; we surface them via + * the optional `_deleted` boolean on the task object. + * + * Cost: typically a few hundred bytes per call when nothing changed. + */ +export const fetchMsTodoTasksDelta = async ( + accessToken: string, + listId: string, + previousDeltaToken: string | null, +): Promise<{ tasks: (MicrosoftTodoTask & { _deleted?: boolean })[]; deltaToken: string | null }> => { + const tasks: (MicrosoftTodoTask & { _deleted?: boolean })[] = []; + let url: string | null; + if (previousDeltaToken) { + // Resume from where we left off + url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta?$deltatoken=${encodeURIComponent(previousDeltaToken)}`; + } else { + // Initial sync — Graph will paginate via @odata.nextLink, then return @odata.deltaLink + url = `${GRAPH_ENDPOINT}/me/todo/lists/${encodeURIComponent(listId)}/tasks/delta`; + } + + let deltaToken: string | null = null; + + while (url) { + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const err = await response.text(); + // 410 Gone = delta token expired (>30 days). Caller should retry with null. + if (response.status === 410) { + throw Object.assign(new Error('Delta token expired'), { code: 'DELTA_EXPIRED' }); + } + console.error(`Error fetching delta from list ${listId}:`, err); + throw new Error(`Failed to fetch To-Do delta: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + for (const item of (data.value || [])) { + if (item['@removed']) { + tasks.push({ ...item, _deleted: true } as any); + } else { + tasks.push(item as MicrosoftTodoTask); + } + } + + const nextLink: string | undefined = data['@odata.nextLink']; + const deltaLink: string | undefined = data['@odata.deltaLink']; + + if (deltaLink) { + // Final page — extract the new delta token + const match = deltaLink.match(/[?&]\$deltatoken=([^&]+)/); + deltaToken = match ? decodeURIComponent(match[1]) : null; + url = null; + } else if (nextLink) { + url = nextLink; + } else { + url = null; + } + } + + return { tasks, deltaToken }; +}; + /** * Create a new Microsoft To-Do task in a list. */