Dieser Commit ist enthalten in:
Claude Project Manager
2025-12-28 21:36:45 +00:00
Commit ab1e5be9a9
146 geänderte Dateien mit 65525 neuen und 0 gelöschten Zeilen

501
frontend/js/offline.js Normale Datei
Datei anzeigen

@ -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;