/** * TASKMATE - Offline Module * ========================== * IndexedDB storage and offline support */ import store from './store.js'; import api from './api.js'; const DB_NAME = 'TaskMateDB'; const DB_VERSION = 1; class OfflineManager { constructor() { this.db = null; this.isReady = false; this.pendingSync = []; } // Initialize IndexedDB async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { console.error('[Offline] Failed to open database'); reject(request.error); }; request.onsuccess = () => { this.db = request.result; this.isReady = true; console.log('[Offline] Database ready'); resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Projects store if (!db.objectStoreNames.contains('projects')) { const projectStore = db.createObjectStore('projects', { keyPath: 'id' }); projectStore.createIndex('name', 'name', { unique: false }); } // Columns store if (!db.objectStoreNames.contains('columns')) { const columnStore = db.createObjectStore('columns', { keyPath: 'id' }); columnStore.createIndex('project_id', 'project_id', { unique: false }); columnStore.createIndex('position', 'position', { unique: false }); } // Tasks store if (!db.objectStoreNames.contains('tasks')) { const taskStore = db.createObjectStore('tasks', { keyPath: 'id' }); taskStore.createIndex('project_id', 'project_id', { unique: false }); taskStore.createIndex('column_id', 'column_id', { unique: false }); taskStore.createIndex('assignee_id', 'assignee_id', { unique: false }); taskStore.createIndex('due_date', 'due_date', { unique: false }); } // Labels store if (!db.objectStoreNames.contains('labels')) { const labelStore = db.createObjectStore('labels', { keyPath: 'id' }); labelStore.createIndex('project_id', 'project_id', { unique: false }); } // Pending operations store (for offline sync) if (!db.objectStoreNames.contains('pending_operations')) { const pendingStore = db.createObjectStore('pending_operations', { keyPath: 'id', autoIncrement: true }); pendingStore.createIndex('timestamp', 'timestamp', { unique: false }); pendingStore.createIndex('type', 'type', { unique: false }); } // Cache metadata store if (!db.objectStoreNames.contains('cache_meta')) { db.createObjectStore('cache_meta', { keyPath: 'key' }); } console.log('[Offline] Database schema created'); }; }); } // Generic CRUD operations async put(storeName, data) { if (!this.db) return; return new Promise((resolve, reject) => { const transaction = this.db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.put(data); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async get(storeName, key) { if (!this.db) return null; return new Promise((resolve, reject) => { const transaction = this.db.transaction(storeName, 'readonly'); const store = transaction.objectStore(storeName); const request = store.get(key); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async getAll(storeName, indexName = null, query = null) { if (!this.db) return []; return new Promise((resolve, reject) => { const transaction = this.db.transaction(storeName, 'readonly'); const store = transaction.objectStore(storeName); const target = indexName ? store.index(indexName) : store; const request = query ? target.getAll(query) : target.getAll(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } async delete(storeName, key) { if (!this.db) return; return new Promise((resolve, reject) => { const transaction = this.db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.delete(key); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async clear(storeName) { if (!this.db) return; return new Promise((resolve, reject) => { const transaction = this.db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } // ===================== // DATA CACHING // ===================== async cacheProjects(projects) { await this.clear('projects'); for (const project of projects) { await this.put('projects', project); } await this.setCacheMeta('projects', Date.now()); } async getCachedProjects() { return this.getAll('projects'); } async cacheColumns(projectId, columns) { // Clear existing columns for project const existing = await this.getAll('columns', 'project_id', projectId); for (const col of existing) { await this.delete('columns', col.id); } // Store new columns for (const column of columns) { await this.put('columns', { ...column, project_id: projectId }); } await this.setCacheMeta(`columns_${projectId}`, Date.now()); } async getCachedColumns(projectId) { return this.getAll('columns', 'project_id', projectId); } async cacheTasks(projectId, tasks) { // Clear existing tasks for project const existing = await this.getAll('tasks', 'project_id', projectId); for (const task of existing) { await this.delete('tasks', task.id); } // Store new tasks for (const task of tasks) { await this.put('tasks', { ...task, project_id: projectId }); } await this.setCacheMeta(`tasks_${projectId}`, Date.now()); } async getCachedTasks(projectId) { return this.getAll('tasks', 'project_id', projectId); } async cacheLabels(projectId, labels) { const existing = await this.getAll('labels', 'project_id', projectId); for (const label of existing) { await this.delete('labels', label.id); } for (const label of labels) { await this.put('labels', { ...label, project_id: projectId }); } await this.setCacheMeta(`labels_${projectId}`, Date.now()); } async getCachedLabels(projectId) { return this.getAll('labels', 'project_id', projectId); } // Cache metadata async setCacheMeta(key, timestamp) { await this.put('cache_meta', { key, timestamp }); } async getCacheMeta(key) { return this.get('cache_meta', key); } async isCacheValid(key, maxAge = 5 * 60 * 1000) { const meta = await this.getCacheMeta(key); if (!meta) return false; return Date.now() - meta.timestamp < maxAge; } // ===================== // OFFLINE OPERATIONS // ===================== async queueOperation(operation) { const pendingOp = { ...operation, timestamp: Date.now(), synced: false }; await this.put('pending_operations', pendingOp); store.setSyncStatus('offline'); return pendingOp; } async getPendingOperations() { return this.getAll('pending_operations'); } async markOperationSynced(operationId) { await this.delete('pending_operations', operationId); } async clearPendingOperations() { await this.clear('pending_operations'); } // Sync pending operations with server async syncPendingOperations() { if (!navigator.onLine) { return { success: false, reason: 'offline' }; } const operations = await this.getPendingOperations(); if (operations.length === 0) { return { success: true, synced: 0 }; } console.log(`[Offline] Syncing ${operations.length} pending operations`); store.setSyncStatus('syncing'); let syncedCount = 0; const errors = []; // Sort by timestamp operations.sort((a, b) => a.timestamp - b.timestamp); for (const op of operations) { try { await this.executePendingOperation(op); await this.markOperationSynced(op.id); syncedCount++; } catch (error) { console.error(`[Offline] Failed to sync operation:`, op, error); errors.push({ operation: op, error: error.message }); // Stop on auth errors if (error.status === 401) { break; } } } const remaining = await this.getPendingOperations(); store.setSyncStatus(remaining.length > 0 ? 'offline' : 'synced'); return { success: errors.length === 0, synced: syncedCount, errors }; } async executePendingOperation(op) { const projectId = op.projectId; switch (op.type) { case 'task:create': const newTask = await api.createTask(projectId, op.data); // Update local ID mapping if (op.tempId) { await this.updateTaskId(op.tempId, newTask.id); } break; case 'task:update': await api.updateTask(projectId, op.taskId, op.data); break; case 'task:delete': await api.deleteTask(projectId, op.taskId); break; case 'task:move': await api.moveTask(projectId, op.taskId, op.columnId, op.position); break; case 'column:create': const newColumn = await api.createColumn(projectId, op.data); if (op.tempId) { await this.updateColumnId(op.tempId, newColumn.id); } break; case 'column:update': await api.updateColumn(projectId, op.columnId, op.data); break; case 'column:delete': await api.deleteColumn(projectId, op.columnId); break; case 'subtask:create': await api.createSubtask(projectId, op.taskId, op.data); break; case 'subtask:update': await api.updateSubtask(projectId, op.taskId, op.subtaskId, op.data); break; case 'comment:create': await api.createComment(projectId, op.taskId, op.data); break; default: console.warn(`[Offline] Unknown operation type: ${op.type}`); } } async updateTaskId(tempId, realId) { // Update in cache const task = await this.get('tasks', tempId); if (task) { await this.delete('tasks', tempId); await this.put('tasks', { ...task, id: realId }); } // Update pending operations that reference this task const pending = await this.getPendingOperations(); for (const op of pending) { if (op.taskId === tempId) { await this.put('pending_operations', { ...op, taskId: realId }); } } } async updateColumnId(tempId, realId) { const column = await this.get('columns', tempId); if (column) { await this.delete('columns', tempId); await this.put('columns', { ...column, id: realId }); } // Update tasks in this column const tasks = await this.getAll('tasks'); for (const task of tasks) { if (task.column_id === tempId) { await this.put('tasks', { ...task, column_id: realId }); } } } // ===================== // OFFLINE DATA LOADING // ===================== async loadOfflineData() { const projectId = store.get('currentProjectId'); if (!projectId) return; console.log('[Offline] Loading cached data'); try { // Load from cache const [columns, tasks, labels] = await Promise.all([ this.getCachedColumns(projectId), this.getCachedTasks(projectId), this.getCachedLabels(projectId) ]); store.setColumns(columns); store.setTasks(tasks); store.setLabels(labels); console.log('[Offline] Loaded cached data:', { columns: columns.length, tasks: tasks.length, labels: labels.length }); } catch (error) { console.error('[Offline] Failed to load cached data:', error); } } // ===================== // HELPERS // ===================== hasPendingOperations() { return this.getPendingOperations().then(ops => ops.length > 0); } async getPendingOperationCount() { const ops = await this.getPendingOperations(); return ops.length; } // Clear all cached data async clearAllData() { const stores = ['projects', 'columns', 'tasks', 'labels', 'pending_operations', 'cache_meta']; for (const storeName of stores) { await this.clear(storeName); } console.log('[Offline] Cleared all cached data'); } } // Create singleton instance const offlineManager = new OfflineManager(); // Initialize on load offlineManager.init().catch(console.error); // Listen for online event to sync window.addEventListener('online', async () => { console.log('[Offline] Back online, starting sync'); await offlineManager.syncPendingOperations(); }); // Subscribe to store changes to cache data store.subscribe('columns', async (columns) => { const projectId = store.get('currentProjectId'); if (projectId && columns.length > 0) { await offlineManager.cacheColumns(projectId, columns); } }); store.subscribe('tasks', async (tasks) => { const projectId = store.get('currentProjectId'); if (projectId && tasks.length > 0) { await offlineManager.cacheTasks(projectId, tasks); } }); store.subscribe('labels', async (labels) => { const projectId = store.get('currentProjectId'); if (projectId && labels.length > 0) { await offlineManager.cacheLabels(projectId, labels); } }); store.subscribe('projects', async (projects) => { if (projects.length > 0) { await offlineManager.cacheProjects(projects); } }); export default offlineManager;