Files
ColaFlow/docs/plans/sprint_4_story_2_task_2.md
Yaojia Wang 88d6413f81 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>
2025-11-05 21:49:57 +01:00

12 KiB

task_id, story_id, sprint_id, status, type, assignee, created_date, estimated_hours
task_id story_id sprint_id status type assignee created_date estimated_hours
sprint_4_story_2_task_2 sprint_4_story_2 sprint_4 not_started frontend Frontend Developer 2 2025-11-05 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)

// 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)

// 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:

// 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.