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",
|
"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",
|
"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": {
|
||||||
|
|||||||
@ -86,7 +86,17 @@ export async function GET(req: NextRequest) {
|
|||||||
for (const listId of googleListIds) {
|
for (const listId of googleListIds) {
|
||||||
const localTasks = googleByList.get(listId) || [];
|
const localTasks = googleByList.get(listId) || [];
|
||||||
try {
|
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]));
|
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
||||||
|
|
||||||
// Build externalId -> localId map for parent linking
|
// Build externalId -> localId map for parent linking
|
||||||
@ -103,11 +113,15 @@ export async function GET(req: NextRequest) {
|
|||||||
const remote = remoteMap.get(localTask.externalId!);
|
const remote = remoteMap.get(localTask.externalId!);
|
||||||
|
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
await prisma.task.update({
|
// Only delete if we did a full fetch (no updatedMin filter)
|
||||||
where: { id: localTask.id },
|
// With updatedMin, unchanged tasks won't be in the response
|
||||||
data: { deletedAt: new Date() }
|
if (!updatedMin) {
|
||||||
});
|
await prisma.task.update({
|
||||||
deleted++;
|
where: { id: localTask.id },
|
||||||
|
data: { deletedAt: new Date() }
|
||||||
|
});
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,8 +205,12 @@ export async function GET(req: NextRequest) {
|
|||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (listError) {
|
} catch (listError: any) {
|
||||||
console.error(`Error syncing Google list ${listId}:`, listError);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2074,6 +2074,8 @@ export default function WeeklyView() {
|
|||||||
const [kanbanFilterWeek, setKanbanFilterWeek] = useState<string>("");
|
const [kanbanFilterWeek, setKanbanFilterWeek] = useState<string>("");
|
||||||
const [kanbanSearch, setKanbanSearch] = useState("");
|
const [kanbanSearch, setKanbanSearch] = useState("");
|
||||||
const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState<string | null>(null);
|
const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState<string | null>(null);
|
||||||
|
const [kanbanAddingStageId, setKanbanAddingStageId] = useState<string | null>(null);
|
||||||
|
const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState("");
|
||||||
|
|
||||||
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
||||||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||||||
@ -2726,7 +2728,7 @@ export default function WeeklyView() {
|
|||||||
setTimeout(() => setSyncError(null), 10000);
|
setTimeout(() => setSyncError(null), 10000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
2 * 60 * 1000,
|
5 * 60 * 1000,
|
||||||
);
|
);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [session]);
|
}, [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
|
// Helper to find a task in both calendar tasks and someday lists
|
||||||
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
||||||
const calTask = tasks.find((t) => t.id === taskId);
|
const calTask = tasks.find((t) => t.id === taskId);
|
||||||
@ -6619,6 +6691,69 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kanban-column-body">
|
<div className="kanban-column-body">
|
||||||
{stageTasks.map(renderKanbanCard)}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -6648,6 +6783,69 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="kanban-column-body">
|
<div className="kanban-column-body">
|
||||||
{unassigned.map(renderKanbanCard)}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -174,7 +174,12 @@ export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId:
|
|||||||
updated: item.updated!,
|
updated: item.updated!,
|
||||||
parent: (item as any).parent || undefined,
|
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);
|
console.error(`Error fetching Google Tasks for sync from list ${taskListId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user