feat(frontend): Create Sprint 4 Stories and Tasks for Story Management
Created comprehensive Story and Task files for Sprint 4 frontend implementation: Story 1: Story Detail Page Foundation (P0 Critical - 3 days) - 6 tasks: route creation, header, sidebar, data loading, Edit/Delete, responsive design - Fixes critical 404 error when clicking Story cards - Two-column layout consistent with Epic detail page Story 2: Task Management in Story Detail (P0 Critical - 2 days) - 6 tasks: API verification, hooks, TaskList, TaskCard, TaskForm, integration - Complete Task CRUD with checkbox status toggle - Filters, sorting, and optimistic UI updates Story 3: Enhanced Story Form (P1 High - 2 days) - 6 tasks: acceptance criteria, assignee selector, tags, story points, integration - Aligns with UX design specification - Backward compatible with existing Stories Story 4: Quick Add Story Workflow (P1 High - 2 days) - 5 tasks: inline form, keyboard shortcuts, batch creation, navigation - Rapid Story creation with minimal fields - Keyboard shortcut (Cmd/Ctrl + N) Story 5: Story Card Component (P2 Medium - 1 day) - 4 tasks: component variants, visual states, Task count, optimization - Reusable component with list/kanban/compact variants - React.memo optimization Story 6: Kanban Story Creation Enhancement (P2 Optional - 2 days) - 4 tasks: Epic card enhancement, inline form, animation, real-time updates - Contextual Story creation from Kanban - Stretch goal - implement only if ahead of schedule Total: 6 Stories, 31 Tasks, 12 days estimated Priority breakdown: P0 (2), P1 (2), P2 (2 optional) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
449
docs/plans/sprint_4_story_2_task_2.md
Normal file
449
docs/plans/sprint_4_story_2_task_2.md
Normal file
@@ -0,0 +1,449 @@
|
||||
---
|
||||
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<Task[]> => {
|
||||
const response = await apiClient.get(`/api/v1/stories/${storyId}/tasks`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get single Task by ID
|
||||
*/
|
||||
get: async (id: string): Promise<Task> => {
|
||||
const response = await apiClient.get(`/api/v1/tasks/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new Task
|
||||
*/
|
||||
create: async (data: CreateTaskDto): Promise<Task> => {
|
||||
const response = await apiClient.post('/api/v1/tasks', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Task
|
||||
*/
|
||||
update: async (id: string, data: UpdateTaskDto): Promise<Task> => {
|
||||
const response = await apiClient.put(`/api/v1/tasks/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Task
|
||||
*/
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/tasks/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Change Task status
|
||||
*/
|
||||
changeStatus: async (id: string, status: string): Promise<Task> => {
|
||||
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<Task> => {
|
||||
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<Task[]>(['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<Task>(['tasks', id]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Task>(['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<Task>(['tasks', taskId]);
|
||||
|
||||
// Optimistically update status
|
||||
queryClient.setQueryData<Task>(['tasks', taskId], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, status };
|
||||
});
|
||||
|
||||
// Also update in list
|
||||
const storyId = previousTask?.storyId;
|
||||
if (storyId) {
|
||||
queryClient.setQueryData<Task[]>(['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 (
|
||||
<div>
|
||||
<button onClick={handleCreate}>Create Task</button>
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{tasks?.map((task) => (
|
||||
<div key={task.id}>{task.title}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user