diff --git a/apiserver/internal/apis/task.go b/apiserver/internal/apis/task.go index 0cf5c24d..a85945c2 100644 --- a/apiserver/internal/apis/task.go +++ b/apiserver/internal/apis/task.go @@ -166,7 +166,16 @@ func (h *TasksAPIHandler) completeTask(c *gin.Context) { return } - status, response := h.tService.CompleteTask(c, currentIdentity.UserID, id) + endRecurrenceStr := c.DefaultQuery("endRecurrence", "false") + endRecurrence, err := strconv.ParseBool(endRecurrenceStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid endRecurrence value", + }) + return + } + + status, response := h.tService.CompleteTask(c, currentIdentity.UserID, id, endRecurrence) c.JSON(status, response) } diff --git a/apiserver/internal/services/tasks/handlers.go b/apiserver/internal/services/tasks/handlers.go index fbc54d34..768d9998 100644 --- a/apiserver/internal/services/tasks/handlers.go +++ b/apiserver/internal/services/tasks/handlers.go @@ -157,16 +157,19 @@ func (h *TasksMessageHandler) updateDueDate(ctx context.Context, userID int, msg } func (h *TasksMessageHandler) completeTask(ctx context.Context, userID int, msg ws.WSMessage) *ws.WSResponse { - var id int - if err := json.Unmarshal(msg.Data, &id); err != nil { + var req struct { + ID int `json:"id"` + EndRecurrence bool `json:"endRecurrence"` + } + if err := json.Unmarshal(msg.Data, &req); err != nil { return &ws.WSResponse{ Status: http.StatusBadRequest, Data: gin.H{ - "error": "Invalid task ID", + "error": "Invalid request data", }, } } - status, response := h.ts.CompleteTask(ctx, userID, id) + status, response := h.ts.CompleteTask(ctx, userID, req.ID, req.EndRecurrence) return &ws.WSResponse{ Status: status, Data: response, diff --git a/apiserver/internal/services/tasks/task.go b/apiserver/internal/services/tasks/task.go index df2391c2..8430b3b0 100644 --- a/apiserver/internal/services/tasks/task.go +++ b/apiserver/internal/services/tasks/task.go @@ -382,7 +382,7 @@ func (s *TaskService) UpdateDueDate(ctx context.Context, userID, taskID int, req } } -func (s *TaskService) CompleteTask(ctx context.Context, userID, taskID int) (int, interface{}) { +func (s *TaskService) CompleteTask(ctx context.Context, userID, taskID int, endRecurrence bool) (int, interface{}) { log := logging.FromContext(ctx) task, err := s.t.GetTask(ctx, taskID) @@ -394,12 +394,15 @@ func (s *TaskService) CompleteTask(ctx context.Context, userID, taskID int) (int } var completedDate time.Time = time.Now().UTC() - var nextDueDate *time.Time - nextDueDate, err = tRepo.ScheduleNextDueDate(task, completedDate) - if err != nil { - log.Errorf("error scheduling next due date: %s", err.Error()) - return http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Error scheduling next due date: %s", err), + var nextDueDate *time.Time = nil + + if !endRecurrence { + nextDueDate, err = tRepo.ScheduleNextDueDate(task, completedDate) + if err != nil { + log.Errorf("error scheduling next due date: %s", err.Error()) + return http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Error scheduling next due date: %s", err), + } } } diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index 13c99e1a..db2a6a3b 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -25,8 +25,8 @@ export const GetTasks = async (): Promise => export const GetCompletedTasks = async (): Promise => await Request(`/tasks/completed`) -export const MarkTaskComplete = async (id: number): Promise => - await Request(`/tasks/${id}/do`, 'POST') +export const MarkTaskComplete = async (id: number, endRecurrence: boolean): Promise => + await Request(`/tasks/${id}/do?endRecurrence=${endRecurrence}`, 'POST') export const SkipTask = async (id: number): Promise => await Request(`/tasks/${id}/skip`, 'POST') diff --git a/frontend/src/store/tasksSlice.ts b/frontend/src/store/tasksSlice.ts index 779e7f4d..34eec9af 100644 --- a/frontend/src/store/tasksSlice.ts +++ b/frontend/src/store/tasksSlice.ts @@ -84,8 +84,8 @@ export const fetchCompletedTasks = createAsyncThunk( export const completeTask = createAsyncThunk( 'tasks/completeTask', - async (taskId: number) => { - const response = await MarkTaskComplete(taskId) + async (req: { taskId: number, endRecurrence: boolean }) => { + const response = await MarkTaskComplete(req.taskId, req.endRecurrence) return response.task }, ) diff --git a/frontend/src/views/Tasks/MyTasks.tsx b/frontend/src/views/Tasks/MyTasks.tsx index f1ff19f3..d90cb4ab 100644 --- a/frontend/src/views/Tasks/MyTasks.tsx +++ b/frontend/src/views/Tasks/MyTasks.tsx @@ -1,4 +1,4 @@ -import { skipTask, deleteTask, updateDueDate, setGroupBy, toggleGroup, toggleShowCompleted, fetchCompletedTasks } from '@/store/tasksSlice' +import { skipTask, deleteTask, updateDueDate, setGroupBy, toggleGroup, toggleShowCompleted, fetchCompletedTasks, completeTask } from '@/store/tasksSlice' import { Task, TASK_UPDATE_EVENT } from '@/models/task' import { ExpandCircleDown, @@ -12,6 +12,7 @@ import { SwitchAccessShortcut, Visibility, VisibilityOff, + PublishedWithChanges, } from '@mui/icons-material' import { Container, @@ -54,6 +55,7 @@ type MyTasksProps = { toggleShowCompleted: () => void fetchCompletedTasks: () => Promise + completeTask: (taskId: number, endRecurrence: boolean) => Promise deleteTask: (taskId: number) => Promise skipTask: (taskId: number) => Promise updateDueDate: (taskId: number, dueDate: string) => Promise @@ -187,6 +189,18 @@ class MyTasksImpl extends React.Component { this.props.navigate(NavigationPaths.TaskHistory(task.id)) } + private onCompleteAndStopRecurrenceClicked = async () => { + const { contextMenuTask: task } = this.state + if (task === null) { + throw new Error('Attempted to complete and stop recurrence without a reference') + } + + await this.props.completeTask(task.id, true) + + this.dismissMoreMenu() + this.onEvent('completed') + } + private onSkipTaskClicked = async () => { const { contextMenuTask: task } = this.state if (task === null) { @@ -384,10 +398,16 @@ class MyTasksImpl extends React.Component { ref={this.menuRef} > {contextMenuTask && contextMenuTask.frequency.type !== 'once' && ( - - - Skip to next due date - + <> + + + Complete and end recurrence + + + + Skip to next due date + + )} @@ -511,6 +531,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({ toggleShowCompleted: () => dispatch(toggleShowCompleted()), fetchCompletedTasks: () => dispatch(fetchCompletedTasks()), + completeTask: (taskId: number, endRecurrence: boolean) => dispatch(completeTask({ taskId, endRecurrence })), deleteTask: (taskId: number) => dispatch(deleteTask(taskId)), skipTask: (taskId: number) => dispatch(skipTask(taskId)), updateDueDate: (taskId: number, dueDate: string) => dispatch(updateDueDate({ taskId, dueDate })), diff --git a/frontend/src/views/Tasks/TaskCard.tsx b/frontend/src/views/Tasks/TaskCard.tsx index 92c4ef5f..25a093cc 100644 --- a/frontend/src/views/Tasks/TaskCard.tsx +++ b/frontend/src/views/Tasks/TaskCard.tsx @@ -30,7 +30,7 @@ import { TaskUI } from '@/utils/marshalling' type TaskCardProps = WithNavigate & { task: TaskUI - completeTask: (taskId: number) => Promise + completeTask: (taskId: number, endRecurrence: boolean) => Promise onTaskUpdate: (event: TASK_UPDATE_EVENT) => void onContextMenu: (event: React.MouseEvent, task: TaskUI) => void } @@ -46,7 +46,7 @@ class TaskCardImpl extends React.Component { private handleTaskCompletion = async () => { const { task, onTaskUpdate } = this.props - await this.props.completeTask(task.id) + await this.props.completeTask(task.id, false) // Play the task completion sound playSound(SoundEffect.TaskComplete) @@ -234,7 +234,7 @@ class TaskCardImpl extends React.Component { } const mapDispatchToProps = (dispatch: AppDispatch) => ({ - completeTask: (taskId: number) => dispatch(completeTask(taskId)), + completeTask: (taskId: number, endRecurrence: boolean) => dispatch(completeTask({ taskId, endRecurrence })), }) export const TaskCard = connect( diff --git a/frontend/src/views/Tasks/TasksOverview.tsx b/frontend/src/views/Tasks/TasksOverview.tsx index c01b765a..d15bf77e 100644 --- a/frontend/src/views/Tasks/TasksOverview.tsx +++ b/frontend/src/views/Tasks/TasksOverview.tsx @@ -44,7 +44,7 @@ type TasksOverviewProps = { filterTasks: (searchQuery: string) => void toggleShowCompleted: () => void fetchCompletedTasks: () => Promise - completeTask: (taskId: number) => Promise + completeTask: (taskId: number, endRecurrence: boolean) => Promise deleteTask: (taskId: number) => Promise updateDueDate: (taskId: number, dueDate: string) => Promise pushStatus: (status: Status) => void @@ -91,7 +91,7 @@ class TasksOverviewImpl extends React.Component { } private onCompleteTaskClicked = (task: TaskUI) => async () => { - await this.props.completeTask(task.id) + await this.props.completeTask(task.id, false) playSound(SoundEffect.TaskComplete) this.props.pushStatus({ @@ -445,7 +445,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({ filterTasks: (searchQuery: string) => dispatch(filterTasks(searchQuery)), toggleShowCompleted: () => dispatch(toggleShowCompleted()), fetchCompletedTasks: () => dispatch(fetchCompletedTasks()), - completeTask: (taskId: number) => dispatch(completeTask(taskId)), + completeTask: (taskId: number, endRecurrence: boolean) => dispatch(completeTask({ taskId, endRecurrence })), deleteTask: (taskId: number) => dispatch(deleteTask(taskId)), updateDueDate: (taskId: number, dueDate: string) => dispatch(updateDueDate({ taskId, dueDate })), pushStatus: (status: Status) => dispatch(pushStatus(status)),