// Personal Dashboard JavaScript with HTMX Integration // Constants const AUTO_REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds // Timeline Calendar Initialization function initTimelineCalendar(calendarId, nowLineId, untimedSectionId) { const calendar = document.getElementById(calendarId); if (!calendar) return; const events = calendar.querySelectorAll('.calendar-event'); const hourHeight = 40; const startHour = parseInt(calendar.dataset.startHour) || 8; const nowHour = parseInt(calendar.dataset.nowHour); const nowMinute = parseInt(calendar.dataset.nowMinute); // Build event data for overlap detection const eventData = []; events.forEach(function(el) { const hour = parseInt(el.dataset.hour); const minute = parseInt(el.dataset.minute); const endHourVal = parseInt(el.dataset.endHour); const endMinute = parseInt(el.dataset.endMinute); const startMin = hour * 60 + minute; let endMin; if (endHourVal > hour || (endHourVal === hour && endMinute > minute)) { endMin = endHourVal * 60 + endMinute; } else { endMin = startMin + 55; } eventData.push({ el, startMin, endMin, column: 0 }); }); // Assign columns for overlapping events eventData.sort((a, b) => a.startMin - b.startMin); for (let i = 0; i < eventData.length; i++) { const ev = eventData[i]; const overlaps = eventData.filter((other, j) => j < i && other.endMin > ev.startMin && other.startMin < ev.endMin ); const usedCols = overlaps.map(o => o.column); let col = 0; while (usedCols.includes(col)) col++; ev.column = col; } // Position events with column-based indentation eventData.forEach(function(ev) { const el = ev.el; const hour = parseInt(el.dataset.hour); const minute = parseInt(el.dataset.minute); const top = (hour - startHour) * hourHeight + (minute / 60) * hourHeight; const durationMinutes = ev.endMin - ev.startMin; const height = Math.max(28, (durationMinutes / 60) * hourHeight - 4); el.style.top = top + 'px'; el.style.height = height + 'px'; el.style.left = (8 + ev.column * 16) + 'px'; el.style.display = 'block'; // Debounced hover effect (100ms delay) let hoverTimeout; el.addEventListener('mouseenter', function() { hoverTimeout = setTimeout(() => el.classList.add('hover-active'), 100); }); el.addEventListener('mouseleave', function() { clearTimeout(hoverTimeout); el.classList.remove('hover-active'); }); const url = el.dataset.url; if (url) { el.style.cursor = 'pointer'; el.addEventListener('click', function(e) { if (e.target.tagName !== 'INPUT') { window.open(url, '_blank'); } }); } }); // Position the "now" line if (nowLineId) { const nowLine = document.getElementById(nowLineId); if (nowLine && !isNaN(nowHour)) { const nowTop = (nowHour - startHour) * hourHeight + (nowMinute / 60) * hourHeight; if (nowTop >= 0) { nowLine.style.top = nowTop + 'px'; nowLine.style.display = 'block'; } } } // Hide untimed section if empty if (untimedSectionId) { const untimedSection = document.getElementById(untimedSectionId); if (untimedSection && untimedSection.querySelectorAll('.untimed-item').length === 0) { untimedSection.style.display = 'none'; } } } function initAllTimelineCalendars() { initTimelineCalendar('today-calendar', 'now-line', 'untimed-section'); initTimelineCalendar('tomorrow-calendar', null, 'tomorrow-untimed-section'); } // Get CSRF token from body hx-headers attribute function getCSRFToken() { const body = document.body; const headers = body.getAttribute('hx-headers'); if (headers) { try { const parsed = JSON.parse(headers); return parsed['X-CSRF-Token'] || ''; } catch (e) { console.error('Failed to parse CSRF token:', e); } } return ''; } // Track current active tab (read from URL for state persistence) const urlParams = new URLSearchParams(window.location.search); let currentTab = urlParams.get('tab') || 'timeline'; let autoRefreshTimer = null; // Initialize on page load document.addEventListener('DOMContentLoaded', function() { console.log('Dashboard initialized'); // Set up HTMX event listeners setupHtmxListeners(); // Start auto-refresh startAutoRefresh(); }); // HTMX Event Listeners function setupHtmxListeners() { // Before HTMX request document.body.addEventListener('htmx:beforeRequest', function(evt) { const target = evt.detail.target; if (target.id === 'tab-content') { // Show loading state target.classList.add('opacity-50', 'pointer-events-none'); } }); // After HTMX swap completes - reinitialize JS components document.body.addEventListener('htmx:afterSwap', function(evt) { // Initialize timeline calendars if they exist in the swapped content initAllTimelineCalendars(); }); // After HTMX request completes document.body.addEventListener('htmx:afterRequest', function(evt) { const target = evt.detail.target; if (target.id === 'tab-content') { // Hide loading state target.classList.remove('opacity-50', 'pointer-events-none'); // Update timestamp updateLastUpdatedTime(); } }); // Handle HTMX errors document.body.addEventListener('htmx:responseError', function(evt) { console.error('HTMX request failed:', evt.detail); alert('Failed to load content. Please try again.'); // Remove loading state const target = evt.detail.target; if (target) { target.classList.remove('opacity-50', 'pointer-events-none'); } }); } // Tab Management function setActiveTab(button) { // Remove active class from all tabs document.querySelectorAll('.tab-button').forEach(tab => { tab.classList.remove('tab-button-active'); }); // Add active class to clicked tab button.classList.add('tab-button-active'); // Extract tab name from hx-get attribute const endpoint = button.getAttribute('hx-get'); currentTab = endpoint.split('/').pop(); // "tasks" or "notes" console.log('Switched to tab:', currentTab); // Reset auto-refresh timer when switching tabs resetAutoRefresh(); } // Manual Refresh async function refreshData() { const button = event.target.closest('button'); const icon = button.querySelector('svg'); // Show loading state button.disabled = true; if (icon) icon.classList.add('animate-spin'); try { // Force API refresh (updates cache) const refreshResponse = await fetch('/api/refresh', { method: 'POST', headers: { 'X-CSRF-Token': getCSRFToken() } }); if (!refreshResponse.ok) throw new Error('Refresh failed'); // Trigger HTMX refresh on the current tab — each tab template // has hx-trigger="refresh-tasks from:body" to handle its own reload htmx.trigger(document.body, 'refresh-tasks'); // Update timestamp updateLastUpdatedTime(); // Reset auto-refresh timer resetAutoRefresh(); console.log('Manual refresh successful'); } catch (error) { console.error('Refresh failed:', error); alert('Failed to refresh data. Please try again.'); } finally { // Restore button state button.disabled = false; if (icon) icon.classList.remove('animate-spin'); } } // Auto-refresh Functions function startAutoRefresh() { if (autoRefreshTimer) { clearInterval(autoRefreshTimer); } autoRefreshTimer = setInterval(autoRefresh, AUTO_REFRESH_INTERVAL); console.log('Auto-refresh started (5 min interval)'); } function resetAutoRefresh() { clearInterval(autoRefreshTimer); startAutoRefresh(); } async function autoRefresh() { console.log(`Auto-refreshing ${currentTab} tab...`); try { // Force API refresh (updates cache) const refreshResponse = await fetch('/api/refresh', { method: 'POST', headers: { 'X-CSRF-Token': getCSRFToken() } }); if (!refreshResponse.ok) throw new Error('Refresh failed'); // Trigger HTMX refresh on the current tab — each tab template // has hx-trigger="refresh-tasks from:body" to handle its own reload htmx.trigger(document.body, 'refresh-tasks'); updateLastUpdatedTime(); console.log('Auto-refresh successful'); } catch (error) { console.error('Auto-refresh failed:', error); } } // Update Last Updated Time function updateLastUpdatedTime() { const now = new Date(); const timeString = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); const element = document.getElementById('last-updated'); if (element) { element.textContent = timeString; } } // Filter tasks by status (Phase 2 feature) function filterTasks(status) { console.log('Filter tasks:', status); // To be implemented in Phase 2 } // Toggle task completion (Phase 2 feature) function toggleTask(taskId) { console.log('Toggle task:', taskId); // To be implemented in Phase 2 } // Mobile Swipe Navigation (function() { let touchStartX = 0; let touchEndX = 0; const SWIPE_THRESHOLD = 50; // Minimum px for a swipe // Get ordered list of tab names (main tabs only for swipe) const TAB_ORDER = ['timeline', 'shopping', 'conditions', 'tasks', 'planning', 'meals']; function handleSwipe() { const swipeDistance = touchEndX - touchStartX; if (Math.abs(swipeDistance) < SWIPE_THRESHOLD) { return; // Not a significant swipe } const currentIndex = TAB_ORDER.indexOf(currentTab); if (currentIndex === -1) return; let newIndex; if (swipeDistance > 0) { // Swiped right -> previous tab newIndex = currentIndex - 1; } else { // Swiped left -> next tab newIndex = currentIndex + 1; } // Bounds check if (newIndex < 0 || newIndex >= TAB_ORDER.length) { return; } const newTab = TAB_ORDER[newIndex]; const tabButton = document.querySelector(`[hx-get="/tabs/${newTab}"]`); if (tabButton) { console.log(`Swipe navigation: ${currentTab} -> ${newTab}`); tabButton.click(); } } // Set up touch event listeners on the tab content area document.addEventListener('DOMContentLoaded', function() { const tabContent = document.getElementById('tab-content'); if (!tabContent) return; tabContent.addEventListener('touchstart', function(e) { touchStartX = e.changedTouches[0].screenX; }, { passive: true }); tabContent.addEventListener('touchend', function(e) { touchEndX = e.changedTouches[0].screenX; handleSwipe(); }, { passive: true }); }); })(); // Agent Access Request Notifications (function() { let wsConnection = null; let reconnectAttempts = 0; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAY_BASE = 1000; function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/notifications`; try { wsConnection = new WebSocket(wsUrl); wsConnection.onopen = function() { console.log('WebSocket connected'); reconnectAttempts = 0; }; wsConnection.onmessage = function(event) { try { const msg = JSON.parse(event.data); if (msg.type === 'agent_request') { showAgentApprovalModal(msg.payload); } } catch (e) { console.error('Failed to parse WebSocket message:', e); } }; wsConnection.onclose = function(event) { console.log('WebSocket disconnected'); if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts); reconnectAttempts++; console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); setTimeout(connectWebSocket, delay); } }; wsConnection.onerror = function(error) { console.error('WebSocket error:', error); }; } catch (e) { console.error('Failed to create WebSocket:', e); } } function showAgentApprovalModal(payload) { // Remove any existing modal const existingModal = document.getElementById('agent-approval-modal'); if (existingModal) { existingModal.remove(); } // Truncate agent ID for display const shortAgentId = payload.agent_id.substring(0, 8); // Determine trust indicator let trustBadge = ''; let trustClass = ''; switch (payload.trust_level) { case 'recognized': trustBadge = 'Recognized'; trustClass = 'bg-green-500'; break; case 'suspicious': trustBadge = 'Warning: Different ID'; trustClass = 'bg-yellow-500'; break; default: trustBadge = 'New Agent'; trustClass = 'bg-blue-500'; } // Calculate time remaining const expiresAt = new Date(payload.expires_at); const timeRemaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000)); const modal = document.createElement('div'); modal.id = 'agent-approval-modal'; modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; modal.innerHTML = `

