502 Zeilen
14 KiB
JavaScript
502 Zeilen
14 KiB
JavaScript
/**
|
|
* 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;
|