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

@@ -0,0 +1,262 @@
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import { useAuth } from './AuthContext'
interface TaskEvent {
type: 'task_created' | 'task_updated' | 'task_status_changed' | 'task_deleted' | 'task_assigned'
event_id: string
data: {
task_id: string
project_id?: string
title?: string
description?: string | null
status_id?: string | null
status_name?: string | null
status_color?: string | null
assignee_id?: string | null
assignee_name?: string | null
priority?: string
due_date?: string | null
time_estimate?: number | null
original_estimate?: number | null
subtask_count?: number
old_status_id?: string | null
new_status_id?: string | null
new_status_name?: string | null
new_status_color?: string | null
old_assignee_id?: string | null
new_assignee_id?: string | null
new_assignee_name?: string | null
[key: string]: unknown
}
triggered_by: string
timestamp?: string
}
interface ProjectSyncContextType {
isConnected: boolean
currentProjectId: string | null
subscribeToProject: (projectId: string) => void
unsubscribeFromProject: () => void
addTaskEventListener: (callback: (event: TaskEvent) => void) => () => void
}
const ProjectSyncContext = createContext<ProjectSyncContextType | null>(null)
const WS_PING_INTERVAL = 30000
const MAX_RECONNECT_ATTEMPTS = 5
const INITIAL_RECONNECT_DELAY = 1000 // 1 second
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
const MAX_PROCESSED_EVENTS = 1000 // Limit memory usage for event tracking
// Development-only logging helper
const devLog = (...args: unknown[]) => {
if (import.meta.env.DEV) {
console.log(...args)
}
}
const devError = (...args: unknown[]) => {
if (import.meta.env.DEV) {
console.error(...args)
}
}
export function ProjectSyncProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const [isConnected, setIsConnected] = useState(false)
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const listenersRef = useRef<Set<(event: TaskEvent) => void>>(new Set())
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const pingIntervalRef = useRef<ReturnType<typeof setInterval>>()
const targetProjectIdRef = useRef<string | null>(null)
const reconnectAttemptsRef = useRef(0)
const processedEventsRef = useRef<Set<string>>(new Set())
const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = undefined
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = undefined
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}, [])
const subscribeToProject = useCallback((projectId: string) => {
const token = localStorage.getItem('token')
if (!token) return
// Store the target project ID for reconnection logic
targetProjectIdRef.current = projectId
// Close existing connection
cleanup()
// Build WebSocket URL
let wsUrl: string
const envWsUrl = import.meta.env.VITE_WS_URL
if (envWsUrl) {
wsUrl = `${envWsUrl}/ws/projects/${projectId}?token=${token}`
} else {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${wsProtocol}//${window.location.host}/ws/projects/${projectId}?token=${token}`
}
try {
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttemptsRef.current = 0 // Reset on successful connection
setIsConnected(true)
setCurrentProjectId(projectId)
devLog(`Connected to project ${projectId} sync`)
// Start ping interval to keep connection alive
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, WS_PING_INTERVAL)
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
// Handle ping/pong
if (message.type === 'ping') {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pong' }))
}
return
}
if (message.type === 'pong' || message.type === 'connected') {
return
}
// Skip already processed events (handles multi-tab deduplication)
if (message.event_id && processedEventsRef.current.has(message.event_id)) {
return
}
// Mark event as processed
if (message.event_id) {
processedEventsRef.current.add(message.event_id)
// Cleanup old events if too many to prevent memory leaks
if (processedEventsRef.current.size > MAX_PROCESSED_EVENTS) {
const entries = Array.from(processedEventsRef.current)
processedEventsRef.current = new Set(entries.slice(-MAX_PROCESSED_EVENTS / 2))
}
}
// Forward task events to listeners
const taskEventTypes = [
'task_created',
'task_updated',
'task_status_changed',
'task_deleted',
'task_assigned',
]
if (taskEventTypes.includes(message.type)) {
listenersRef.current.forEach((listener) => listener(message as TaskEvent))
}
} catch (e) {
devError('Failed to parse WebSocket message:', e)
}
}
ws.onclose = (event) => {
setIsConnected(false)
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = undefined
}
// Only reconnect if not manually closed (code 1000) and under retry limit
const shouldReconnect =
targetProjectIdRef.current === projectId &&
event.code !== 1000 &&
reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS
if (shouldReconnect) {
const delay = Math.min(
INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current),
MAX_RECONNECT_DELAY
)
reconnectAttemptsRef.current++
devLog(
`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
)
reconnectTimeoutRef.current = setTimeout(() => {
if (targetProjectIdRef.current === projectId) {
subscribeToProject(projectId)
}
}, delay)
} else if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
devError('Max reconnection attempts reached. Please refresh the page.')
}
}
ws.onerror = (error) => {
devError('WebSocket error:', error)
}
wsRef.current = ws
} catch (err) {
devError('Failed to create WebSocket:', err)
}
}, [user?.id, cleanup])
const unsubscribeFromProject = useCallback(() => {
targetProjectIdRef.current = null
cleanup()
setCurrentProjectId(null)
setIsConnected(false)
}, [cleanup])
const addTaskEventListener = useCallback((callback: (event: TaskEvent) => void) => {
listenersRef.current.add(callback)
return () => {
listenersRef.current.delete(callback)
}
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
targetProjectIdRef.current = null
cleanup()
}
}, [cleanup])
return (
<ProjectSyncContext.Provider
value={{
isConnected,
currentProjectId,
subscribeToProject,
unsubscribeFromProject,
addTaskEventListener,
}}
>
{children}
</ProjectSyncContext.Provider>
)
}
export function useProjectSync() {
const context = useContext(ProjectSyncContext)
if (!context) {
throw new Error('useProjectSync must be used within ProjectSyncProvider')
}
return context
}
export type { TaskEvent }

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { NotificationProvider } from './contexts/NotificationContext'
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -11,7 +12,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<AuthProvider>
<NotificationProvider>
<App />
<ProjectSyncProvider>
<App />
</ProjectSyncProvider>
</NotificationProvider>
</AuthProvider>
</BrowserRouter>

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',