Initial commit
Dieser Commit ist enthalten in:
501
frontend/js/offline.js
Normale Datei
501
frontend/js/offline.js
Normale Datei
@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren