diff --git a/Dockerfile b/Dockerfile index 48658da..aac0e05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -130,9 +130,8 @@ USER timetracker # Expose port EXPOSE 8080 -# Health check (liveness) -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:8080/_health || exit 1 +# Note: Health check is configured in docker-compose.yml +# This allows different healthcheck settings per environment # Set the entrypoint ENTRYPOINT ["/app/docker/entrypoint_fixed.sh"] diff --git a/app/routes/clients.py b/app/routes/clients.py index 4f3fee1..2f90077 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -284,9 +284,20 @@ def view_client(client_id): # Get link templates for custom fields (for clickable values) from app.models import LinkTemplate + from sqlalchemy.exc import ProgrammingError link_templates_by_field = {} - for template in LinkTemplate.get_active_templates(): - link_templates_by_field[template.field_key] = template + try: + for template in LinkTemplate.get_active_templates(): + link_templates_by_field[template.field_key] = template + except ProgrammingError as e: + # Handle case where link_templates table doesn't exist (migration not run) + if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): + current_app.logger.warning( + "link_templates table does not exist. Run migration: flask db upgrade" + ) + link_templates_by_field = {} + else: + raise # Get recent time entries for this client # Include entries directly linked to client and entries through projects diff --git a/app/static/offline-sync.js b/app/static/offline-sync.js index 61377db..4d61e60 100644 --- a/app/static/offline-sync.js +++ b/app/static/offline-sync.js @@ -132,14 +132,65 @@ class OfflineSyncManager { }); } + // 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'); } - const entry = { + // 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, @@ -160,7 +211,7 @@ class OfflineSyncManager { type: 'time_entry', action: 'create', localId: entry.localId, - data: entryData, + data: normalizedData, timestamp: new Date().toISOString(), processed: false, retries: 0 @@ -222,6 +273,15 @@ class OfflineSyncManager { 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: { @@ -230,8 +290,8 @@ class OfflineSyncManager { body: JSON.stringify({ project_id: entry.project_id, task_id: entry.task_id, - start_time: entry.start_time, - end_time: entry.end_time, + start_time: startTimeISO, + end_time: endTimeISO, notes: entry.notes, tags: entry.tags, billable: entry.billable @@ -243,7 +303,8 @@ class OfflineSyncManager { await this.markAsSynced('timeEntries', entry.localId, result.id); this.pendingSyncCount--; } else { - console.error('[OfflineSync] Failed to sync entry:', response.statusText); + 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); @@ -373,17 +434,27 @@ class OfflineSyncManager { // 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(data) + 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); @@ -391,7 +462,7 @@ class OfflineSyncManager { } // Save offline - return await this.saveTimeEntryOffline(data); + return await this.saveTimeEntryOffline(normalizedData); } async getPendingCount() { diff --git a/app/templates/auth/edit_profile.html b/app/templates/auth/edit_profile.html index 855a890..68ff93a 100644 --- a/app/templates/auth/edit_profile.html +++ b/app/templates/auth/edit_profile.html @@ -63,17 +63,19 @@ {% if requires_password %}
- + +
+
{% endif %}
Cancel - +
@@ -124,6 +126,100 @@ function handleRemoveAvatar(event) { if (fallback) fallback.style.display = 'flex'; }, 100); } + +// Password validation +{% if requires_password %} +document.addEventListener('DOMContentLoaded', function() { + const passwordInput = document.getElementById('password'); + const passwordConfirmInput = document.getElementById('password_confirm'); + const passwordError = document.getElementById('password-error'); + const passwordConfirmError = document.getElementById('password_confirm-error'); + const saveButton = document.getElementById('save-button'); + const form = document.querySelector('form'); + + if (!passwordInput || !passwordConfirmInput) { + return; // Password fields not present + } + + function validatePasswords() { + const password = passwordInput.value.trim(); + const passwordConfirm = passwordConfirmInput.value.trim(); + let isValid = true; + + // Clear previous errors + if (passwordError) { + passwordError.classList.add('hidden'); + passwordError.textContent = ''; + } + if (passwordConfirmError) { + passwordConfirmError.classList.add('hidden'); + passwordConfirmError.textContent = ''; + } + + // If both fields are empty, that's valid (user wants to keep current password) + if (!password && !passwordConfirm) { + if (saveButton) { + saveButton.disabled = false; + } + return true; + } + + // If one field is filled but not the other, show error + if ((password && !passwordConfirm) || (!password && passwordConfirm)) { + if (passwordConfirmError) { + passwordConfirmError.textContent = '{{ _("Please fill both password fields or leave both empty") }}'; + passwordConfirmError.classList.remove('hidden'); + } + isValid = false; + } + + // Validate password length if password is provided + if (password) { + if (password.length < 8) { + if (passwordError) { + passwordError.textContent = '{{ _("Password must be at least 8 characters long") }}'; + passwordError.classList.remove('hidden'); + } + isValid = false; + } + + // Check if passwords match + if (passwordConfirm && password !== passwordConfirm) { + if (passwordConfirmError) { + passwordConfirmError.textContent = '{{ _("Passwords do not match") }}'; + passwordConfirmError.classList.remove('hidden'); + } + isValid = false; + } + } + + // Enable/disable save button based on validation + if (saveButton) { + saveButton.disabled = !isValid; + } + + return isValid; + } + + // Add event listeners for real-time validation + passwordInput.addEventListener('input', validatePasswords); + passwordInput.addEventListener('blur', validatePasswords); + passwordConfirmInput.addEventListener('input', validatePasswords); + passwordConfirmInput.addEventListener('blur', validatePasswords); + + // Validate on form submit + form.addEventListener('submit', function(e) { + if (!validatePasswords()) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }); + + // Initial validation + validatePasswords(); +}); +{% endif %} {% endblock %} diff --git a/app/utils/error_handlers.py b/app/utils/error_handlers.py index 96edaf5..575edf2 100644 --- a/app/utils/error_handlers.py +++ b/app/utils/error_handlers.py @@ -103,10 +103,19 @@ def register_error_handlers(app): if request.is_json or request.path.startswith("/api/"): return error_response(message="Database error occurred", error_code="database_error", status_code=500) - from flask import flash + from flask import flash, render_template flash("Database error occurred", "error") - return error, 500 + return ( + render_template( + "errors/500.html", + error_info={ + "title": "Database Error", + "message": "A database error occurred. Please contact support if this persists.", + }, + ), + 500, + ) @app.errorhandler(HTTPException) def handle_http_exception(error): diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 14cd64e..f7897c2 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -44,7 +44,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] + test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.local-test.yml b/docker-compose.local-test.yml index 56bb0fa..7ed8b50 100644 --- a/docker-compose.local-test.yml +++ b/docker-compose.local-test.yml @@ -43,7 +43,7 @@ services: # Use custom entrypoint for local testing entrypoint: ["/app/docker/entrypoint-local-test.sh"] healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] + test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.remote-dev.yml b/docker-compose.remote-dev.yml index cd74968..acb5a2b 100644 --- a/docker-compose.remote-dev.yml +++ b/docker-compose.remote-dev.yml @@ -40,7 +40,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] + test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml index eeebf55..56614e2 100644 --- a/docker-compose.remote.yml +++ b/docker-compose.remote.yml @@ -67,7 +67,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] + test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index e3bf030..b3c6375 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,7 +95,7 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] + test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:8080/_health"] interval: 30s timeout: 10s retries: 3