diff --git a/backend/app/api/routers/tasks.py b/backend/app/api/routers/tasks.py index 14ee50f..4701d83 100644 --- a/backend/app/api/routers/tasks.py +++ b/backend/app/api/routers/tasks.py @@ -168,7 +168,6 @@ async def commit_drafts( session: Session = Depends(get_db), ) -> TasksCreateResponse: tasks = task_crud.commit_drafts(task_ids, user_id, session) - session.commit() return TasksCreateResponse( task_ids=[t.id for t in tasks if t.id], created_count=len(tasks), @@ -184,10 +183,20 @@ async def update_task( ) -> TaskRead: user_timezone: str = get_user_timezone(user_id, session) updated_task = task_crud.update_task(task_id, task_update, user_id, session) - session.commit() return TaskRead.from_model(updated_task, user_timezone) +@router.post("/deschedule", status_code=status.HTTP_200_OK) +async def deschedule_tasks( + task_ids: TasksDelete = Body(...), + user_id: int = Depends(get_current_user_id), + session: Session = Depends(get_db), +) -> dict[str, Any]: + """Deschedule tasks by removing scheduled_at timestamp and deleting schedule items.""" + task_crud.deschedule_tasks(task_ids.task_ids, user_id, session) + return {"descheduled_count": len(task_ids.task_ids)} + + @router.get("/jobs/{job_id}", status_code=status.HTTP_200_OK) async def get_job_status( job_id: str, diff --git a/backend/app/crud/schedule_item_crud.py b/backend/app/crud/schedule_item_crud.py index dcd484f..c5e1c3f 100644 --- a/backend/app/crud/schedule_item_crud.py +++ b/backend/app/crud/schedule_item_crud.py @@ -54,7 +54,7 @@ def create_schedule_items_from_blocks( schedule_items.append(schedule_item) session.add(schedule_item) - session.commit() + session.flush() for item in schedule_items: session.refresh(item) diff --git a/backend/app/crud/task_crud.py b/backend/app/crud/task_crud.py index 26af21c..9cffa22 100644 --- a/backend/app/crud/task_crud.py +++ b/backend/app/crud/task_crud.py @@ -157,3 +157,43 @@ def commit_drafts(draft_ids: list[int], user_id: int, session: Session) -> list[ session.add(draft) session.flush() return list(drafts) + + +def deschedule_tasks(task_ids: list[int], user_id: int, session: Session) -> None: + """ + Deschedule tasks by setting scheduled_at to None and deleting their schedule items. + + Args: + task_ids: List of task IDs to deschedule + user_id: User ID to ensure ownership + session: Database session + """ + if not task_ids: + return + + from app.models.schedule_item import ScheduleItem + + tasks = session.exec( + select(Task) + .where(Task.id.in_(task_ids)) # type: ignore[union-attr] + .where(Task.user_id == user_id) + ).all() + + if not tasks: + return + + schedule_items = session.exec( + select(ScheduleItem) + .where(ScheduleItem.task_id.in_(task_ids)) # type: ignore[union-attr] + .where(ScheduleItem.user_id == user_id) + .where(ScheduleItem.source == "task") + ).all() + + for schedule_item in schedule_items: + session.delete(schedule_item) + + for task in tasks: + task.scheduled_at = None + session.add(task) + + session.flush() diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1b95a0a..3cdaa48 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -18,7 +18,7 @@ export default function RootLayout({ {children} - + diff --git a/frontend/src/components/tasks/CompletedTasks.tsx b/frontend/src/components/tasks/CompletedTasks.tsx index 6767eb1..24e619a 100644 --- a/frontend/src/components/tasks/CompletedTasks.tsx +++ b/frontend/src/components/tasks/CompletedTasks.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useCallback } from "react"; import { useSchedule } from "@/context/schedule-context"; import { useTaskList } from "@/hooks/useTaskLists"; +import { Trash } from "lucide-react"; export default function CompletedTasks() { const { tasks: completedTasks, fetchTasks } = useTaskList("/tasks/completed"); @@ -42,6 +43,7 @@ export default function CompletedTasks() { label: "Delete Selected Tasks", onClick: deleteSelectedTasks, variant: "destructive", + icon: , }, ]} /> diff --git a/frontend/src/components/tasks/ScheduledTasks.tsx b/frontend/src/components/tasks/ScheduledTasks.tsx index 302941e..26a20f9 100644 --- a/frontend/src/components/tasks/ScheduledTasks.tsx +++ b/frontend/src/components/tasks/ScheduledTasks.tsx @@ -8,10 +8,11 @@ import Link from "next/link"; import { useSchedule } from "@/context/schedule-context"; import { useTaskList } from "@/hooks/useTaskLists"; import { toast } from "sonner"; +import { CalendarX, Trash } from "lucide-react"; export default function ScheduledTasks() { const { tasks: scheduledTasks, fetchTasks } = useTaskList("/tasks/scheduled"); - const { deleteTasks, refreshScheduleItems } = useSchedule(); + const { deleteTasks, descheduleTasks, refreshScheduleItems } = useSchedule(); const markAsCompleted = useCallback( async (selectedIndices: Set) => { @@ -45,6 +46,21 @@ export default function ScheduledTasks() { [scheduledTasks, fetchTasks, refreshScheduleItems] ); + const descheduleSelectedTasks = useCallback( + async (selectedIndices: Set) => { + const selectedTasks = scheduledTasks.filter((task, index) => + selectedIndices.has(index) + ); + const taskIds = selectedTasks.map((task) => task.id); + const success = await descheduleTasks(taskIds); + if (success) { + await fetchTasks(); + refreshScheduleItems(); + } + }, + [scheduledTasks, fetchTasks, descheduleTasks, refreshScheduleItems] + ); + const deleteSelectedTasks = useCallback( async (selectedIndices: Set) => { const selectedTasks = scheduledTasks.filter((task, index) => @@ -74,14 +90,20 @@ export default function ScheduledTasks() { } actionButtons={[ + { + label: "Mark as Completed", + onClick: markAsCompleted, + }, + { + label: "Deschedule", + onClick: descheduleSelectedTasks, + icon: , + }, { label: "Delete Selected Tasks", onClick: deleteSelectedTasks, variant: "destructive", - }, - { - label: "Mark as Completed", - onClick: markAsCompleted, + icon: , }, ]} /> diff --git a/frontend/src/components/tasks/TaskDrafts.tsx b/frontend/src/components/tasks/TaskDrafts.tsx index fcb6909..fdbb84a 100644 --- a/frontend/src/components/tasks/TaskDrafts.tsx +++ b/frontend/src/components/tasks/TaskDrafts.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { DraftEditDialog } from "./DraftEditDialog"; import { apiRequest, ApiError } from "@/lib/chrono-client"; import { toast } from "sonner"; -import { Pencil } from "lucide-react"; +import { Pencil, Trash } from "lucide-react"; import TaskList from "./TaskList"; import { useTaskList } from "@/hooks/useTaskLists"; @@ -17,16 +17,16 @@ const commitResponseSchema = z.object({ export default function TaskDrafts() { const { tasks: drafts, fetchTasks } = useTaskList("/tasks/drafts"); - async function commitDrafts() { + async function commitDrafts(selectedIndices: Set) { + const selectedDrafts = drafts.filter((draft, index) => + selectedIndices.has(index) + ); if (drafts.length === 0) { toast.error("No drafts to commit"); return; } - const draftIds = drafts - .filter((draft) => draft.committed_at === null) - .map((draft) => draft.id); - + const draftIds = selectedDrafts.map((draft) => draft.id); if (draftIds.length === 0) { toast.info("All drafts are already committed"); return; @@ -90,13 +90,14 @@ export default function TaskDrafts() { emptyStateDescription="Task drafts will appear here after ingestion" actionButtons={[ { - label: "Commit All Drafts", + label: "Commit Selected Drafts", onClick: commitDrafts, }, { - label: "Delete Selected Tasks", + label: "Delete Selected Drafts", onClick: deleteSelectedTasks, variant: "destructive", + icon: , }, ]} renderEditDialog={(draft, index) => ( @@ -119,8 +120,9 @@ export default function TaskDrafts() { isSingleEdit={false} onUpdate={fetchTasks} trigger={ - } /> diff --git a/frontend/src/components/tasks/TaskList.tsx b/frontend/src/components/tasks/TaskList.tsx index ed5e768..7f1a2f5 100644 --- a/frontend/src/components/tasks/TaskList.tsx +++ b/frontend/src/components/tasks/TaskList.tsx @@ -29,6 +29,7 @@ interface TaskListProps { | "ghost" | "link"; className?: string; + icon?: React.ReactNode; }>; renderEditDialog?: (task: TaskBase, index: number) => React.ReactNode; renderBulkEditDialog?: (selectedIndices: Set) => React.ReactNode; @@ -132,6 +133,7 @@ export default function TaskList({ className={button.className || "flex-1"} onClick={() => handleButtonClick(button.onClick)} > + {button.icon} {button.label} ))} diff --git a/frontend/src/components/tasks/UnscheduledTasks.tsx b/frontend/src/components/tasks/UnscheduledTasks.tsx index 9f36e3e..5e8548a 100644 --- a/frontend/src/components/tasks/UnscheduledTasks.tsx +++ b/frontend/src/components/tasks/UnscheduledTasks.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useCallback } from "react"; import { useSchedule } from "@/context/schedule-context"; import { useTaskList } from "@/hooks/useTaskLists"; +import { CalendarCog, Trash } from "lucide-react"; export default function UnscheduledTasks() { const { tasks: unscheduledTasks, fetchTasks } = @@ -54,14 +55,16 @@ export default function UnscheduledTasks() { } actionButtons={[ + { + label: "Schedule Selected Tasks", + onClick: handleScheduleTasks, + icon: , + }, { label: "Delete Selected Tasks", onClick: deleteSelectedTasks, variant: "destructive", - }, - { - label: "Schedule Selected Tasks", - onClick: handleScheduleTasks, + icon: , }, ]} /> diff --git a/frontend/src/context/schedule-context.tsx b/frontend/src/context/schedule-context.tsx index b704c5c..18f5c69 100644 --- a/frontend/src/context/schedule-context.tsx +++ b/frontend/src/context/schedule-context.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; interface ScheduleContextType { deleteTasks: (taskIds: number[]) => Promise; + descheduleTasks: (taskIds: number[]) => Promise; refreshScheduleItems: () => void; refreshTrigger: number; scheduleTasks: (taskIds: number[]) => Promise; @@ -88,10 +89,33 @@ export function ScheduleProvider({ children }: { children: React.ReactNode }) { } } + async function descheduleTasks(taskIds: number[]): Promise { + if (taskIds.length === 0) { + return true; + } + + try { + await apiRequest("/tasks/deschedule", z.object({}), { + method: "POST", + body: JSON.stringify({ + task_ids: taskIds, + }), + }); + refreshScheduleItems(); + const taskWord = taskIds.length === 1 ? "task" : "tasks"; + toast.success(`Successfully descheduled ${taskIds.length} ${taskWord}`); + return true; + } catch { + toast.error("Failed to deschedule tasks. Please try again."); + return false; + } + } + return (