feat: focus-triggered MS To-Do delta sync for near-instant change reflection

Pull-sync now runs on visibilitychange + window focus (in addition to mount
and the slow 15-min safety-net interval), throttled to one call per 5s. The
moment the user returns to the tab the app reconciles, so an Outlook-side
checkbox/star/title change shows up within a second of refocusing the tab
instead of after a 15-minute wait.

To keep this cheap, the Microsoft Graph delta endpoint
(/me/todo/lists/{id}/tasks/delta) is used when we have a stored deltaToken
for a synced list — typically a few hundred bytes per call when nothing
changed. Tokens are persisted on SomedayList.syncDeltaToken (new column,
migration applied) and refreshed each call. On 410 Gone (token expired
>30 days) we fall back to a full fetch and prime a new chain. Delta also
brings remote deletions (@removed) so they propagate locally without
needing a full list scan.

v1.104.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-03 21:58:00 +02:00
parent f61f5ddb7a
commit 8958df03ce
7 changed files with 221 additions and 31 deletions

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, { id: string; title: string; syncDeltaToken: string | null }>();
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);
}

View File

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

View File

@ -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.
*/