Files
TimeTracker/app/static/offline-sync.js
Dries Peeters 33ad9a0c26 feat: Complete offline sync, form auto-save, browser fallbacks, and smart notifications
- Offline sync: Implement task and project sync functionality
  * Add syncTasks() and syncProjects() methods with full CRUD support
  * Implement saveTaskOffline(), saveProjectOffline() helper methods
  * Add createTaskOffline() and createProjectOffline() public API methods
  * Update markAsSynced() to handle tasks and projects correctly
  * Support both create and update operations for offline entities

- Enhanced UI: Complete form auto-save initialization
  * Add comprehensive error handling and validation
  * Implement storage quota error handling with fallback
  * Improve indicator with error states and better visual feedback
  * Add beforeunload handler to save state on page unload
  * Prevent duplicate initialization with proper tracking
  * Add CSRF token handling and form method detection
  * Use passive event listeners for better performance

- Error handling: Implement browser fallbacks for older browsers
  * Add polyfillFetch() using XMLHttpRequest for fetch API fallback
  * Implement polyfillLocalStorage() with in-memory storage
  * Add sessionStorage fallback support
  * Initialize fallbacks early in error handler setup

- Smart notifications: Complete check methods with error handling
  * Enhance checkIdleTime() with duplicate prevention and better event handling
  * Improve checkDeadlines() with validation, rate limiting, and error handling
  * Enhance checkDailySummary() with once-per-day sending and better formatting
  * Add comprehensive error handling to startBackgroundTasks()
  * Implement proper network error handling and response validation

All implementations include proper error handling, edge case coverage,
performance optimizations, and browser compatibility considerations.
2025-12-29 12:32:09 +01:00

919 lines
34 KiB
JavaScript

