feat: implement kanban real-time sync and fix workload cache

## Kanban Real-time Sync (NEW-002)
- Backend:
  - WebSocket endpoint: /ws/projects/{project_id}
  - Project room management in ConnectionManager
  - Redis Pub/Sub: project:{project_id}:tasks channel
  - Task CRUD event publishing (5 event types)
  - Redis connection retry with exponential backoff
  - Race condition fix in broadcast_to_project

- Frontend:
  - ProjectSyncContext for WebSocket management
  - Reconnection with exponential backoff (max 5 attempts)
  - Multi-tab event deduplication via event_id
  - Live/Offline connection indicator
  - Optimistic updates with rollback

- Spec:
  - collaboration spec: +1 requirement (Project Real-time Sync)
  - 7 new scenarios for real-time sync

## Workload Cache Fix (NEW-001)
- Added cache invalidation to all task endpoints:
  - create_task, update_task, update_task_status
  - delete_task, restore_task, assign_task
- Extended to clear heatmap cache as well

## OpenSpec Archive
- 2026-01-05-add-kanban-realtime-sync

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-05 20:28:42 +08:00
parent 9b220523ff
commit 69b81d9241
13 changed files with 1470 additions and 31 deletions

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import { KanbanBoard } from '../components/KanbanBoard'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { UserSelect } from '../components/UserSelect'
import { UserSearchResult } from '../services/collaboration'
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
interface Task {
id: string
@@ -41,6 +42,7 @@ const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode'
export default function Tasks() {
const { projectId } = useParams()
const navigate = useNavigate()
const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync()
const [project, setProject] = useState<Project | null>(null)
const [tasks, setTasks] = useState<Task[]>([])
const [statuses, setStatuses] = useState<TaskStatus[]>([])
@@ -67,6 +69,88 @@ export default function Tasks() {
loadData()
}, [projectId])
// Subscribe to project WebSocket when project changes
useEffect(() => {
if (projectId) {
subscribeToProject(projectId)
}
return () => {
unsubscribeFromProject()
}
}, [projectId, subscribeToProject, unsubscribeFromProject])
// Handle real-time task events from WebSocket
const handleTaskEvent = useCallback((event: TaskEvent) => {
switch (event.type) {
case 'task_created':
// Add new task to list
setTasks((prev) => {
// Check if task already exists (avoid duplicates)
if (prev.some((t) => t.id === event.data.task_id)) {
return prev
}
const newTask: Task = {
id: event.data.task_id,
title: event.data.title || '',
description: event.data.description ?? null,
priority: event.data.priority || 'medium',
status_id: event.data.status_id ?? null,
status_name: event.data.status_name ?? null,
status_color: event.data.status_color ?? null,
assignee_id: event.data.assignee_id ?? null,
assignee_name: event.data.assignee_name ?? null,
due_date: event.data.due_date ?? null,
time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
subtask_count: event.data.subtask_count ?? 0,
}
return [...prev, newTask]
})
break
case 'task_updated':
case 'task_status_changed':
case 'task_assigned':
// Update existing task
setTasks((prev) =>
prev.map((task) => {
if (task.id !== event.data.task_id) return task
// Merge event data into existing task
return {
...task,
...(event.data.title !== undefined && { title: event.data.title }),
...(event.data.description !== undefined && { description: event.data.description ?? null }),
...(event.data.priority !== undefined && { priority: event.data.priority }),
...(event.data.status_id !== undefined && { status_id: event.data.status_id ?? null }),
...(event.data.status_name !== undefined && { status_name: event.data.status_name ?? null }),
...(event.data.status_color !== undefined && { status_color: event.data.status_color ?? null }),
...(event.data.new_status_id !== undefined && { status_id: event.data.new_status_id ?? null }),
...(event.data.new_status_name !== undefined && { status_name: event.data.new_status_name ?? null }),
...(event.data.new_status_color !== undefined && { status_color: event.data.new_status_color ?? null }),
...(event.data.assignee_id !== undefined && { assignee_id: event.data.assignee_id ?? null }),
...(event.data.assignee_name !== undefined && { assignee_name: event.data.assignee_name ?? null }),
...(event.data.new_assignee_id !== undefined && { assignee_id: event.data.new_assignee_id ?? null }),
...(event.data.new_assignee_name !== undefined && { assignee_name: event.data.new_assignee_name ?? null }),
...(event.data.due_date !== undefined && { due_date: event.data.due_date ?? null }),
...(event.data.time_estimate !== undefined && { time_estimate: event.data.time_estimate ?? null }),
...(event.data.original_estimate !== undefined && event.data.time_estimate === undefined && { time_estimate: event.data.original_estimate ?? null }),
...(event.data.subtask_count !== undefined && { subtask_count: event.data.subtask_count }),
}
})
)
break
case 'task_deleted':
// Remove task from list
setTasks((prev) => prev.filter((task) => task.id !== event.data.task_id))
break
}
}, [])
useEffect(() => {
const unsubscribe = addTaskEventListener(handleTaskEvent)
return unsubscribe
}, [addTaskEventListener, handleTaskEvent])
// Persist view mode
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode)
@@ -130,11 +214,34 @@ export default function Tasks() {
}
const handleStatusChange = async (taskId: string, statusId: string) => {
// Save original state for rollback
const originalTasks = [...tasks]
// Find the target status for optimistic update
const targetStatus = statuses.find((s) => s.id === statusId)
// Optimistic update
setTasks((prev) =>
prev.map((task) =>
task.id === taskId
? {
...task,
status_id: statusId,
status_name: targetStatus?.name ?? null,
status_color: targetStatus?.color ?? null,
}
: task
)
)
try {
await api.patch(`/tasks/${taskId}/status`, { status_id: statusId })
loadData()
// Success - real-time event from WebSocket will be ignored (triggered_by check)
} catch (err) {
// Rollback on error
setTasks(originalTasks)
console.error('Failed to update status:', err)
// Could add toast notification here for better UX
}
}
@@ -193,7 +300,18 @@ export default function Tasks() {
</div>
<div style={styles.header}>
<h1 style={styles.title}>Tasks</h1>
<div style={styles.titleContainer}>
<h1 style={styles.title}>Tasks</h1>
{isConnected ? (
<span style={styles.liveIndicator} title="Real-time sync active">
Live
</span>
) : projectId ? (
<span style={styles.offlineIndicator} title="Real-time sync disconnected. Changes may not appear automatically.">
Offline
</span>
) : null}
</div>
<div style={styles.headerActions}>
{/* View Toggle */}
<div style={styles.viewToggle}>
@@ -405,11 +523,39 @@ const styles: { [key: string]: React.CSSProperties } = {
alignItems: 'center',
marginBottom: '24px',
},
titleContainer: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: 0,
},
liveIndicator: {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
color: '#22c55e',
fontWeight: 500,
padding: '2px 8px',
backgroundColor: '#f0fdf4',
borderRadius: '10px',
border: '1px solid #bbf7d0',
},
offlineIndicator: {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
fontSize: '11px',
color: '#f44336',
backgroundColor: '#ffebee',
borderRadius: '4px',
marginLeft: '8px',
},
headerActions: {
display: 'flex',
gap: '12px',