From 83e2a99d78af034f2c63dcbd86d101909a2d86bb Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 24 Feb 2026 00:07:59 +0100 Subject: [PATCH] feat: add undo/redo, fix Outlook OAuth session loss, fix Microsoft To-Do task import Undo/Redo: - Add undo/redo stacks tracking task and someday list state snapshots - Undo/redo buttons appear on hover in header center section - Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Y / Ctrl+Shift+Z (redo) - Snapshots taken before addTask, toggleTask, updateTask, deleteTask, toggleTaskRolling Outlook OAuth fix: - Redirect through /auth/oauth-complete client-side page instead of directly to /tasks - Client page calls session.update() to refresh JWT before navigating - Prevents session loss caused by SameSite cookie policy during cross-origin redirect Microsoft To-Do import fix: - Set externalId and externalProvider on SomedayList during import - Link existing lists missing external metadata on re-import - Enables pull-sync to find and update tasks in subsequent syncs v1.2.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- .../api/calendar/outlook/callback/route.ts | 13 ++- src/app/api/tasks/import/route.ts | 45 ++++++++- src/app/auth/oauth-complete/page.tsx | 54 +++++++++++ src/components/WeeklyView.tsx | 91 +++++++++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 src/app/auth/oauth-complete/page.tsx diff --git a/package.json b/package.json index 00996fe..74ff4db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.1.1", + "version": "1.2.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/src/app/api/calendar/outlook/callback/route.ts b/src/app/api/calendar/outlook/callback/route.ts index 9cdcdfd..69d2ede 100644 --- a/src/app/api/calendar/outlook/callback/route.ts +++ b/src/app/api/calendar/outlook/callback/route.ts @@ -87,9 +87,18 @@ export async function GET(request: NextRequest) { }); } - return NextResponse.redirect(new URL('/tasks?calendar=connected', request.url)); + // Redirect to a client-side page that re-establishes the session + // Direct redirects from Microsoft OAuth may lose the session cookie (SameSite policy) + const redirectUrl = new URL('/auth/oauth-complete', request.url); + redirectUrl.searchParams.set('provider', 'outlook'); + redirectUrl.searchParams.set('status', 'connected'); + return NextResponse.redirect(redirectUrl); } catch (error) { console.error('Error in Outlook callback:', error); - return NextResponse.redirect(new URL('/auth/login?error=outlook_callback_failed', request.url)); + const redirectUrl = new URL('/auth/oauth-complete', request.url); + redirectUrl.searchParams.set('provider', 'outlook'); + redirectUrl.searchParams.set('status', 'error'); + redirectUrl.searchParams.set('message', 'outlook_callback_failed'); + return NextResponse.redirect(redirectUrl); } } diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index eb5c024..fcd1d13 100644 --- a/src/app/api/tasks/import/route.ts +++ b/src/app/api/tasks/import/route.ts @@ -176,17 +176,52 @@ export async function POST(req: NextRequest) { // Tasks that need parent linking after creation const pendingParentLinks: { localId: string; parentExternalId: string }[] = []; + // Build a lookup from list title to source list info (id, title) + const listTitleToSource = new Map(); + for (const tl of targetLists) { + listTitleToSource.set(tl.title, tl); + } + for (const [listTitle, tasks] of tasksByList) { - let somedayList = await prisma.somedayList.findFirst({ - where: { userId: user.id, title: listTitle } - }); + const sourceInfo = listTitleToSource.get(listTitle); + + // Try to find by externalId first (more reliable), then by title + let somedayList = sourceInfo + ? await prisma.somedayList.findFirst({ + where: { userId: user.id, externalId: sourceInfo.id, externalProvider: provider } + }) + : null; + + if (!somedayList) { + somedayList = await prisma.somedayList.findFirst({ + where: { userId: user.id, title: listTitle } + }); + } if (!somedayList) { somedayList = await prisma.somedayList.create({ - data: { userId: user.id, title: listTitle, order: 0 } + data: { + userId: user.id, + title: listTitle, + order: 0, + externalId: sourceInfo?.id ?? null, + externalProvider: sourceInfo ? provider : null, + lastSyncedAt: new Date(), + } }); listsCreated++; - console.log(`[IMPORT] Created SomedayList "${listTitle}" (${somedayList.id})`); + console.log(`[IMPORT] Created SomedayList "${listTitle}" (${somedayList.id}) with externalId=${sourceInfo?.id}`); + } else if (sourceInfo && !somedayList.externalId) { + // Update existing list with external link if missing + somedayList = await prisma.somedayList.update({ + where: { id: somedayList.id }, + data: { + externalId: sourceInfo.id, + externalProvider: provider, + lastSyncedAt: new Date(), + } + }); + console.log(`[IMPORT] Linked SomedayList "${listTitle}" to external ${sourceInfo.id}`); } // First pass: create/update all tasks (parents first via sorting) diff --git a/src/app/auth/oauth-complete/page.tsx b/src/app/auth/oauth-complete/page.tsx new file mode 100644 index 0000000..2b11c29 --- /dev/null +++ b/src/app/auth/oauth-complete/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; + +export default function OAuthCompletePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { update } = useSession(); + + useEffect(() => { + async function completeOAuth() { + const status = searchParams.get("status"); + const provider = searchParams.get("provider"); + + // Force NextAuth to refresh the session token + await update(); + + if (status === "connected") { + router.replace(`/tasks?calendar=${provider}_connected`); + } else { + const message = searchParams.get("message") || "connection_failed"; + router.replace(`/tasks?error=${message}`); + } + } + + completeOAuth(); + }, [router, searchParams, update]); + + return ( +
+
+
+

