Files
TaskMate/frontend/js/sync.js
2025-12-30 22:49:56 +00:00

586 Zeilen
14 KiB
JavaScript

/**
* 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;