/** * TASKMATE - Real-time Sync Module * ================================= * WebSocket synchronization with Socket.io */ import store from './store.js'; import api from './api.js'; class SyncManager { constructor() { this.socket = null; this.isConnected = false; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.reconnectDelay = 1000; this.pendingOperations = []; this.operationQueue = []; this.isProcessingQueue = false; } // Initialize Socket.io connection async connect() { // Verhindere doppelte Verbindungen (auch während des Verbindungsaufbaus) if (this.socket) { console.log('[Sync] Socket already exists, skipping connect'); return; } const token = api.getToken(); if (!token) { console.log('[Sync] No auth token, skipping connection'); return; } try { // Socket.io is loaded globally via script tag if (typeof io === 'undefined') { throw new Error('Socket.io not loaded'); } this.socket = io({ auth: { token }, reconnection: true, reconnectionAttempts: this.maxReconnectAttempts, reconnectionDelay: this.reconnectDelay, reconnectionDelayMax: 10000, timeout: 20000 }); this.setupEventListeners(); } catch (error) { console.error('[Sync] Failed to connect:', error); store.setSyncStatus('error'); } } // Setup socket event listeners setupEventListeners() { if (!this.socket) return; // Connection events this.socket.on('connect', () => { console.log('[Sync] Connected'); this.isConnected = true; this.reconnectAttempts = 0; store.setSyncStatus('synced'); // Join current project room const projectId = store.get('currentProjectId'); if (projectId) { this.joinProject(projectId); } // Process pending operations this.processPendingOperations(); }); this.socket.on('disconnect', (reason) => { console.log('[Sync] Disconnected:', reason); this.isConnected = false; if (reason === 'io server disconnect') { // Server initiated disconnect, try reconnecting this.socket.connect(); } store.setSyncStatus('offline'); }); this.socket.on('connect_error', (error) => { console.error('[Sync] Connection error:', error); this.reconnectAttempts++; if (this.reconnectAttempts >= this.maxReconnectAttempts) { store.setSyncStatus('error'); } else { store.setSyncStatus('offline'); } }); // Auth error this.socket.on('error', (error) => { console.error('[Sync] Socket error:', error); if (error.type === 'auth') { // Nur ausloggen wenn wir wirklich nicht eingeloggt sind // (verhindert Logout durch alte Socket-Verbindungen nach neuem Login) const currentToken = localStorage.getItem('auth_token'); if (!currentToken) { console.log('[Sync] Auth error and no token, triggering logout'); window.dispatchEvent(new CustomEvent('auth:logout')); } else { console.log('[Sync] Auth error ignored - new login occurred'); } } }); // Data sync events this.socket.on('project:updated', (data) => { this.handleProjectUpdated(data); }); this.socket.on('column:created', (data) => { this.handleColumnCreated(data); }); this.socket.on('column:updated', (data) => { this.handleColumnUpdated(data); }); this.socket.on('column:deleted', (data) => { this.handleColumnDeleted(data); }); this.socket.on('column:reordered', (data) => { this.handleColumnsReordered(data); }); this.socket.on('task:created', (data) => { this.handleTaskCreated(data); }); this.socket.on('task:updated', (data) => { this.handleTaskUpdated(data); }); this.socket.on('task:deleted', (data) => { this.handleTaskDeleted(data); }); this.socket.on('task:moved', (data) => { this.handleTaskMoved(data); }); this.socket.on('label:created', (data) => { this.handleLabelCreated(data); }); this.socket.on('label:updated', (data) => { this.handleLabelUpdated(data); }); this.socket.on('label:deleted', (data) => { this.handleLabelDeleted(data); }); this.socket.on('comment:created', (data) => { this.handleCommentCreated(data); }); this.socket.on('subtask:updated', (data) => { this.handleSubtaskUpdated(data); }); // User presence events this.socket.on('user:joined', (data) => { this.handleUserJoined(data); }); this.socket.on('user:left', (data) => { this.handleUserLeft(data); }); this.socket.on('user:typing', (data) => { this.handleUserTyping(data); }); // Notification events this.socket.on('notification:new', (data) => { this.handleNotificationNew(data); }); this.socket.on('notification:count', (data) => { this.handleNotificationCount(data); }); this.socket.on('notification:deleted', (data) => { this.handleNotificationDeleted(data); }); } // Disconnect socket disconnect() { if (this.socket) { this.socket.disconnect(); this.socket = null; this.isConnected = false; } } // Join project room joinProject(projectId) { if (!this.socket?.connected) return; this.socket.emit('project:join', { projectId }); console.log('[Sync] Joined project:', projectId); } // Leave project room leaveProject(projectId) { if (!this.socket?.connected) return; this.socket.emit('project:leave', { projectId }); console.log('[Sync] Left project:', projectId); } // Emit event with queueing for offline support emit(event, data) { if (this.socket?.connected) { this.socket.emit(event, data); return true; } // Queue for later this.pendingOperations.push({ event, data, timestamp: Date.now() }); store.setSyncStatus('offline'); return false; } // Process pending operations when reconnected async processPendingOperations() { if (!this.socket?.connected || this.pendingOperations.length === 0) { return; } console.log(`[Sync] Processing ${this.pendingOperations.length} pending operations`); store.setSyncStatus('syncing'); const operations = [...this.pendingOperations]; this.pendingOperations = []; for (const op of operations) { try { this.socket.emit(op.event, op.data); await this.delay(100); // Small delay between operations } catch (error) { console.error('[Sync] Failed to process operation:', error); // Re-queue failed operation this.pendingOperations.push(op); } } store.setSyncStatus(this.pendingOperations.length > 0 ? 'offline' : 'synced'); } // Helper delay function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // ===================== // EVENT HANDLERS // ===================== handleProjectUpdated(data) { const { project, userId } = data; // Ignore own updates if (userId === store.get('currentUser')?.id) return; store.updateProject(project.id, project); this.showNotification('Projekt aktualisiert', `${data.username} hat das Projekt aktualisiert`); } handleColumnCreated(data) { const { column, userId } = data; if (userId === store.get('currentUser')?.id) return; store.addColumn(column); this.showNotification('Neue Spalte', `${data.username} hat eine Spalte erstellt`); } handleColumnUpdated(data) { const { column, userId } = data; if (userId === store.get('currentUser')?.id) return; store.updateColumn(column.id, column); } handleColumnDeleted(data) { const { columnId, userId } = data; if (userId === store.get('currentUser')?.id) return; store.removeColumn(columnId); } handleColumnsReordered(data) { const { columnIds, userId } = data; if (userId === store.get('currentUser')?.id) return; store.reorderColumns(columnIds); } handleTaskCreated(data) { const { task, userId, username } = data; if (userId === store.get('currentUser')?.id) return; store.addTask(task); this.showNotification('Neue Aufgabe', `${username} hat "${task.title}" erstellt`); } handleTaskUpdated(data) { const { task, userId, changes } = data; if (userId === store.get('currentUser')?.id) return; store.updateTask(task.id, task); // Show notification for significant changes if (changes.includes('status') || changes.includes('assignee')) { this.showNotification('Aufgabe aktualisiert', `"${task.title}" wurde aktualisiert`); } } handleTaskDeleted(data) { const { taskId, userId, taskTitle } = data; if (userId === store.get('currentUser')?.id) return; store.removeTask(taskId); this.showNotification('Aufgabe gelöscht', `"${taskTitle}" wurde gelöscht`); } handleTaskMoved(data) { const { taskId, columnId, position, userId } = data; if (userId === store.get('currentUser')?.id) return; store.moveTask(taskId, columnId, position); } handleLabelCreated(data) { const { label, userId } = data; if (userId === store.get('currentUser')?.id) return; store.addLabel(label); } handleLabelUpdated(data) { const { label, userId } = data; if (userId === store.get('currentUser')?.id) return; store.updateLabel(label.id, label); } handleLabelDeleted(data) { const { labelId, userId } = data; if (userId === store.get('currentUser')?.id) return; store.removeLabel(labelId); } handleCommentCreated(data) { const { taskId, comment, userId, username } = data; if (userId === store.get('currentUser')?.id) return; // Update task comment count const task = store.getTaskById(taskId); if (task) { store.updateTask(taskId, { commentCount: (task.commentCount || 0) + 1 }); } // Check for mention const currentUser = store.get('currentUser'); if (comment.content.includes(`@${currentUser?.username}`)) { this.showNotification( 'Du wurdest erwähnt', `${username} hat dich in "${task?.title}" erwähnt`, 'mention' ); } } handleSubtaskUpdated(data) { const { taskId, subtask, userId } = data; if (userId === store.get('currentUser')?.id) return; // Trigger task refresh if viewing this task const editingTask = store.get('editingTask'); if (editingTask?.id === taskId) { window.dispatchEvent(new CustomEvent('task:refresh', { detail: { taskId } })); } } handleUserJoined(data) { const { userId, username } = data; if (userId === store.get('currentUser')?.id) return; this.showNotification('Benutzer online', `${username} ist jetzt online`, 'info'); } handleUserLeft(data) { const { userId, username } = data; if (userId === store.get('currentUser')?.id) return; // Optional: show notification } handleUserTyping(data) { const { userId, taskId, isTyping } = data; if (userId === store.get('currentUser')?.id) return; window.dispatchEvent(new CustomEvent('user:typing', { detail: { userId, taskId, isTyping } })); } handleNotificationNew(data) { const { notification } = data; window.dispatchEvent(new CustomEvent('notification:new', { detail: { notification } })); } handleNotificationCount(data) { const { count } = data; window.dispatchEvent(new CustomEvent('notification:count', { detail: { count } })); } handleNotificationDeleted(data) { const { notificationId } = data; window.dispatchEvent(new CustomEvent('notification:deleted', { detail: { notificationId } })); } // ===================== // OUTGOING EVENTS // ===================== notifyTaskCreated(task) { this.emit('task:create', { task, projectId: store.get('currentProjectId') }); } notifyTaskUpdated(task, changes) { this.emit('task:update', { task, changes, projectId: store.get('currentProjectId') }); } notifyTaskDeleted(taskId, taskTitle) { this.emit('task:delete', { taskId, taskTitle, projectId: store.get('currentProjectId') }); } notifyTaskMoved(taskId, columnId, position) { this.emit('task:move', { taskId, columnId, position, projectId: store.get('currentProjectId') }); } notifyColumnCreated(column) { this.emit('column:create', { column, projectId: store.get('currentProjectId') }); } notifyColumnUpdated(column) { this.emit('column:update', { column, projectId: store.get('currentProjectId') }); } notifyColumnDeleted(columnId) { this.emit('column:delete', { columnId, projectId: store.get('currentProjectId') }); } notifyColumnsReordered(columnIds) { this.emit('column:reorder', { columnIds, projectId: store.get('currentProjectId') }); } notifyTyping(taskId, isTyping) { this.emit('user:typing', { taskId, isTyping, projectId: store.get('currentProjectId') }); } // ===================== // NOTIFICATIONS // ===================== showNotification(title, message, type = 'info') { window.dispatchEvent(new CustomEvent('toast:show', { detail: { title, message, type } })); } } // Create singleton instance const syncManager = new SyncManager(); // Listen for auth events // Hinweis: syncManager.connect() wird NICHT hier aufgerufen, // sondern in app.js initializeApp() um doppelte Verbindungen zu vermeiden window.addEventListener('auth:logout', () => { syncManager.disconnect(); }); // Listen for project changes store.subscribe('currentProjectId', (newProjectId, oldProjectId) => { if (oldProjectId) { syncManager.leaveProject(oldProjectId); } if (newProjectId) { syncManager.joinProject(newProjectId); } }); // Listen for online/offline events window.addEventListener('online', () => { store.setOnline(true); syncManager.connect(); }); window.addEventListener('offline', () => { store.setOnline(false); }); export default syncManager;