payfrit-works/hud/hud.js
John Mizerek 3df846a31b Fix HUD business name - get from listPending API
The previous approach called getBusiness which requires auth.
Now listPending returns BUSINESS_NAME and HUD uses that.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:33:09 -08:00

367 lines
11 KiB
JavaScript

/**
* 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(new URLSearchParams(window.location.search).get('b')) || 17,
},
// 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();
}
});
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() {
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);
// Demo mode: generate fake tasks if API unavailable
if (this.tasks.length === 0) {
this.loadDemoTasks();
}
}
},
// Load demo tasks for testing
loadDemoTasks() {
const now = Date.now();
this.tasks = [
{ TaskID: 101, TaskCategoryID: 1, TaskTitle: 'Order #42', TaskCreatedOn: new Date(now - 15000).toISOString() },
{ TaskID: 102, TaskCategoryID: 3, TaskTitle: 'Order #43', TaskCreatedOn: new Date(now - 35000).toISOString() },
{ TaskID: 103, TaskCategoryID: 2, TaskTitle: 'Pickup #7', TaskCreatedOn: new Date(now - 55000).toISOString() },
{ TaskID: 104, TaskCategoryID: 5, TaskTitle: 'Prep #12', TaskCreatedOn: new Date(now - 70000).toISOString() },
];
this.renderTasks();
},
// 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 = `
<span class="task-time"></span>
<span class="task-id">#${task.TaskID}</span>
<span class="task-label">${this.getCategoryName(task)}</span>
`;
// 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.TaskCreatedOn);
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 name - use task's category name if available, fall back to hardcoded
getCategoryName(task) {
if (task && task.TaskCategoryName) {
return task.TaskCategoryName;
}
return this.categories[task?.TaskCategoryID]?.name || 'Task';
},
// Get category color - use task's category color if available, fall back to hardcoded
getCategoryColor(task) {
if (task && task.TaskCategoryColor) {
return task.TaskCategoryColor;
}
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.showTaskDetails(task);
},
// Show task details overlay
showTaskDetails(task) {
this.selectedTask = task;
const overlay = document.getElementById('taskOverlay');
const detail = document.getElementById('taskDetail');
const elapsed = this.getElapsedSeconds(task.TaskCreatedOn);
const categoryName = this.getCategoryName(task);
const categoryColor = this.getCategoryColor(task);
document.getElementById('detailTitle').textContent = task.TaskTitle || `Task #${task.TaskID}`;
document.getElementById('detailCategory').textContent = categoryName;
document.getElementById('detailCreated').textContent = new Date(task.TaskCreatedOn).toLocaleTimeString();
document.getElementById('detailWaiting').textContent = this.formatElapsed(elapsed);
document.getElementById('detailInfo').textContent = task.TaskDetails || '-';
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);
// Demo mode: just remove locally
this.tasks = this.tasks.filter(t => t.TaskID !== task.TaskID);
this.renderTasks();
this.showFeedback('Task accepted!', 'success');
}
},
// 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();
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
HUD.init();
});