/** * Payfrit HUD - Heads Up Display for Task Management * * Displays tasks as vertical bars that grow over 60 seconds. * Bars flash when they exceed the target time. * Workers can tap to view details or long-press to accept. */ const HUD = { // Configuration config: { pollInterval: 1000, // Poll every second for smooth animation targetSeconds: 60, // Target time to accept a task apiBaseUrl: '/api/tasks', // API endpoint businessId: parseInt(localStorage.getItem('payfrit_portal_business')) || 0, }, // State tasks: [], selectedTask: null, longPressTimer: null, isConnected: true, businessName: '', // Chat state chatTaskId: null, chatMessages: [], chatPollInterval: null, lastMessageId: 0, // Category names (will be loaded from API) categories: { 1: { name: 'Orders', color: '#ef4444' }, 2: { name: 'Pickup', color: '#f59e0b' }, 3: { name: 'Delivery', color: '#22c55e' }, 4: { name: 'Support', color: '#3b82f6' }, 5: { name: 'Kitchen', color: '#8b5cf6' }, 6: { name: 'Other', color: '#ec4899' }, }, // Initialize init() { this.updateClock(); setInterval(() => this.updateClock(), 1000); setInterval(() => this.updateBars(), 1000); setInterval(() => this.fetchTasks(), 3000); // Initial fetch (also gets business name) this.fetchTasks(); // Close overlay on background tap document.getElementById('taskOverlay').addEventListener('click', (e) => { if (e.target.id === 'taskOverlay') { this.closeOverlay(); } }); // Monitor online/offline status window.addEventListener('online', () => { console.log('[HUD] Back online'); this.setConnected(true); this.fetchTasks(); }); window.addEventListener('offline', () => { console.log('[HUD] Went offline'); this.setConnected(false); }); // Initial connection check if (!navigator.onLine) { this.setConnected(false); } console.log('[HUD] Initialized'); }, // Update the clock display updateClock() { const now = new Date(); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); document.getElementById('clock').textContent = `${hours}:${minutes}:${seconds}`; }, // Fetch tasks from API async fetchTasks() { // Check if online before attempting fetch if (!navigator.onLine) { this.setConnected(false); return; } try { const response = await fetch(`${this.config.apiBaseUrl}/listPending.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.tasks = data.TASKS || []; this.renderTasks(); this.setConnected(true); // Update business name from response if (data.BUSINESS_NAME && !this.businessName) { this.businessName = data.BUSINESS_NAME; document.getElementById('businessName').textContent = ' - ' + this.businessName; } } else { console.error('[HUD] API error:', data.ERROR); this.setConnected(false); } } catch (err) { console.error('[HUD] Fetch error:', err); this.setConnected(false); } }, // Render task bars renderTasks() { const container = document.getElementById('taskContainer'); const emptyState = document.getElementById('emptyState'); // Show/hide empty state if (this.tasks.length === 0) { emptyState.style.display = 'block'; } else { emptyState.style.display = 'none'; } // Get existing bar elements const existingBars = container.querySelectorAll('.task-bar'); const existingIds = new Set(); existingBars.forEach(bar => existingIds.add(parseInt(bar.dataset.taskId))); // Get current task IDs const currentIds = new Set(this.tasks.map(t => t.TaskID)); // Remove bars for tasks that no longer exist existingBars.forEach(bar => { const id = parseInt(bar.dataset.taskId); if (!currentIds.has(id)) { bar.remove(); } }); // Add or update bars this.tasks.forEach(task => { let bar = container.querySelector(`.task-bar[data-task-id="${task.TaskID}"]`); if (!bar) { bar = this.createTaskBar(task); container.appendChild(bar); } this.updateTaskBar(bar, task); }); }, // Create a new task bar element createTaskBar(task) { const bar = document.createElement('div'); bar.className = 'task-bar'; bar.dataset.taskId = task.TaskID; bar.dataset.category = task.TaskCategoryID || 1; // Apply dynamic category color const categoryColor = this.getCategoryColor(task); bar.style.setProperty('--bar-color', categoryColor); bar.innerHTML = ` #${task.TaskID} ${this.getName(task)} `; // Touch/click handlers bar.addEventListener('mousedown', (e) => this.onBarPress(task, e)); bar.addEventListener('mouseup', () => this.onBarRelease()); bar.addEventListener('mouseleave', () => this.onBarRelease()); bar.addEventListener('touchstart', (e) => this.onBarPress(task, e)); bar.addEventListener('touchend', () => this.onBarRelease()); bar.addEventListener('click', () => this.onBarTap(task)); return bar; }, // Update task bar height and state updateTaskBar(bar, task) { const elapsed = this.getElapsedSeconds(task.CreatedOn); const percentage = Math.min(elapsed / this.config.targetSeconds * 100, 100); bar.style.height = `${percentage}%`; // Update time display const timeEl = bar.querySelector('.task-time'); if (timeEl) { timeEl.textContent = this.formatElapsed(elapsed); } // Flash if overdue if (elapsed >= this.config.targetSeconds) { bar.classList.add('flashing'); } else { bar.classList.remove('flashing'); } }, // Update all bars (called every second) updateBars() { const bars = document.querySelectorAll('.task-bar'); bars.forEach(bar => { const taskId = parseInt(bar.dataset.taskId); const task = this.tasks.find(t => t.TaskID === taskId); if (task) { this.updateTaskBar(bar, task); } }); }, // Get elapsed seconds since task creation getElapsedSeconds(createdOn) { if (!createdOn) return 0; const created = new Date(createdOn); if (isNaN(created.getTime())) return 0; const now = new Date(); return Math.max(0, Math.floor((now - created) / 1000)); }, // Format elapsed time as mm:ss formatElapsed(seconds) { if (isNaN(seconds) || seconds < 0) seconds = 0; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${String(secs).padStart(2, '0')}`; }, // Get category/task type name - prefer task type for service bell tasks getName(task) { if (task && task.TaskTypeName && task.TaskTypeName.length > 0) { return task.TaskTypeName; } if (task && task.Name) { return task.Name; } return this.categories[task?.TaskCategoryID]?.name || 'Task'; }, // Get task type color from tt_TaskTypes getCategoryColor(task) { if (task && task.TaskTypeColor && task.TaskTypeColor.length > 0) { return task.TaskTypeColor; } return '#9C27B0'; // Default purple }, // Handle bar press (start long press timer) onBarPress(task, e) { e.preventDefault(); this.longPressTimer = setTimeout(() => { this.acceptTaskDirect(task); }, 800); // 800ms for long press }, // Handle bar release (cancel long press) onBarRelease() { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } }, // Handle bar tap (show details) onBarTap(task) { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } this.showDetails(task); }, // Show task details overlay showDetails(task) { this.selectedTask = task; const overlay = document.getElementById('taskOverlay'); const detail = document.getElementById('taskDetail'); const elapsed = this.getElapsedSeconds(task.CreatedOn); const categoryName = this.getName(task); const categoryColor = this.getCategoryColor(task); document.getElementById('detailTitle').textContent = task.Title || `Task #${task.TaskID}`; document.getElementById('detailCategory').textContent = categoryName; document.getElementById('detailCreated').textContent = new Date(task.CreatedOn).toLocaleTimeString(); document.getElementById('detailWaiting').textContent = this.formatElapsed(elapsed); document.getElementById('detailInfo').textContent = task.Details || '-'; // Show location if available const locationRow = document.getElementById('detailLocationRow'); const locationValue = document.getElementById('detailLocation'); if (task.ServicePointName && task.ServicePointName.length > 0) { locationValue.textContent = task.ServicePointName; locationRow.style.display = 'flex'; } else { locationRow.style.display = 'none'; } detail.style.setProperty('--detail-color', categoryColor); overlay.classList.add('visible'); }, // Close overlay closeOverlay() { document.getElementById('taskOverlay').classList.remove('visible'); this.selectedTask = null; }, // Accept task from overlay acceptTask() { if (this.selectedTask) { this.acceptTaskDirect(this.selectedTask); this.closeOverlay(); } }, // Accept task directly (long press or button) async acceptTaskDirect(task) { console.log('[HUD] Accepting task:', task.TaskID); try { const response = await fetch(`${this.config.apiBaseUrl}/accept.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ TaskID: task.TaskID, BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { // Remove from local list immediately this.tasks = this.tasks.filter(t => t.TaskID !== task.TaskID); this.renderTasks(); this.showFeedback('Task accepted!', 'success'); // If it's a chat task, open the chat modal if (this.isChatTask(task)) { this.openChat(task); } } else { this.showFeedback(data.ERROR || 'Failed to accept', 'error'); } } catch (err) { console.error('[HUD] Accept error:', err); this.showFeedback('Failed to accept task', 'error'); } }, // Show feedback message showFeedback(message, type) { // Simple console feedback for now console.log(`[HUD] ${type}: ${message}`); // TODO: Add toast notification }, // Set connection status setConnected(connected) { this.isConnected = connected; const indicator = document.getElementById('statusIndicator'); if (connected) { indicator.classList.remove('disconnected'); } else { indicator.classList.add('disconnected'); } }, // Check if task is a chat task isChatTask(task) { if (!task) return false; const name = (task.TaskTypeName || task.Title || '').toLowerCase(); return name.includes('chat'); }, // Open chat modal for a task openChat(task) { this.chatTaskId = task.TaskID; this.chatMessages = []; this.lastMessageId = 0; document.getElementById('chatTitle').textContent = task.Title || 'Chat'; document.getElementById('chatMessages').innerHTML = '