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:
parent
f61f5ddb7a
commit
8958df03ce
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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;
|
||||
@ -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[]
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user