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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-24 00:07:59 +01:00
parent 535f343790
commit 83e2a99d78
5 changed files with 197 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "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": {

View File

@ -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) { } catch (error) {
console.error('Error in Outlook callback:', 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);
} }
} }

View File

@ -176,17 +176,52 @@ export async function POST(req: NextRequest) {
// Tasks that need parent linking after creation // Tasks that need parent linking after creation
const pendingParentLinks: { localId: string; parentExternalId: string }[] = []; const pendingParentLinks: { localId: string; parentExternalId: string }[] = [];
// Build a lookup from list title to source list info (id, title)
const listTitleToSource = new Map<string, SourceList>();
for (const tl of targetLists) {
listTitleToSource.set(tl.title, tl);
}
for (const [listTitle, tasks] of tasksByList) { for (const [listTitle, tasks] of tasksByList) {
let somedayList = await prisma.somedayList.findFirst({ 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 } where: { userId: user.id, title: listTitle }
}); });
}
if (!somedayList) { if (!somedayList) {
somedayList = await prisma.somedayList.create({ 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++; 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) // First pass: create/update all tasks (parents first via sorting)

View File

@ -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 (
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontFamily: "Inter, sans-serif",
}}>
<div style={{ textAlign: "center" }}>
<div style={{
width: "24px",
height: "24px",
border: "3px solid rgba(0,0,0,0.1)",
borderTopColor: "#3b82f6",
borderRadius: "50%",
animation: "spin 0.8s linear infinite",
margin: "0 auto 16px",
}} />
<p style={{ color: "#6b7280" }}>Completing connection...</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
</div>
);
}

View File

@ -40,6 +40,8 @@ import {
Sparkles, Sparkles,
Info, Info,
Trash2, Trash2,
Undo2,
Redo2,
} from "lucide-react"; } from "lucide-react";
// Types // Types
@ -493,6 +495,13 @@ export default function WeeklyView() {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null); const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null); const [draggingListId, setDraggingListId] = useState<string | null>(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 // Moved state definitions to the top
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
@ -1987,9 +1996,71 @@ export default function WeeklyView() {
setImportProvider(null); 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 // Task CRUD operations
const addTask = async (date: Date, title: string, startTime?: string) => { const addTask = async (date: Date, title: string, startTime?: string) => {
if (!title.trim()) return; if (!title.trim()) return;
saveSnapshot();
const scheduledDate = formatDateToISO(date); // Use local date formatting const scheduledDate = formatDateToISO(date); // Use local date formatting
@ -2058,6 +2129,7 @@ export default function WeeklyView() {
}; };
const toggleTask = async (taskId: string) => { const toggleTask = async (taskId: string) => {
saveSnapshot();
const task = findTaskAnywhere(taskId); const task = findTaskAnywhere(taskId);
if (!task) return; if (!task) return;
@ -2105,6 +2177,7 @@ export default function WeeklyView() {
}; };
const updateTask = async (taskId: string, newTitle: string) => { const updateTask = async (taskId: string, newTitle: string) => {
saveSnapshot();
if (!newTitle.trim()) { if (!newTitle.trim()) {
await deleteTask(taskId); await deleteTask(taskId);
return; return;
@ -2413,6 +2486,7 @@ export default function WeeklyView() {
}; };
const toggleTaskRolling = async (taskId: string) => { const toggleTaskRolling = async (taskId: string) => {
saveSnapshot();
const task = findTaskAnywhere(taskId); const task = findTaskAnywhere(taskId);
if (!task) return; if (!task) return;
@ -2517,6 +2591,7 @@ export default function WeeklyView() {
}; };
const deleteTask = async (taskId: string) => { const deleteTask = async (taskId: string) => {
saveSnapshot();
const taskToDelete = findTaskAnywhere(taskId); const taskToDelete = findTaskAnywhere(taskId);
const isSomeday = !!taskToDelete?.somedayListId; const isSomeday = !!taskToDelete?.somedayListId;
const isVirtual = taskId.startsWith("virtual-"); const isVirtual = taskId.startsWith("virtual-");
@ -3326,6 +3401,22 @@ export default function WeeklyView() {
<RefreshCcw size={14} /> <RefreshCcw size={14} />
</button> </button>
)} )}
<button
onClick={handleUndo}
disabled={undoCount === 0}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 disabled:opacity-0 disabled:cursor-default"
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</button>
<button
onClick={handleRedo}
disabled={redoCount === 0}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 disabled:opacity-0 disabled:cursor-default"
title="Redo (Ctrl+Y)"
>
<Redo2 size={14} />
</button>
<span style={{ <span style={{
fontFamily: profile.cwFontFamily || "Inter", fontFamily: profile.cwFontFamily || "Inter",
fontSize: profile.cwFontSize || "1.125rem", fontSize: profile.cwFontSize || "1.125rem",