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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user