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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-21 19:28:15 +01:00
parent 876bd1047b
commit bce86aeb9b
4 changed files with 232 additions and 11 deletions

View File

@ -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": {

View File

@ -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) {
// 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,12 +205,16 @@ export async function GET(req: NextRequest) {
created++;
}
}
} catch (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);
}
}
}
}
}
// --- Microsoft To-Do pull-sync ---
const outlookSyncedLists = syncedLists.filter(l => l.externalProvider === 'outlook');

View File

@ -2074,6 +2074,8 @@ export default function WeeklyView() {
const [kanbanFilterWeek, setKanbanFilterWeek] = useState<string>("");
const [kanbanSearch, setKanbanSearch] = useState("");
const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState<string | null>(null);
const [kanbanAddingStageId, setKanbanAddingStageId] = useState<string | null>(null);
const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState("");
const [protectEventTimes, setProtectEventTimes] = useState(false);
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(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() {
</div>
<div className="kanban-column-body">
{stageTasks.map(renderKanbanCard)}
{kanbanAddingStageId === stage.id ? (
<div className="kanban-add-task-input">
<input
type="text"
autoFocus
placeholder={language === "de" ? "Aufgabe eingeben..." : "Enter task..."}
value={kanbanNewTaskTitle}
onChange={(e) => 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",
}}
/>
</div>
) : (
<button
className="kanban-add-task-btn"
onClick={() => {
setKanbanAddingStageId(stage.id);
setKanbanNewTaskTitle("");
}}
style={{
width: "100%",
padding: "6px 8px",
fontSize: "0.75rem",
color: "#9ca3af",
background: "none",
border: "1px dashed #d1d5db",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
justifyContent: "center",
marginTop: "4px",
}}
>
<Plus size={12} />
{language === "de" ? "Aufgabe" : "Add task"}
</button>
)}
</div>
</div>
);
@ -6648,6 +6783,69 @@ export default function WeeklyView() {
</div>
<div className="kanban-column-body">
{unassigned.map(renderKanbanCard)}
{kanbanAddingStageId === "__unassigned__" ? (
<div className="kanban-add-task-input">
<input
type="text"
autoFocus
placeholder={language === "de" ? "Aufgabe eingeben..." : "Enter task..."}
value={kanbanNewTaskTitle}
onChange={(e) => 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",
}}
/>
</div>
) : (
<button
className="kanban-add-task-btn"
onClick={() => {
setKanbanAddingStageId("__unassigned__");
setKanbanNewTaskTitle("");
}}
style={{
width: "100%",
padding: "6px 8px",
fontSize: "0.75rem",
color: "#9ca3af",
background: "none",
border: "1px dashed #d1d5db",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
justifyContent: "center",
marginTop: "4px",
}}
>
<Plus size={12} />
{language === "de" ? "Aufgabe" : "Add task"}
</button>
)}
</div>
</div>
);

View File

@ -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;
}