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:
parent
876bd1047b
commit
bce86aeb9b
@ -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": {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user