import { defineStore } from 'pinia' import { openDashboardLiveStream } from '../services/live' import type { BoardGroup, DashboardTaskDto } from './tasks' import type { NotificationItem } from './notifications' import type { TaskItem } from '../components/dashboard/v2/types' import { useTaskStore } from './tasks' import { useNotificationStore } from './notifications' import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live' interface NotificationSnapshotDto { notifications: NotificationItem[] unreadCount: number forUser: string } interface DashboardLiveSnapshotDto { board: BoardGroup notifications: NotificationSnapshotDto cursor: LiveCursorDto } function isBoardGroup(value: unknown): value is BoardGroup { const v = value as BoardGroup return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done) } function mapTasks(board: BoardGroup): DashboardTaskDto[] { return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done] } function mapTaskStripItem(t: DashboardTaskDto): TaskItem { return { id: t.id, title: t.title, agent: t.assignedTo ?? '—', priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low', status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending', progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0), detail: t.detail, source: t.source, } } export const useLiveSyncStore = defineStore('liveSync', { state: () => ({ connected: false, connecting: false, lastEventAt: null as string | null, lastHeartbeatAt: null as string | null, error: null as string | null, controller: null as AbortController | null, reconnectTimer: null as ReturnType | null, mode: 'polling' as 'polling' | 'live', lastSequence: 0, reconnectAttempts: 0, }), getters: { liveIndicatorLabel: (state) => { if (state.connecting) return 'Verbinde…' if (state.connected) return `Live · #${state.lastSequence}` return state.mode === 'polling' ? 'Polling' : 'Offline' }, connectionHealth: (state) => { if (state.connected) return 'healthy' if (state.connecting) return 'connecting' return 'degraded' }, }, actions: { async connect(forUser = 'bao') { if (this.connecting || this.connected) return this.connecting = true this.error = null this.controller = new AbortController() const taskStore = useTaskStore() const notificationStore = useNotificationStore() try { const stream = await openDashboardLiveStream((event, data) => { this.lastEventAt = new Date().toISOString() if (event === 'heartbeat') { const cursor = data as LiveCursorDto this.lastHeartbeatAt = cursor.timestamp this.lastSequence = Math.max(this.lastSequence, cursor.sequence) return } if (event === 'snapshot') { const snapshot = data as DashboardLiveSnapshotDto taskStore.board = snapshot.board taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem) notificationStore.notifications = snapshot.notifications.notifications notificationStore.unreadCount = snapshot.notifications.unreadCount this.lastSequence = snapshot.cursor.sequence this.connected = true this.mode = 'live' this.reconnectAttempts = 0 taskStore.stopBoardPolling() return } const eventDto = data as DashboardLiveEventDto this.applyEnvelope(eventDto.envelope, forUser) this.lastSequence = eventDto.cursor.sequence this.connected = true this.mode = 'live' this.reconnectAttempts = 0 taskStore.stopBoardPolling() }, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null }) await stream.closed } catch (error) { if (this.controller?.signal.aborted) return console.warn('[liveSync] stream failed, falling back to polling', error) this.error = 'Live updates unavailable' this.connected = false this.mode = 'polling' taskStore.startBoardPolling() this.scheduleReconnect(forUser) } finally { this.connecting = false if (!this.controller?.signal.aborted && !this.connected) { this.mode = 'polling' } } }, applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) { const taskStore = useTaskStore() const notificationStore = useNotificationStore() if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) { taskStore.board = envelope.payload taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem) return } if (envelope.type === 'notifications.snapshot') { const snapshot = envelope.payload as NotificationSnapshotDto if (snapshot.forUser !== forUser) return notificationStore.notifications = snapshot.notifications notificationStore.unreadCount = snapshot.unreadCount } }, disconnect() { this.controller?.abort() this.controller = null this.connected = false this.connecting = false this.mode = 'polling' if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } }, scheduleReconnect(forUser = 'bao') { if (this.reconnectTimer) return const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1)) this.reconnectAttempts += 1 this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect(forUser) }, delay) }, }, })