From f5d529de6389ab0c53a5ece8a2c241b5131c852b Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 28 Jul 2026 23:44:05 -0500 Subject: [PATCH 1/6] Add lock-conflict modal for the new one-lock-per-user backend rule The backend now allows a user only one active task lock at a time and returns 409 with the held lock's details when a second lock attempt conflicts. Add getLockConflict to parse that response and a useLockConflict hook that shows LockConflictModal, releases the held task via the existing unlock endpoint, and retries the original action on confirm. Wire it into TaskContext's auto-lock/manual lock and into TaskActionModal's bundle create/update flow, and add bundledTasks to the LockedTaskData type. --- .../Pages/TaskEditPage/TaskActionModal.tsx | 347 ++++++++++-------- .../TaskActions/LockConflictModal.tsx | 77 ++++ .../TaskEditPage/contexts/TaskContext.tsx | 53 ++- src/hooks/useLockConflict.test.ts | 133 +++++++ src/hooks/useLockConflict.ts | 53 +++ src/lib/apiError.test.ts | 53 ++- src/lib/apiError.ts | 30 ++ src/types/openApiTypes.ts | 99 ++--- 8 files changed, 609 insertions(+), 236 deletions(-) create mode 100644 src/components/Pages/TaskEditPage/TaskActions/LockConflictModal.tsx create mode 100644 src/hooks/useLockConflict.test.ts create mode 100644 src/hooks/useLockConflict.ts diff --git a/src/components/Pages/TaskEditPage/TaskActionModal.tsx b/src/components/Pages/TaskEditPage/TaskActionModal.tsx index ebec6aa02..35bf03cd6 100644 --- a/src/components/Pages/TaskEditPage/TaskActionModal.tsx +++ b/src/components/Pages/TaskEditPage/TaskActionModal.tsx @@ -24,12 +24,14 @@ import { SelectValue, } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' +import { useLockConflict } from '@/hooks/useLockConflict' import { useIntl } from '@/i18n' import { getApiErrorMessage } from '@/lib/apiError' import { logger } from '@/lib/logger' import { getStatusLabel } from '@/lib/taskConstants' import type { Task } from '@/types/Task' import { PENDING_BUNDLE_ID, useTaskBundleContext } from './contexts/TaskBundleContext' +import { LockConflictModal } from './TaskActions/LockConflictModal' import { TaskNearbyMap } from './TaskNearbyMap' interface TaskActionModalProps { @@ -79,6 +81,7 @@ export const TaskActionModal = ({ const updateBundleStatusMutation = api.taskBundle.useUpdateTaskBundleStatus() const createBundleMutation = api.taskBundle.useCreateTaskBundle() const updateBundleMutation = api.taskBundle.useUpdateTaskBundle() + const lockConflict = useLockConflict() const { activeBundle, initialBundle } = useTaskBundleContext() const currentStatus = task.status ?? 0 const currentStatusLabel = @@ -143,7 +146,10 @@ export const TaskActionModal = ({ } if (comment.trim()) { - addTaskCommentMutation.mutate({ taskId: task.id, commentText: comment.trim() }) + addTaskCommentMutation.mutate({ + taskId: task.id, + commentText: comment.trim(), + }) } toast.success( @@ -155,7 +161,10 @@ export const TaskActionModal = ({ ) if (nextTaskType === 'nearby' && selectedNearbyTaskId) { - await navigate({ to: '/tasks/$taskId', params: { taskId: String(selectedNearbyTaskId) } }) + await navigate({ + to: '/tasks/$taskId', + params: { taskId: String(selectedNearbyTaskId) }, + }) } else { toast.info( t('taskEditPage.taskActionModal.toast.loadingNext', undefined, 'Loading next task...') @@ -163,7 +172,10 @@ export const TaskActionModal = ({ try { const randomTasks = await api.challenge.getRandomTask(task.parent, queryClient) if (randomTasks && randomTasks.length > 0) { - await navigate({ to: '/tasks/$taskId', params: { taskId: String(randomTasks[0].id) } }) + await navigate({ + to: '/tasks/$taskId', + params: { taskId: String(randomTasks[0].id) }, + }) } else { toast.info( t( @@ -188,6 +200,11 @@ export const TaskActionModal = ({ onOpenChange(false) } catch (error) { + const isLockConflict = await lockConflict.handleError(error, () => { + void handleSubmit() + }) + if (isLockConflict) return + logger.error('Error updating task', { error: String(error) }) toast.error( (await getApiErrorMessage(error)) ?? @@ -212,174 +229,186 @@ export const TaskActionModal = ({ } return ( - - - - - {t('taskEditPage.taskActionModal.title', undefined, 'Complete Task Action')} - - - {t( - 'taskEditPage.taskActionModal.description', - undefined, - 'Update the task status and optionally add a comment or tags' - )} - - + <> + + + + + {t('taskEditPage.taskActionModal.title', undefined, 'Complete Task Action')} + + + {t( + 'taskEditPage.taskActionModal.description', + undefined, + 'Update the task status and optionally add a comment or tags' + )} + + -
- {/* Status Transition */} -
- -
-
- {currentStatusLabel} +
+ {/* Status Transition */} +
+ +
+
+ {currentStatusLabel} +
+ +
- -
-
- {/* Comment */} -
- -