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",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.103.2",
|
"version": "1.104.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.103.2",
|
"version": "1.104.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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?
|
externalId String?
|
||||||
externalProvider String?
|
externalProvider String?
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
|
syncDeltaToken String?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
|
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';
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
|
|
||||||
// GET - Pull changes from Google Tasks into local DB
|
// 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);
|
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) {
|
for (const listId of outlookListIds) {
|
||||||
const localTasks = outlookByList.get(listId) || [];
|
const localTasks = outlookByList.get(listId) || [];
|
||||||
try {
|
try {
|
||||||
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
|
const syncedListRow = syncedListIdToRow.get(listId);
|
||||||
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
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));
|
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
|
||||||
|
|
||||||
for (const localTask of localTasks) {
|
// -- Delta deletions: remote tasks marked _deleted are gone in Outlook --
|
||||||
const remote = remoteMap.get(localTask.externalId!);
|
if (isDelta) {
|
||||||
|
const deletedIds = remoteTasks.filter((r: any) => r._deleted).map((r: any) => r.id);
|
||||||
if (!remote) {
|
if (deletedIds.length > 0) {
|
||||||
await prisma.task.update({
|
const result = await prisma.task.updateMany({
|
||||||
where: { id: localTask.id },
|
where: {
|
||||||
data: { deletedAt: new Date() }
|
userId: user.id,
|
||||||
|
externalProvider: 'outlook',
|
||||||
|
externalListId: listId,
|
||||||
|
externalId: { in: deletedIds },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
deleted++;
|
deleted += result.count;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Field-by-field reconciliation: always check each field and update if it
|
||||||
// differs from remote. Local-side mutations are pushed eagerly via
|
// differs from remote. Local-side mutations are pushed eagerly via
|
||||||
// /api/tasks PATCH, so a remote-side change is the authoritative source
|
// /api/tasks PATCH, so a remote-side change is the authoritative source
|
||||||
// when fields disagree at pull time. (A timestamp gate here was previously
|
// when fields disagree at pull time.
|
||||||
// too strict — bumping lastSyncedAt on no-op pulls hid genuine remote
|
|
||||||
// changes such as the importance "star" being toggled in Outlook.)
|
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
|
|
||||||
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
|
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
|
// -- Create new local tasks for remote tasks we don't have yet --
|
||||||
const somedayListInfo = outlookListIdToSomedayList.get(listId);
|
if (syncedListRow) {
|
||||||
if (somedayListInfo) {
|
const newRemoteTasks = reconcileTargets.filter((rt: any) => !existingExternalIds.has(rt.id));
|
||||||
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
|
|
||||||
|
|
||||||
for (const remote of newRemoteTasks) {
|
for (const remote of newRemoteTasks) {
|
||||||
if (!remote.title || !remote.title.trim()) continue;
|
if (!remote.title || !remote.title.trim()) continue;
|
||||||
@ -355,7 +431,7 @@ export async function GET(req: NextRequest) {
|
|||||||
scheduledDate: remote.dueDateTime?.dateTime
|
scheduledDate: remote.dueDateTime?.dateTime
|
||||||
? new Date(remote.dueDateTime.dateTime)
|
? new Date(remote.dueDateTime.dateTime)
|
||||||
: null,
|
: null,
|
||||||
somedayListId: somedayListInfo.id,
|
somedayListId: syncedListRow.id,
|
||||||
externalId: remote.id,
|
externalId: remote.id,
|
||||||
externalProvider: 'outlook',
|
externalProvider: 'outlook',
|
||||||
externalListId: listId,
|
externalListId: listId,
|
||||||
@ -366,6 +442,14 @@ export async function GET(req: NextRequest) {
|
|||||||
created++;
|
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) {
|
} catch (listError) {
|
||||||
console.error(`Error syncing Outlook list ${listId}:`, 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.
|
// Pull-sync from external task providers.
|
||||||
// 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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
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 {
|
try {
|
||||||
const res = await fetch("/api/tasks/sync");
|
const res = await fetch("/api/tasks/sync");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.updated > 0 || data.deleted > 0 || data.created > 0) {
|
if (data.updated > 0 || data.deleted > 0 || data.created > 0) {
|
||||||
console.log(
|
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();
|
fetchTasks();
|
||||||
}
|
}
|
||||||
@ -2023,9 +2035,28 @@ export default function WeeklyView() {
|
|||||||
console.error("[SYNC] Task sync error:", e);
|
console.error("[SYNC] Task sync error:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
runPullSync();
|
|
||||||
const interval = setInterval(runPullSync, 15 * 60 * 1000);
|
// Eager pull on mount
|
||||||
return () => clearInterval(interval);
|
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]);
|
}, [session]);
|
||||||
|
|
||||||
// SSE real-time sync: listen for server-pushed task/list changes
|
// SSE real-time sync: listen for server-pushed task/list changes
|
||||||
|
|||||||
@ -184,6 +184,78 @@ export const fetchMsTodoTasksForSync = async (
|
|||||||
return allTasks;
|
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.
|
* Create a new Microsoft To-Do task in a list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user