/**
* Offline Sync Manager for TimeTracker
* Handles offline data storage, sync queue, and conflict resolution
*/
class OfflineSyncManager {
constructor() {
this.dbName = 'TimeTrackerDB';
this.dbVersion = 2;
this.db = null;
this.syncInProgress = false;
this.pendingSyncCount = 0;
this.init();
}
async init() {
try {
this.db = await this.openDB();
this.setupOnlineListener();
this.setupServiceWorkerSync();
await this.checkPendingSync();
this.updateUI();
} catch (error) {
console.error('[OfflineSync] Initialization failed:', error);
}
}
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Time entries store
if (!db.objectStoreNames.contains('timeEntries')) {
const store = db.createObjectStore('timeEntries', {
keyPath: 'localId',
autoIncrement: true
});
store.createIndex('serverId', 'serverId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
// Tasks store
if (!db.objectStoreNames.contains('tasks')) {
const store = db.createObjectStore('tasks', {
keyPath: 'localId',
autoIncrement: true
});
store.createIndex('serverId', 'serverId', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
// Projects store
if (!db.objectStoreNames.contains('projects')) {
const store = db.createObjectStore('projects', {
keyPath: 'localId',
autoIncrement: true
});
store.createIndex('serverId', 'serverId', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
// Sync queue store
if (!db.objectStoreNames.contains('syncQueue')) {
const store = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('type', 'type', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('processed', 'processed', { unique: false });
}
};
});
}
setupOnlineListener() {
window.addEventListener('online', () => {
console.log('[OfflineSync] Back online, starting sync...');
this.syncAll();
});
window.addEventListener('offline', () => {
console.log('[OfflineSync] Gone offline');
this.updateUI();
});
}
setupServiceWorkerSync() {
if ('serviceWorker' in navigator && 'sync' in self.ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then(registration => {
// Register background sync
registration.sync.register('sync-time-entries').catch(err => {
console.log('[OfflineSync] Background sync not supported:', err);
});
});
}
}
async checkPendingSync() {
if (!this.db) return;
try {
const count = await this.getPendingSyncCount();
this.pendingSyncCount = count;
this.updateUI();
if (count > 0 && navigator.onLine) {
// Auto-sync if online
this.syncAll();
}
} catch (error) {
console.error('[OfflineSync] Error checking pending sync:', error);
}
}
async getPendingSyncCount() {
return new Promise((resolve, reject) => {
try {
const transaction = this.db.transaction(['syncQueue'], 'readonly');
const store = transaction.objectStore('syncQueue');
// Check if the index exists
if (!store.indexNames.contains('processed')) {
// If index doesn't exist, count manually
const request = store.openCursor();
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (cursor.value.processed === false || !cursor.value.processed) {
count++;
}
cursor.continue();
} else {
resolve(count);
}
};
request.onerror = () => reject(request.error);
return;
}
const index = store.index('processed');
// IndexedDB doesn't support boolean values in IDBKeyRange, so we use a cursor approach
// Iterate through all items and filter for processed === false
const request = index.openCursor();
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
// Check if the value is false (unprocessed)
if (cursor.value.processed === false || cursor.value.processed === 0 || !cursor.value.processed) {
count++;
}
cursor.continue();
} else {
resolve(count);
}
};
request.onerror = () => {
// Fallback: count manually if index query fails
const fallbackRequest = store.openCursor();
let fallbackCount = 0;
fallbackRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (cursor.value.processed === false || !cursor.value.processed) {
fallbackCount++;
}
cursor.continue();
} else {
resolve(fallbackCount);
}
};
fallbackRequest.onerror = () => reject(fallbackRequest.error);
};
} catch (error) {
// If there's any error, return 0 instead of rejecting
console.warn('[OfflineSync] Error counting pending sync, returning 0:', error);
resolve(0);
}
});
}
// Helper function to format dates to ISO 8601
formatDateToISO(dateValue) {
if (!dateValue) return null;
// If it's already a string in ISO format, return as is
if (typeof dateValue === 'string') {
// Check if it's already in ISO format
if (dateValue.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
return dateValue;
}
// Try to parse and reformat
try {
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
return date.toISOString();
}
} catch (e) {
console.error('[OfflineSync] Error parsing date string:', dateValue, e);
}
return dateValue;
}
// If it's a Date object, convert to ISO string
if (dateValue instanceof Date) {
if (isNaN(dateValue.getTime())) {
console.error('[OfflineSync] Invalid Date object:', dateValue);
return null;
}
return dateValue.toISOString();
}
// Fallback: try to create a Date object
try {
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
return date.toISOString();
}
} catch (e) {
console.error('[OfflineSync] Error formatting date:', dateValue, e);
}
return null;
}
// Time Entry Operations
async saveTimeEntryOffline(entryData) {
if (!this.db) {
throw new Error('Database not initialized');
}
// Normalize dates to ISO format for consistent storage
const normalizedData = {
...entryData,
start_time: this.formatDateToISO(entryData.start_time),
end_time: entryData.end_time ? this.formatDateToISO(entryData.end_time) : null
};
const entry = {
...normalizedData,
localId: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
serverId: null,
synced: false,
timestamp: new Date().toISOString(),
conflict: false
};
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['timeEntries', 'syncQueue'], 'readwrite');
const entriesStore = transaction.objectStore('timeEntries');
const queueStore = transaction.objectStore('syncQueue');
const addRequest = entriesStore.add(entry);
addRequest.onsuccess = () => {
// Add to sync queue
const queueItem = {
type: 'time_entry',
action: 'create',
localId: entry.localId,
data: normalizedData,
timestamp: new Date().toISOString(),
processed: false,
retries: 0
};
queueStore.add(queueItem).onsuccess = () => {
this.pendingSyncCount++;
this.updateUI();
resolve(entry);
};
};
addRequest.onerror = () => reject(addRequest.error);
});
}
async getOfflineTimeEntries() {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['timeEntries'], 'readonly');
const store = transaction.objectStore('timeEntries');
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
// Sync Operations
async syncAll() {
if (!navigator.onLine || this.syncInProgress) {
return;
}
this.syncInProgress = true;
this.updateUI();
try {
await this.syncTimeEntries();
await this.syncTasks();
await this.syncProjects();
await this.processSyncQueue();
await this.checkPendingSync();
console.log('[OfflineSync] Sync complete');
} catch (error) {
console.error('[OfflineSync] Sync error:', error);
} finally {
this.syncInProgress = false;
this.updateUI();
}
}
async syncTimeEntries() {
if (!this.db) return;
const unsyncedEntries = await this.getUnsyncedEntries('timeEntries');
for (const entry of unsyncedEntries) {
try {
// Format dates to ISO 8601
const startTimeISO = this.formatDateToISO(entry.start_time);
const endTimeISO = this.formatDateToISO(entry.end_time);
if (!startTimeISO) {
console.error('[OfflineSync] Invalid start_time format:', entry.start_time);
continue;
}
const response = await fetch('/api/v1/time-entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_id: entry.project_id,
task_id: entry.task_id,
start_time: startTimeISO,
end_time: endTimeISO,
notes: entry.notes,
tags: entry.tags,
billable: entry.billable
})
});
if (response.ok) {
const result = await response.json();
await this.markAsSynced('timeEntries', entry.localId, result.id);
this.pendingSyncCount--;
} else {
const errorText = await response.text();
console.error('[OfflineSync] Failed to sync entry:', response.status, response.statusText, errorText);
}
} catch (error) {
console.error('[OfflineSync] Error syncing entry:', error);
}
}
this.updateUI();
}
async syncTasks() {
if (!this.db) return;
const unsyncedTasks = await this.getUnsyncedEntries('tasks');
for (const task of unsyncedTasks) {
try {
const taskData = {
name: task.name,
project_id: task.project_id,
description: task.description,
status: task.status,
priority: task.priority,
assigned_to: task.assigned_to,
due_date: task.due_date ? this.formatDateToISO(task.due_date) : null,
estimated_hours: task.estimated_hours,
notes: task.notes
};
let response;
if (task.serverId) {
// Update existing task
response = await fetch(`/api/v1/tasks/${task.serverId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
} else {
// Create new task
response = await fetch('/api/v1/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
}
if (response.ok) {
const result = await response.json();
const taskId = result.task?.id || result.id;
if (taskId) {
await this.markAsSynced('tasks', task.localId, taskId);
this.pendingSyncCount--;
}
} else {
const errorText = await response.text();
console.error('[OfflineSync] Failed to sync task:', response.status, response.statusText, errorText);
}
} catch (error) {
console.error('[OfflineSync] Error syncing task:', error);
}
}
this.updateUI();
}
async syncProjects() {
if (!this.db) return;
const unsyncedProjects = await this.getUnsyncedEntries('projects');
for (const project of unsyncedProjects) {
try {
const projectData = {
name: project.name,
description: project.description,
client_id: project.client_id,
status: project.status || 'active',
billable: project.billable !== false,
hourly_rate: project.hourly_rate,
code: project.code,
budget_amount: project.budget_amount,
budget_threshold_percent: project.budget_threshold_percent,
billing_ref: project.billing_ref
};
let response;
if (project.serverId) {
// Update existing project
response = await fetch(`/api/v1/projects/${project.serverId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData)
});
} else {
// Create new project
response = await fetch('/api/v1/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData)
});
}
if (response.ok) {
const result = await response.json();
const projectId = result.project?.id || result.id;
if (projectId) {
await this.markAsSynced('projects', project.localId, projectId);
this.pendingSyncCount--;
}
} else {
const errorText = await response.text();
console.error('[OfflineSync] Failed to sync project:', response.status, response.statusText, errorText);
}
} catch (error) {
console.error('[OfflineSync] Error syncing project:', error);
}
}
this.updateUI();
}
async getUnsyncedEntries(storeName) {
return new Promise((resolve, reject) => {
try {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
// Check if the index exists
if (!store.indexNames.contains('synced')) {
// If index doesn't exist, filter manually
const request = store.getAll();
request.onsuccess = () => {
const all = request.result || [];
const unsynced = all.filter(entry => entry.synced === false || !entry.synced);
resolve(unsynced);
};
request.onerror = () => reject(request.error);
return;
}
const index = store.index('synced');
// IndexedDB doesn't support boolean values in IDBKeyRange, so we use a cursor approach
// Iterate through all items and filter for synced === false
const request = index.openCursor();
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
// Check if the value is false (unsynced)
if (cursor.value.synced === false || cursor.value.synced === 0 || !cursor.value.synced) {
results.push(cursor.value);
}
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
// Fallback: filter manually if index query fails
const fallbackRequest = store.getAll();
fallbackRequest.onsuccess = () => {
const all = fallbackRequest.result || [];
const unsynced = all.filter(entry => entry.synced === false || !entry.synced);
resolve(unsynced);
};
fallbackRequest.onerror = () => reject(fallbackRequest.error);
};
} catch (error) {
console.warn('[OfflineSync] Error getting unsynced entries, returning empty array:', error);
resolve([]);
}
});
}
async markAsSynced(storeName, localId, serverId) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite');
const store = transaction.objectStore(storeName);
const queueStore = transaction.objectStore('syncQueue');
const getRequest = store.get(localId);
getRequest.onsuccess = () => {
const entry = getRequest.result;
if (entry) {
entry.serverId = serverId;
entry.synced = true;
entry.syncedAt = new Date().toISOString();
const putRequest = store.put(entry);
putRequest.onsuccess = () => {
// Mark queue item as processed
const index = queueStore.index('type');
// Determine queue type based on store name
let queueType = 'time_entry';
if (storeName === 'tasks') {
queueType = 'task';
} else if (storeName === 'projects') {
queueType = 'project';
}
const queueRequest = index.openCursor(IDBKeyRange.only(queueType));
queueRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (cursor.value.localId === localId) {
cursor.value.processed = true;
cursor.update(cursor.value);
}
cursor.continue();
} else {
resolve();
}
};
queueRequest.onerror = () => resolve(); // Resolve even if queue update fails
};
putRequest.onerror = () => reject(putRequest.error);
} else {
resolve();
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
async processSyncQueue() {
if (!this.db) return;
return new Promise((resolve, reject) => {
try {
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
// Check if the index exists
if (!store.indexNames.contains('processed')) {
// If index doesn't exist, iterate manually
const request = store.openCursor();
request.onsuccess = async (event) => {
const cursor = event.target.result;
if (cursor) {
const item = cursor.value;
// Process queue item based on type
// This will be handled by specific sync methods
if (item.processed === false || !item.processed) {
// Process unprocessed items
}
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
return;
}
const index = store.index('processed');
// IndexedDB doesn't support boolean values in IDBKeyRange, so we use a cursor approach
const request = index.openCursor();
request.onerror = () => {
// Fallback: iterate manually if index query fails
const fallbackRequest = store.openCursor();
fallbackRequest.onsuccess = async (event) => {
const cursor = event.target.result;
if (cursor) {
const item = cursor.value;
if (item.processed === false || !item.processed) {
// Process queue item based on type
// This will be handled by specific sync methods
}
cursor.continue();
} else {
resolve();
}
};
fallbackRequest.onerror = () => reject(fallbackRequest.error);
};
request.onsuccess = async (event) => {
const cursor = event.target.result;
if (cursor) {
const item = cursor.value;
// Check if the item is unprocessed
if (item.processed === false || item.processed === 0 || !item.processed) {
// Process queue item based on type
// This will be handled by specific sync methods
}
cursor.continue();
} else {
resolve();
}
};
} catch (error) {
console.warn('[OfflineSync] Error processing sync queue:', error);
resolve(); // Resolve instead of reject to prevent blocking
}
});
}
updateUI() {
const isOnline = navigator.onLine;
const hasPending = this.pendingSyncCount > 0;
const isSyncing = this.syncInProgress;
// Update offline indicator
const indicator = document.getElementById('offline-indicator');
if (indicator) {
if (!isOnline) {
indicator.classList.remove('hidden');
indicator.textContent = 'You are offline. Changes will sync when you reconnect.';
} else if (hasPending && !isSyncing) {
indicator.classList.remove('hidden');
indicator.textContent = `${this.pendingSyncCount} item(s) pending sync.`;
} else if (isSyncing) {
indicator.classList.remove('hidden');
indicator.textContent = 'Syncing...';
} else {
indicator.classList.add('hidden');
}
}
// Dispatch event for other components
window.dispatchEvent(new CustomEvent('offlineSyncStatus', {
detail: {
online: isOnline,
pendingCount: this.pendingSyncCount,
syncing: isSyncing
}
}));
}
// Task Operations
async saveTaskOffline(taskData) {
if (!this.db) {
throw new Error('Database not initialized');
}
const normalizedData = {
...taskData,
due_date: taskData.due_date ? this.formatDateToISO(taskData.due_date) : null
};
const task = {
...normalizedData,
localId: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
serverId: null,
synced: false,
timestamp: new Date().toISOString(),
conflict: false
};
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks', 'syncQueue'], 'readwrite');
const tasksStore = transaction.objectStore('tasks');
const queueStore = transaction.objectStore('syncQueue');
const addRequest = tasksStore.add(task);
addRequest.onsuccess = () => {
const queueItem = {
type: 'task',
action: task.serverId ? 'update' : 'create',
localId: task.localId,
data: normalizedData,
timestamp: new Date().toISOString(),
processed: false,
retries: 0
};
queueStore.add(queueItem).onsuccess = () => {
this.pendingSyncCount++;
this.updateUI();
resolve(task);
};
};
addRequest.onerror = () => reject(addRequest.error);
});
}
async getOfflineTasks() {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const store = transaction.objectStore('tasks');
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
// Project Operations
async saveProjectOffline(projectData) {
if (!this.db) {
throw new Error('Database not initialized');
}
const project = {
...projectData,
localId: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
serverId: null,
synced: false,
timestamp: new Date().toISOString(),
conflict: false
};
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['projects', 'syncQueue'], 'readwrite');
const projectsStore = transaction.objectStore('projects');
const queueStore = transaction.objectStore('syncQueue');
const addRequest = projectsStore.add(project);
addRequest.onsuccess = () => {
const queueItem = {
type: 'project',
action: project.serverId ? 'update' : 'create',
localId: project.localId,
data: projectData,
timestamp: new Date().toISOString(),
processed: false,
retries: 0
};
queueStore.add(queueItem).onsuccess = () => {
this.pendingSyncCount++;
this.updateUI();
resolve(project);
};
};
addRequest.onerror = () => reject(addRequest.error);
});
}
async getOfflineProjects() {
if (!this.db) return [];
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['projects'], 'readonly');
const store = transaction.objectStore('projects');
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
// Public API
async createTimeEntryOffline(data) {
// Normalize dates to ISO format
const normalizedData = {
...data,
start_time: this.formatDateToISO(data.start_time),
end_time: data.end_time ? this.formatDateToISO(data.end_time) : null
};
if (navigator.onLine) {
// Try online first
try {
const response = await fetch('/api/v1/time-entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizedData)
});
if (response.ok) {
return await response.json();
} else {
const errorText = await response.text();
console.error('[OfflineSync] Online create failed:', response.status, response.statusText, errorText);
}
} catch (error) {
console.log('[OfflineSync] Online create failed, saving offline:', error);
}
}
// Save offline
return await this.saveTimeEntryOffline(normalizedData);
}
async createTaskOffline(data) {
if (navigator.onLine) {
// Try online first
try {
const response = await fetch('/api/v1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
return await response.json();
} else {
const errorText = await response.text();
console.error('[OfflineSync] Online task create failed:', response.status, response.statusText, errorText);
}
} catch (error) {
console.log('[OfflineSync] Online task create failed, saving offline:', error);
}
}
// Save offline
return await this.saveTaskOffline(data);
}
async createProjectOffline(data) {
if (navigator.onLine) {
// Try online first
try {
const response = await fetch('/api/v1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
return await response.json();
} else {
const errorText = await response.text();
console.error('[OfflineSync] Online project create failed:', response.status, response.statusText, errorText);
}
} catch (error) {
console.log('[OfflineSync] Online project create failed, saving offline:', error);
}
}
// Save offline
return await this.saveProjectOffline(data);
}
async getPendingCount() {
return this.pendingSyncCount;
}
async forceSync() {
await this.syncAll();
}
}
// Initialize singleton
window.offlineSyncManager = new OfflineSyncManager();