mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 18:38:46 -05:00
1ebfbf39de
Performance: - Fix N+1 queries in reports.py with joinedload for TimeEntry.project, TimeEntry.user, TimeEntry.task, and Project.client across 6 query locations - Replace per-task time_entries loops with batch UPDATE queries in tasks.py - Use efficient subquery for favorite project IDs in projects.py Architecture: - Add get_by_id() and get_by_name() methods to ProjectService and ClientService - Route project/client lookups through service layer in timer.py, projects.py, and clients.py instead of direct Model.query calls Security: - Add sanitize_input() with length limits to form inputs in clients.py, projects.py, timer.py, issues.py, and auth.py - Add email format validation for client creation - Warn at startup when SECRET_KEY uses the default value or is too short in ProductionConfig - Replace 7 bare except: pass clauses with specific exception types (OSError, IOError, TypeError, ValueError) in admin.py, settings.py, and invoice.py Authorization: - Migrate all @admin_required decorators to @admin_or_permission_required() with granular permissions (manage_roles, manage_kanban, manage_webhooks, manage_api_tokens, manage_integrations, access_admin) across permissions.py, kanban.py, webhooks.py, and admin.py (28 routes total) Frontend: - Remove 40+ console.log debug statements across 18 JS files - Replace 42 inline onclick/onchange handlers in base.html with delegated event listeners using data-dropdown and data-no-propagation attributes - Migrate 6 inline handlers in time_entries_overview.html to addEventListener - Extract shared typing detection into typing-utils.js, eliminating 5 duplicate isTyping() implementations across keyboard shortcut files - Add missing aria-label attributes to icon-only buttons Dependencies: - Migrate from pytz to stdlib zoneinfo (Python 3.9+) across all 6 files that used pytz; replace pytz with tzdata in requirements.txt - Separate dev/test dependencies into requirements-dev.txt - Configure RotatingFileHandler (10MB, 5 backups) for app and JSON logs Co-authored-by: Cursor <cursoragent@cursor.com>
929 lines
35 KiB
JavaScript
929 lines
35 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', () => {
|
|
this.syncAll();
|
|
});
|
|
|
|
window.addEventListener('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 => {
|
|
// no-op
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
} 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');
|
|
const indicatorText = document.getElementById('offline-indicator-text');
|
|
const indicatorIcon = document.getElementById('offline-indicator-icon');
|
|
const syncButton = document.getElementById('offline-sync-button');
|
|
|
|
if (indicator) {
|
|
if (!isOnline) {
|
|
indicator.classList.remove('hidden');
|
|
indicator.className = indicator.className.replace(/bg-\w+-\d+ dark:bg-\w+-\d+/, 'bg-yellow-500 dark:bg-yellow-600');
|
|
if (indicatorText) indicatorText.textContent = 'You are offline. Changes will sync when you reconnect.';
|
|
if (indicatorIcon) indicatorIcon.className = 'fas fa-wifi-slash';
|
|
if (syncButton) syncButton.style.display = 'none';
|
|
} else if (hasPending && !isSyncing) {
|
|
indicator.classList.remove('hidden');
|
|
indicator.className = indicator.className.replace(/bg-\w+-\d+ dark:bg-\w+-\d+/, 'bg-blue-500 dark:bg-blue-600');
|
|
if (indicatorText) indicatorText.textContent = `${this.pendingSyncCount} item(s) pending sync.`;
|
|
if (indicatorIcon) indicatorIcon.className = 'fas fa-clock';
|
|
if (syncButton) syncButton.style.display = 'inline-block';
|
|
} else if (isSyncing) {
|
|
indicator.classList.remove('hidden');
|
|
indicator.className = indicator.className.replace(/bg-\w+-\d+ dark:bg-\w+-\d+/, 'bg-blue-500 dark:bg-blue-600');
|
|
if (indicatorText) indicatorText.textContent = 'Syncing...';
|
|
if (indicatorIcon) indicatorIcon.className = 'fas fa-sync-alt fa-spin';
|
|
if (syncButton) syncButton.style.display = 'none';
|
|
} 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) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
// Save offline
|
|
return await this.saveProjectOffline(data);
|
|
}
|
|
|
|
async getPendingCount() {
|
|
return this.pendingSyncCount;
|
|
}
|
|
|
|
async forceSync() {
|
|
await this.syncAll();
|
|
}
|
|
}
|
|
|
|
// Initialize singleton
|
|
window.offlineSyncManager = new OfflineSyncManager();
|
|
|