From bce86aeb9bc10ab0154de06b794e7b7d3cf8064a Mon Sep 17 00:00:00 2001 From: mARTin Date: Sat, 21 Mar 2026 19:28:15 +0100 Subject: [PATCH] feat: kanban task creation and Google Tasks API quota optimization Add inline task creation in kanban view columns - creates tasks in a "Kanban" someday list (or project-named list if filtered by project). Optimize Google Tasks sync: use updatedMin to fetch only changed tasks, increase sync interval to 5min, handle 429 quota errors gracefully. v1.54.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/api/tasks/sync/route.ts | 34 ++++-- src/components/WeeklyView.tsx | 200 +++++++++++++++++++++++++++++++- src/lib/google-tasks.ts | 7 +- 4 files changed, 232 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index b2cb0f6..401af08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.53.0", + "version": "1.54.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/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 13a8cad..91775d6 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -86,7 +86,17 @@ export async function GET(req: NextRequest) { for (const listId of googleListIds) { const localTasks = googleByList.get(listId) || []; try { - const remoteTasks = await fetchGoogleTasksForSync(client, listId); + // Use updatedMin to only fetch tasks changed since last sync + let updatedMin: string | undefined; + if (localTasks.length > 0) { + const maxSyncDate = localTasks.reduce((max, t) => { + const d = t.lastSyncedAt || t.updatedAt; + return d > max ? d : max; + }, new Date(0)); + // Subtract 1 minute buffer to avoid missing edge cases + updatedMin = new Date(maxSyncDate.getTime() - 60000).toISOString(); + } + const remoteTasks = await fetchGoogleTasksForSync(client, listId, updatedMin); const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); // Build externalId -> localId map for parent linking @@ -103,11 +113,15 @@ export async function GET(req: NextRequest) { const remote = remoteMap.get(localTask.externalId!); if (!remote) { - await prisma.task.update({ - where: { id: localTask.id }, - data: { deletedAt: new Date() } - }); - deleted++; + // Only delete if we did a full fetch (no updatedMin filter) + // With updatedMin, unchanged tasks won't be in the response + if (!updatedMin) { + await prisma.task.update({ + where: { id: localTask.id }, + data: { deletedAt: new Date() } + }); + deleted++; + } continue; } @@ -191,8 +205,12 @@ export async function GET(req: NextRequest) { created++; } } - } catch (listError) { - console.error(`Error syncing Google list ${listId}:`, listError); + } catch (listError: any) { + if (listError?.code === 429 || listError?.status === 429) { + console.warn(`Google Tasks quota exceeded for list ${listId}, skipping`); + } else { + console.error(`Error syncing Google list ${listId}:`, listError); + } } } } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 5ae49cb..900b10b 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -2074,6 +2074,8 @@ export default function WeeklyView() { const [kanbanFilterWeek, setKanbanFilterWeek] = useState(""); const [kanbanSearch, setKanbanSearch] = useState(""); const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState(null); + const [kanbanAddingStageId, setKanbanAddingStageId] = useState(null); + const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState(""); const [protectEventTimes, setProtectEventTimes] = useState(false); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); @@ -2726,7 +2728,7 @@ export default function WeeklyView() { setTimeout(() => setSyncError(null), 10000); } }, - 2 * 60 * 1000, + 5 * 60 * 1000, ); return () => clearInterval(interval); }, [session]); @@ -4191,6 +4193,76 @@ export default function WeeklyView() { } }; + // Create a task in kanban view — auto-creates a someday list if needed + const addKanbanTask = async (title: string, stageId: string | null) => { + if (!title.trim() || !session?.user) return; + saveSnapshot(); + + try { + // Determine the target someday list name + const activeProject = kanbanFilterProject + ? projects.find(p => p.id === kanbanFilterProject) + : null; + const listName = activeProject ? activeProject.name : "Kanban"; + + // Find existing someday list with that name + let targetList = somedayLists.find(sl => sl.title === listName); + + // Create the list if it doesn't exist + if (!targetList) { + const listRes = await fetch("/api/someday-lists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: listName }), + }); + if (listRes.ok) { + const listData = await listRes.json(); + targetList = { ...listData.list, tasks: [] }; + setSomedayLists(prev => [...prev, targetList!]); + } else { + console.error("Failed to create someday list:", await listRes.text()); + return; + } + } + + // Create the task + const response = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + somedayListId: targetList!.id, + kanbanStage: stageId, + projectId: activeProject?.id || undefined, + order: 0, + }), + }); + + if (response.ok) { + const data = await response.json(); + const newTask = { + ...data.task, + createdAt: new Date(data.task.createdAt), + updatedAt: new Date(data.task.updatedAt), + }; + setSomedayLists(prev => + prev.map(sl => + sl.id === targetList!.id + ? { ...sl, tasks: [...sl.tasks, newTask] } + : sl + ) + ); + } else { + console.error("Failed to add kanban task:", await response.text()); + } + } catch (error) { + console.error("Error adding kanban task:", error); + } + + setKanbanAddingStageId(null); + setKanbanNewTaskTitle(""); + }; + // Helper to find a task in both calendar tasks and someday lists const findTaskAnywhere = (taskId: string): Task | undefined => { const calTask = tasks.find((t) => t.id === taskId); @@ -6619,6 +6691,69 @@ export default function WeeklyView() {
{stageTasks.map(renderKanbanCard)} + {kanbanAddingStageId === stage.id ? ( +
+ setKanbanNewTaskTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && kanbanNewTaskTitle.trim()) { + addKanbanTask(kanbanNewTaskTitle, stage.id); + } else if (e.key === "Escape") { + setKanbanAddingStageId(null); + setKanbanNewTaskTitle(""); + } + }} + onBlur={() => { + if (kanbanNewTaskTitle.trim()) { + addKanbanTask(kanbanNewTaskTitle, stage.id); + } else { + setKanbanAddingStageId(null); + setKanbanNewTaskTitle(""); + } + }} + style={{ + width: "100%", + padding: "6px 8px", + fontSize: "0.8rem", + border: "1px solid var(--border-color, #d1d5db)", + borderRadius: "6px", + background: "var(--card-bg, #fff)", + color: "var(--text-color, #111)", + outline: "none", + }} + /> +
+ ) : ( + + )}
); @@ -6648,6 +6783,69 @@ export default function WeeklyView() {
{unassigned.map(renderKanbanCard)} + {kanbanAddingStageId === "__unassigned__" ? ( +
+ setKanbanNewTaskTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && kanbanNewTaskTitle.trim()) { + addKanbanTask(kanbanNewTaskTitle, null); + } else if (e.key === "Escape") { + setKanbanAddingStageId(null); + setKanbanNewTaskTitle(""); + } + }} + onBlur={() => { + if (kanbanNewTaskTitle.trim()) { + addKanbanTask(kanbanNewTaskTitle, null); + } else { + setKanbanAddingStageId(null); + setKanbanNewTaskTitle(""); + } + }} + style={{ + width: "100%", + padding: "6px 8px", + fontSize: "0.8rem", + border: "1px solid var(--border-color, #d1d5db)", + borderRadius: "6px", + background: "var(--card-bg, #fff)", + color: "var(--text-color, #111)", + outline: "none", + }} + /> +
+ ) : ( + + )}
); diff --git a/src/lib/google-tasks.ts b/src/lib/google-tasks.ts index 9663e5a..8059fc9 100644 --- a/src/lib/google-tasks.ts +++ b/src/lib/google-tasks.ts @@ -174,7 +174,12 @@ export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId: updated: item.updated!, parent: (item as any).parent || undefined, })); - } catch (error) { + } catch (error: any) { + // On quota exceeded (429), return empty array instead of crashing + if (error?.code === 429 || error?.status === 429) { + console.warn(`Google Tasks API quota exceeded for list ${taskListId}, skipping sync cycle`); + return []; + } console.error(`Error fetching Google Tasks for sync from list ${taskListId}:`, error); throw error; }