Agent Access Request

${trustBadge}

Agent Name: ${escapeHtml(payload.agent_name)}

Agent ID: ${shortAgentId}...

Expires in ${timeRemaining}s

`; document.body.appendChild(modal); // Countdown timer const countdownEl = document.getElementById('agent-countdown'); const countdownInterval = setInterval(() => { const remaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000)); countdownEl.textContent = remaining; if (remaining <= 0) { clearInterval(countdownInterval); modal.remove(); } }, 1000); // Button handlers document.getElementById('agent-approve-btn').addEventListener('click', async () => { await handleAgentDecision(payload.request_token, 'approve'); clearInterval(countdownInterval); modal.remove(); }); document.getElementById('agent-deny-btn').addEventListener('click', async () => { await handleAgentDecision(payload.request_token, 'deny'); clearInterval(countdownInterval); modal.remove(); }); // Click outside to dismiss (treat as no action) modal.addEventListener('click', (e) => { if (e.target === modal) { clearInterval(countdownInterval); modal.remove(); } }); } async function handleAgentDecision(requestToken, decision) { try { const response = await fetch(`/agent/auth/${decision}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() }, body: JSON.stringify({ request_token: requestToken }) }); if (!response.ok) { const error = await response.text(); console.error(`Failed to ${decision} agent:`, error); alert(`Failed to ${decision} agent request. Please try again.`); } else { console.log(`Agent request ${decision}d successfully`); } } catch (e) { console.error(`Error during agent ${decision}:`, e); alert(`Error processing request. Please try again.`); } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Initialize WebSocket on page load document.addEventListener('DOMContentLoaded', function() { connectWebSocket(); }); })();