--- task_id: sprint_4_story_2_task_2 story_id: sprint_4_story_2 sprint_id: sprint_4 status: not_started type: frontend assignee: Frontend Developer 2 created_date: 2025-11-05 estimated_hours: 3 --- # Task 2: Create Task API Client and React Query Hooks ## Description Create the Task API client module and React Query hooks for Task CRUD operations. This establishes the data layer for Task management with optimistic updates, caching, and error handling. ## What to Do 1. Add Task API client methods to `lib/api/pm.ts` 2. Create `lib/hooks/use-tasks.ts` with React Query hooks 3. Implement optimistic updates for instant UI feedback 4. Add cache invalidation strategies 5. Add error handling with toast notifications 6. Add logger integration for debugging 7. Test all hooks with real API data 8. Document hook usage and examples ## Files to Create/Modify - `lib/api/pm.ts` (modify, add Task methods ~100 lines) - `lib/hooks/use-tasks.ts` (new, ~150-200 lines) ## Implementation Details ### Task API Client (`lib/api/pm.ts`) ```typescript // lib/api/pm.ts // Add these Task API methods import { apiClient } from './client'; import type { Task, CreateTaskDto, UpdateTaskDto, ChangeTaskStatusDto, AssignTaskDto, } from '@/types/project'; export const tasksApi = { /** * Get all Tasks for a Story */ list: async (storyId: string): Promise => { const response = await apiClient.get(`/api/v1/stories/${storyId}/tasks`); return response.data; }, /** * Get single Task by ID */ get: async (id: string): Promise => { const response = await apiClient.get(`/api/v1/tasks/${id}`); return response.data; }, /** * Create new Task */ create: async (data: CreateTaskDto): Promise => { const response = await apiClient.post('/api/v1/tasks', data); return response.data; }, /** * Update Task */ update: async (id: string, data: UpdateTaskDto): Promise => { const response = await apiClient.put(`/api/v1/tasks/${id}`, data); return response.data; }, /** * Delete Task */ delete: async (id: string): Promise => { await apiClient.delete(`/api/v1/tasks/${id}`); }, /** * Change Task status */ changeStatus: async (id: string, status: string): Promise => { const response = await apiClient.put(`/api/v1/tasks/${id}/status`, { status }); return response.data; }, /** * Assign Task to user */ assign: async (id: string, assigneeId: string): Promise => { const response = await apiClient.put(`/api/v1/tasks/${id}/assign`, { assigneeId }); return response.data; }, }; ``` ### React Query Hooks (`lib/hooks/use-tasks.ts`) ```typescript // lib/hooks/use-tasks.ts 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { tasksApi } from '@/lib/api/pm'; import { toast } from 'sonner'; import { logger } from '@/lib/utils/logger'; import type { Task, CreateTaskDto, UpdateTaskDto } from '@/types/project'; /** * Get all Tasks for a Story */ export function useTasks(storyId: string | undefined) { return useQuery({ queryKey: ['tasks', storyId], queryFn: () => { if (!storyId) throw new Error('Story ID required'); return tasksApi.list(storyId); }, enabled: !!storyId, staleTime: 60_000, // Consider fresh for 1 minute }); } /** * Get single Task by ID */ export function useTask(id: string | undefined) { return useQuery({ queryKey: ['tasks', id], queryFn: () => { if (!id) throw new Error('Task ID required'); return tasksApi.get(id); }, enabled: !!id, }); } /** * Create new Task with optimistic update */ export function useCreateTask() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: CreateTaskDto) => { logger.info('Creating task:', data); return tasksApi.create(data); }, onMutate: async (newTask) => { // Cancel outgoing queries await queryClient.cancelQueries({ queryKey: ['tasks', newTask.storyId] }); // Snapshot previous value const previousTasks = queryClient.getQueryData(['tasks', newTask.storyId]); // Optimistically update (optional for creates) // Usually we just wait for server response return { previousTasks }; }, onSuccess: (newTask, variables) => { logger.info('Task created successfully:', newTask); // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ['tasks', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); // Update Story task count toast.success('Task created successfully'); }, onError: (error, variables, context) => { logger.error('Failed to create task:', error); // Restore previous state if optimistic update was used if (context?.previousTasks) { queryClient.setQueryData(['tasks', variables.storyId], context.previousTasks); } toast.error('Failed to create Task'); }, }); } /** * Update Task with optimistic update */ export function useUpdateTask() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, data }: { id: string; data: UpdateTaskDto }) => { logger.info('Updating task:', id, data); return tasksApi.update(id, data); }, onMutate: async ({ id, data }) => { // Cancel queries await queryClient.cancelQueries({ queryKey: ['tasks', id] }); // Snapshot previous const previousTask = queryClient.getQueryData(['tasks', id]); // Optimistically update queryClient.setQueryData(['tasks', id], (old) => { if (!old) return old; return { ...old, ...data }; }); return { previousTask }; }, onSuccess: (updatedTask) => { logger.info('Task updated successfully:', updatedTask); // Invalidate queries queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.id] }); queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.storyId] }); toast.success('Task updated successfully'); }, onError: (error, variables, context) => { logger.error('Failed to update task:', error); // Revert optimistic update if (context?.previousTask) { queryClient.setQueryData(['tasks', variables.id], context.previousTask); } toast.error('Failed to update Task'); }, }); } /** * Delete Task */ export function useDeleteTask() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { logger.info('Deleting task:', id); return tasksApi.delete(id); }, onSuccess: (_, deletedId) => { logger.info('Task deleted successfully:', deletedId); // Remove from cache queryClient.removeQueries({ queryKey: ['tasks', deletedId] }); // Invalidate lists queryClient.invalidateQueries({ queryKey: ['tasks'] }); toast.success('Task deleted successfully'); }, onError: (error) => { logger.error('Failed to delete task:', error); toast.error('Failed to delete Task'); }, }); } /** * Change Task status (for checkbox toggle) */ export function useChangeTaskStatus() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ taskId, status }: { taskId: string; status: string }) => { logger.info('Changing task status:', taskId, status); return tasksApi.changeStatus(taskId, status); }, onMutate: async ({ taskId, status }) => { // Cancel queries await queryClient.cancelQueries({ queryKey: ['tasks', taskId] }); // Snapshot previous const previousTask = queryClient.getQueryData(['tasks', taskId]); // Optimistically update status queryClient.setQueryData(['tasks', taskId], (old) => { if (!old) return old; return { ...old, status }; }); // Also update in list const storyId = previousTask?.storyId; if (storyId) { queryClient.setQueryData(['tasks', storyId], (old) => { if (!old) return old; return old.map((task) => task.id === taskId ? { ...task, status } : task ); }); } return { previousTask }; }, onSuccess: (updatedTask) => { logger.info('Task status changed successfully:', updatedTask); // Invalidate queries queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.id] }); queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', updatedTask.storyId] }); // Update Story progress toast.success(`Task marked as ${updatedTask.status}`); }, onError: (error, variables, context) => { logger.error('Failed to change task status:', error); // Revert optimistic update if (context?.previousTask) { queryClient.setQueryData(['tasks', variables.taskId], context.previousTask); } toast.error('Failed to update Task status'); }, }); } /** * Assign Task to user */ export function useAssignTask() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ taskId, assigneeId }: { taskId: string; assigneeId: string }) => { logger.info('Assigning task:', taskId, assigneeId); return tasksApi.assign(taskId, assigneeId); }, onSuccess: (updatedTask) => { logger.info('Task assigned successfully:', updatedTask); queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.id] }); queryClient.invalidateQueries({ queryKey: ['tasks', updatedTask.storyId] }); toast.success('Task assigned successfully'); }, onError: (error) => { logger.error('Failed to assign task:', error); toast.error('Failed to assign Task'); }, }); } ``` ## Acceptance Criteria - [ ] Task API client methods added to `lib/api/pm.ts` - [ ] All CRUD operations implemented (list, get, create, update, delete) - [ ] `use-tasks.ts` hooks file created - [ ] `useTasks(storyId)` hook returns Task list for Story - [ ] `useCreateTask()` hook creates Task with optimistic update - [ ] `useUpdateTask()` hook updates Task with optimistic update - [ ] `useDeleteTask()` hook deletes Task - [ ] `useChangeTaskStatus()` hook changes status with optimistic update - [ ] All hooks include error handling with toast notifications - [ ] All hooks include logger integration - [ ] Cache invalidation strategies implemented correctly - [ ] Optimistic updates provide instant UI feedback - [ ] Hooks tested with real API data ## Testing **Manual Testing**: ```typescript // Test in a React component function TestTaskHooks() { const { data: tasks, isLoading } = useTasks('story-123'); const createTask = useCreateTask(); const handleCreate = () => { createTask.mutate({ storyId: 'story-123', title: 'Test Task', priority: 'High', estimatedHours: 8, createdBy: 'user-123', }); }; return (
{isLoading &&

Loading...

} {tasks?.map((task) => (
{task.title}
))}
); } ``` **Test Cases**: 1. Create Task → Verify appears in list immediately (optimistic update) 2. Update Task → Verify updates instantly 3. Delete Task → Verify removes from list 4. Change status → Verify checkbox updates instantly 5. Error handling → Disconnect internet → Verify error toast shows 6. Cache invalidation → Create Task → Verify Story task count updates ## Dependencies **Prerequisites**: - Task 1 (API endpoints verified, types created) - ✅ React Query configured - ✅ apiClient ready (`lib/api/client.ts`) - ✅ logger utility (`lib/utils/logger.ts`) - ✅ sonner toast library **Blocks**: - Task 3, 4, 5 (components depend on hooks) ## Estimated Time 3 hours ## Notes **Optimistic Updates**: Provide instant UI feedback by updating cache immediately, then reverting on error. This makes the app feel fast and responsive. **Cache Invalidation**: When creating/updating/deleting Tasks, also invalidate the parent Story query to update task counts and progress indicators. **Code Reuse**: Copy patterns from `use-stories.ts` hook - Task hooks are very similar to Story hooks.