/**
* 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: '',
// 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) {
const created = new Date(createdOn);
const now = new Date();
return Math.floor((now - created) / 1000);
},
// Format elapsed time as mm:ss
formatElapsed(seconds) {
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 category/task type color - prefer task type color for service bell tasks
getCategoryColor(task) {
if (task && task.TaskTypeColor && task.TaskTypeColor.length > 0 && task.TaskTypeColor !== '#9C27B0') {
return task.TaskTypeColor;
}
if (task && task.Color && task.Color !== '#888888') {
return task.Color;
}
return this.categories[task?.TaskCategoryID]?.color || '#888888';
},
// 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');
} 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');
}
}
};
// Global functions for onclick handlers
function closeOverlay() {
HUD.closeOverlay();
}
function acceptTask() {
HUD.acceptTask();
}
function toggleFullscreen() {
const body = document.body;
const isMaximized = body.classList.contains('maximized');
if (isMaximized) {
// Restore normal view
body.classList.remove('maximized');
// Also exit native fullscreen if active
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else if (document.webkitFullscreenElement) {
document.webkitExitFullscreen();
}
} else {
// Maximize - hide header for more space
body.classList.add('maximized');
// Also try native fullscreen on desktop
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(() => {});
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
}
}
}
// Exit maximized mode when exiting native fullscreen
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
document.body.classList.remove('maximized');
}
});
document.addEventListener('webkitfullscreenchange', () => {
if (!document.webkitFullscreenElement) {
document.body.classList.remove('maximized');
}
});
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
HUD.init();
});