Completing connection...

+ +
+
+ ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 6a16d62..da7bd3e 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -40,6 +40,8 @@ import { Sparkles, Info, Trash2, + Undo2, + Redo2, } from "lucide-react"; // Types @@ -493,6 +495,13 @@ export default function WeeklyView() { const [editingTaskId, setEditingTaskId] = useState(null); const [draggingListId, setDraggingListId] = useState(null); + // Undo/Redo state + const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]); + const redoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]); + const [undoCount, setUndoCount] = useState(0); + const [redoCount, setRedoCount] = useState(0); + const skipSnapshotRef = useRef(false); + // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); const [activeTab, setActiveTab] = useState< @@ -1987,9 +1996,71 @@ export default function WeeklyView() { setImportProvider(null); }; + // Undo/Redo helpers + const saveSnapshot = useCallback(() => { + if (skipSnapshotRef.current) return; + undoStackRef.current = [ + ...undoStackRef.current.slice(-29), // keep last 30 snapshots + { + tasks: JSON.parse(JSON.stringify(tasks)), + somedayLists: JSON.parse(JSON.stringify(somedayLists)), + }, + ]; + redoStackRef.current = []; + setUndoCount(undoStackRef.current.length); + setRedoCount(0); + }, [tasks, somedayLists]); + + const handleUndo = useCallback(() => { + if (undoStackRef.current.length === 0) return; + const snapshot = undoStackRef.current.pop()!; + redoStackRef.current.push({ + tasks: JSON.parse(JSON.stringify(tasks)), + somedayLists: JSON.parse(JSON.stringify(somedayLists)), + }); + skipSnapshotRef.current = true; + setTasks(snapshot.tasks); + setSomedayLists(snapshot.somedayLists); + skipSnapshotRef.current = false; + setUndoCount(undoStackRef.current.length); + setRedoCount(redoStackRef.current.length); + }, [tasks, somedayLists]); + + const handleRedo = useCallback(() => { + if (redoStackRef.current.length === 0) return; + const snapshot = redoStackRef.current.pop()!; + undoStackRef.current.push({ + tasks: JSON.parse(JSON.stringify(tasks)), + somedayLists: JSON.parse(JSON.stringify(somedayLists)), + }); + skipSnapshotRef.current = true; + setTasks(snapshot.tasks); + setSomedayLists(snapshot.somedayLists); + skipSnapshotRef.current = false; + setUndoCount(undoStackRef.current.length); + setRedoCount(redoStackRef.current.length); + }, [tasks, somedayLists]); + + // Keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } + if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) { + e.preventDefault(); + handleRedo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleUndo, handleRedo]); + // Task CRUD operations const addTask = async (date: Date, title: string, startTime?: string) => { if (!title.trim()) return; + saveSnapshot(); const scheduledDate = formatDateToISO(date); // Use local date formatting @@ -2058,6 +2129,7 @@ export default function WeeklyView() { }; const toggleTask = async (taskId: string) => { + saveSnapshot(); const task = findTaskAnywhere(taskId); if (!task) return; @@ -2105,6 +2177,7 @@ export default function WeeklyView() { }; const updateTask = async (taskId: string, newTitle: string) => { + saveSnapshot(); if (!newTitle.trim()) { await deleteTask(taskId); return; @@ -2413,6 +2486,7 @@ export default function WeeklyView() { }; const toggleTaskRolling = async (taskId: string) => { + saveSnapshot(); const task = findTaskAnywhere(taskId); if (!task) return; @@ -2517,6 +2591,7 @@ export default function WeeklyView() { }; const deleteTask = async (taskId: string) => { + saveSnapshot(); const taskToDelete = findTaskAnywhere(taskId); const isSomeday = !!taskToDelete?.somedayListId; const isVirtual = taskId.startsWith("virtual-"); @@ -3326,6 +3401,22 @@ export default function WeeklyView() { )} + +