Catsy Deployer
Deploy Catsy Simulation
96027bb
Raw
History Blame Contribute Delete
31.9 kB
import { configState } from './js/core/ConfigState.js';
import { SettingsModal } from './js/components/SettingsModal.js';
import { AreaPopovers } from './js/components/AreaPopovers.js';
import { dashboardStats } from './js/core/DashboardStats.js';
import { simulationClient } from './js/core/SimulationClient.js';
import { analyticsModal } from './js/ui/AnalyticsModal.js';
import { tableRenderer } from './js/ui/TableRenderer.js';
import { staffRenderer } from './js/ui/StaffRenderer.js';
import { customerRenderer } from './js/ui/CustomerRenderer.js';
// Initialize UI Components
const settingsModal = new SettingsModal();
const areaPopovers = new AreaPopovers();
// Listen to configuration updates to save state
configState.addEventListener('updated', (e) => {
localStorage.setItem('catsySimConfig', JSON.stringify(e.detail));
const toast = document.getElementById('toast');
if (toast) {
toast.style.opacity = '1';
clearTimeout(toast.timeoutId);
toast.timeoutId = setTimeout(() => {
toast.style.opacity = '0';
}, 3000);
}
// We intentionally do NOT auto-restart startSimulation() here.
// The user must manually click 'Restart' to apply new settings,
// to avoid wiping the active simulation UI abruptly when tweaking settings.
});
document.addEventListener('DOMContentLoaded', () => {
loadConfigFromStorage();
});
function loadConfigFromStorage() {
const saved = localStorage.getItem('catsySimConfig');
if (!saved) return;
try {
const config = JSON.parse(saved);
const directMap = {
'cashiers': 'cfg-cashiers', 'baristas': 'cfg-baristas', 'tables': 'cfg-tables',
'resArrivalMin': 'cfg-res-arrival-min', 'resArrivalMax': 'cfg-res-arrival-max',
'arrival': 'cfg-arrival', 'duration': 'cfg-duration', 'warmupTime': 'cfg-warmup',
'balkThreshold': 'cfg-balk-threshold', 'maxStrikes': 'cfg-max-strikes',
'decideMin': 'cfg-decide-min', 'decideMax': 'cfg-decide-max',
'payMin': 'cfg-pay-min', 'payMax': 'cfg-pay-max',
'prepMin': 'cfg-prep-min', 'prepMax': 'cfg-prep-max',
'dwellMin': 'cfg-dwell-min', 'dwellMax': 'cfg-dwell-max',
'replications': 'cfg-replications', 'shiftHours': 'cfg-shift-hours'
};
const probMap = {
'takeoutProb': 'cfg-takeout-prob', 'resProb': 'cfg-res-prob',
'balkProb': 'cfg-balk-prob', 'renegeProb': 'cfg-renege-prob'
};
for (const [key, domId] of Object.entries(directMap)) {
if (config[key] !== undefined) {
const el = document.getElementById(domId);
if (el) el.value = config[key];
}
}
for (const [key, domId] of Object.entries(probMap)) {
if (config[key] !== undefined) {
const el = document.getElementById(domId);
if (el) el.value = config[key] * 100; // Convert back to percentage
}
}
// Also update the scenario select box if it matches an arrival value
const scenarioSelect = document.getElementById('cfg-scenario');
if (scenarioSelect && config.arrival) {
let match = false;
for (let option of scenarioSelect.options) {
if (option.value == config.arrival && option.value !== 'custom') {
scenarioSelect.value = option.value;
match = true;
break;
}
}
if (!match) scenarioSelect.value = 'custom';
}
// Update the span displays for quick controls
const dispCashiers = document.getElementById('disp-cashiers');
if (dispCashiers && config.cashiers) dispCashiers.innerText = config.cashiers;
const dispBaristas = document.getElementById('disp-baristas');
if (dispBaristas && config.baristas) dispBaristas.innerText = config.baristas;
const dispTables = document.getElementById('disp-tables');
if (dispTables && config.tables) dispTables.innerText = config.tables;
} catch (e) {
console.error('Failed to parse saved config', e);
}
}
configState.refreshFromDOM();
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
const btnRestart = document.getElementById('btn-restart');
const statusDot = document.getElementById('connection-status');
const statusText = document.getElementById('status-text');
const floatingLayer = document.getElementById('floating-layer');
let customerTableMap = new Map(); // Maps customer ID to table element ID
let customerFrustrationMap = new Map(); // Maps customer ID to frustration level (0-3)
let cashierQueueLens = []; // Array of lengths per cashier
let waitingAreaLen = 0;
let pickupAreaLen = 0;
let totalCustomers = 0;
let servedCustomers = 0;
let lostCustomers = 0;
let totalDineIn = 0;
let totalTakeout = 0;
let totalReservations = 0;
let missedReservations = 0;
const cashierOffsets = new Map();
const customerCashierMap = new Map();
const waitingAreaOffsets = new Map();
const pickupAreaOffsets = new Map();
const customerBaristaMap = new Map();
let availableTables = [];
let availableResTables = [];
let cashierTimers = {};
let baristaTimers = {};
function enforceBounds() {
const cashiersEl = document.getElementById('cfg-cashiers');
if (cashiersEl) {
let cVal = parseInt(cashiersEl.value);
if (isNaN(cVal) || cVal < 1) cashiersEl.value = 1;
else if (cVal > 10) cashiersEl.value = 10;
}
const baristasEl = document.getElementById('cfg-baristas');
if (baristasEl) {
let bVal = parseInt(baristasEl.value);
if (isNaN(bVal) || bVal < 1) baristasEl.value = 1;
else if (bVal > 15) baristasEl.value = 15;
}
}
function startSimulation() {
enforceBounds();
// Ensure the backend isn't stuck in a paused state (e.g. if the user hit restart while the settings modal was open)
fetch('/api/resume', { method: 'POST' }).catch(e => console.error(e));
simulationClient.resume(); // also ensure frontend is unpaused
simulationClient.connect();
}
window.addEventListener('sim:connected', () => {
if (statusDot) statusDot.className = 'dot green';
if (statusText) statusText.innerText = 'Connected';
if (btnStart) {
btnStart.disabled = false;
btnStart.innerText = '⏸ Pause';
btnStart.classList.add('playing');
}
if (btnStop) btnStop.disabled = false;
if (btnRestart) btnRestart.disabled = false;
const warmupTime = configState.getConfig().warmupTime || 0;
const warmupIndicator = document.getElementById('warmup-indicator');
if (warmupIndicator) {
if (warmupTime > 0) {
warmupIndicator.style.display = 'inline-block';
} else {
warmupIndicator.style.display = 'none';
}
}
resetSimulation();
});
window.addEventListener('sim:disconnected', () => {
if (statusDot) statusDot.className = 'status-indicator__dot status-indicator__dot--disconnected';
if (statusText) statusText.innerText = 'Disconnected';
if (btnStart) {
btnStart.disabled = false;
btnStart.innerText = '▶ Play';
btnStart.classList.remove('playing');
}
if (btnStop) btnStop.disabled = true;
if (btnRestart) btnRestart.disabled = true;
// Stop all visual timers immediately
for (const key in cashierTimers) {
if (cashierTimers[key] && cashierTimers[key].interval) {
clearInterval(cashierTimers[key].interval);
}
}
for (const key in baristaTimers) {
if (baristaTimers[key] && baristaTimers[key].interval) {
clearInterval(baristaTimers[key].interval);
}
}
staffRenderer.clearAllTimers();
});
window.addEventListener('sim:event', (e) => {
handleEvent(e.detail);
});
btnStart.addEventListener('click', () => {
// If not connected, start the simulation
if (!simulationClient.socket || simulationClient.socket.readyState !== WebSocket.OPEN) {
startSimulation();
return;
}
// If already connected, toggle pause/resume
if (simulationClient.isPaused) {
// Resume
simulationClient.resume();
fetch('/api/resume', { method: 'POST' }).catch(e => console.error(e));
btnStart.innerText = '⏸ Pause';
btnStart.classList.add('playing');
} else {
// Pause
simulationClient.pause();
fetch('/api/pause', { method: 'POST' }).catch(e => console.error(e));
btnStart.innerText = '▶ Resume';
btnStart.classList.remove('playing');
}
});
btnStop.addEventListener('click', () => {
simulationClient.disconnect();
btnStart.disabled = false;
btnStop.disabled = true;
if (btnRestart) btnRestart.disabled = true;
});
if (btnRestart) {
btnRestart.addEventListener('click', () => {
simulationClient.disconnect();
// Slight delay to ensure the disconnect processes completely before reconnecting
setTimeout(() => {
startSimulation();
}, 100);
});
}
function resetSimulation() {
floatingLayer.innerHTML = '';
cashierOffsets.clear();
customerCashierMap.clear();
waitingAreaOffsets.clear();
pickupAreaOffsets.clear();
customerBaristaMap.clear();
// Clear all manual intervals
for (const key in cashierTimers) {
if (cashierTimers[key] && cashierTimers[key].interval) {
clearInterval(cashierTimers[key].interval);
}
}
cashierTimers = {};
for (const key in baristaTimers) {
if (baristaTimers[key] && baristaTimers[key].interval) {
clearInterval(baristaTimers[key].interval);
}
}
baristaTimers = {};
staffRenderer.clearAllTimers();
customerRenderer.clearAll();
customerTableMap.clear();
customerFrustrationMap.clear();
totalCustomers = 0;
servedCustomers = 0;
lostCustomers = 0;
totalDineIn = 0;
totalTakeout = 0;
cashierQueueLens = [];
waitingAreaLen = 0;
pickupAreaLen = 0;
// Reset Dashboard UI values
const paceEl = document.getElementById('current-pace');
if (paceEl) paceEl.innerText = 'Quiet 💤';
document.getElementById('sim-time').innerText = '0.0s';
document.getElementById('total-customers').innerText = '0';
document.getElementById('served-customers').innerText = '0';
document.getElementById('lost-customers').innerText = '0';
document.getElementById('cashier-queue-len').innerText = '0';
document.getElementById('waiting-area-len').innerText = '0';
// Dynamically draw cashiers
const cashiersCount = parseInt(document.getElementById('cfg-cashiers').value);
staffRenderer.drawCashiers(cashiersCount);
// Initialize queue lengths array
cashierQueueLens = Array(cashiersCount).fill(0);
// Dynamically draw baristas
const baristasCount = parseInt(document.getElementById('cfg-baristas').value);
staffRenderer.drawBaristas(baristasCount);
// Dynamically draw tables
const tablesCount = parseInt(document.getElementById('cfg-tables').value);
tableRenderer.draw(tablesCount);
}
function updateStats(time) {
document.getElementById('sim-time').innerText = time.toFixed(1) + 's';
document.getElementById('total-customers').innerText = totalCustomers;
document.getElementById('served-customers').innerText = servedCustomers;
document.getElementById('lost-customers').innerText = lostCustomers;
document.getElementById('total-dine-in').innerText = totalDineIn;
document.getElementById('total-takeout').innerText = totalTakeout;
// Sum all individual cashier queues
const totalCashierQueue = cashierQueueLens.reduce((a, b) => a + b, 0);
document.getElementById('cashier-queue-len').innerText = totalCashierQueue;
document.getElementById('waiting-area-len').innerText = waitingAreaLen + pickupAreaLen;
}
function handleEvent(data) {
if (tableRenderer && data.time) {
tableRenderer.tickTimers(data.time);
}
if (data.event === 'warmup_complete') {
document.getElementById('warmup-indicator').style.display = 'none';
totalCustomers = 0;
servedCustomers = 0;
lostCustomers = 0;
totalDineIn = 0;
totalTakeout = 0;
totalReservations = 0;
missedReservations = 0;
document.getElementById('total-customers').innerText = totalCustomers;
document.getElementById('served-customers').innerText = servedCustomers;
document.getElementById('lost-customers').innerText = lostCustomers;
document.getElementById('total-dine-in').innerText = totalDineIn;
document.getElementById('total-takeout').innerText = totalTakeout;
const totalResEl = document.getElementById('total-reservations');
if (totalResEl) totalResEl.innerText = totalReservations;
const missedResEl = document.getElementById('missed-reservations');
if (missedResEl) missedResEl.innerText = missedReservations;
return;
}
if (data.event === 'pace_change') {
const paceEl = document.getElementById('current-pace');
if (paceEl) {
const pace = data.pace;
let color = '#3498db'; // Steady
let text = 'Steady ☕';
if (pace === 'Peak') {
color = '#e74c3c';
text = 'Peak 🔥';
} else if (pace === 'Quiet') {
color = '#9b59b6';
text = 'Quiet 💤';
}
paceEl.style.color = color;
paceEl.innerText = text;
}
return;
}
const id = data.customer_id;
switch(data.event) {
case 'arrive':
totalCustomers++;
document.getElementById('total-customers').innerText = totalCustomers;
const numId = parseInt(id.replace('Cust-', '')) || 0;
customerRenderer.moveCustomer(id, 'entrance', numId);
break;
case 'balking_start':
const balkStartEl = customerRenderer.customers.get(id);
if (balkStartEl) balkStartEl.classList.add('customer--walkout');
customerRenderer.incrementFrustration(id, 4);
break;
case 'balk_leave':
lostCustomers++;
const balkEl = customerRenderer.customers.get(id);
if (balkEl) {
const badge = balkEl.querySelector('.customer__badge');
if (badge) {
badge.innerText = '🙅';
badge.style.display = 'flex';
}
// Storm out from entrance
balkEl.classList.add('customer--walkout');
balkEl.style.transition = 'transform 3.5s ease-in, opacity 3.5s ease-in';
balkEl.style.transform += ' translate(-80px, 200px)';
balkEl.style.opacity = '0';
setTimeout(() => {
customerRenderer.removeCustomer(id);
}, 3500);
} else {
customerRenderer.removeCustomer(id);
}
break;
case 'frustrated_waiting':
customerRenderer.incrementFrustration(id, 1);
break;
case 'patience_warning':
customerRenderer.incrementFrustration(id, 2);
break;
case 'prep_waiting_frustration':
customerRenderer.incrementFrustration(id, 3);
break;
case 'renege_leave':
lostCustomers++; // Count them as a lost walkout
// Remove them from the queue tracking
const cIdxToLeave = data.cashier_index;
if (isNaN(cashierQueueLens[cIdxToLeave]) || cashierQueueLens[cIdxToLeave] === undefined) { cashierQueueLens[cIdxToLeave] = 0; }
cashierQueueLens[cIdxToLeave] = Math.max(0, cashierQueueLens[cIdxToLeave] - 1);
const leavingOffset = cashierOffsets.get(id);
// Advance everyone behind them forward
for (let [custId, cIdx] of customerCashierMap.entries()) {
if (cIdx === cIdxToLeave && custId !== id) {
let currentOffset = cashierOffsets.get(custId);
if (currentOffset !== undefined && currentOffset > leavingOffset) {
let newOffset = currentOffset - 1;
cashierOffsets.set(custId, newOffset);
customerRenderer.moveCustomer(custId, `cashier-queue-${cIdxToLeave}`, newOffset);
}
}
}
// Remove from maps
cashierOffsets.delete(id);
customerCashierMap.delete(id);
// Animate them storming out in frustration
const renegeEl = customerRenderer.customers.get(id);
if (renegeEl) {
renegeEl.classList.add('customer--walkout');
const badge = renegeEl.querySelector('.customer__badge');
if (badge) {
badge.innerText = '🏃';
badge.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.5)' },
{ transform: 'scale(1)' }
], {
duration: 600,
iterations: Infinity,
easing: 'ease-in-out'
});
}
// Instantly step OUT of the queue so the person behind them doesn't walk through them
renegeEl.style.transform += ' translate(0px, 40px)';
// Wait for 2 seconds so the user can clearly see them get frustrated
setTimeout(() => {
// Then they storm out completely
renegeEl.style.transition = 'transform 3.5s ease-in, opacity 3.5s ease-in';
renegeEl.style.transform += ' translate(-150px, 200px)';
renegeEl.style.opacity = '0';
setTimeout(() => {
if (renegeEl.parentNode) renegeEl.parentNode.removeChild(renegeEl);
customerRenderer.customers.delete(id);
}, 3500);
}, 2000);
}
break;
case 'vip_checkin':
const vipCashierIdx = data.cashier_index !== undefined ? data.cashier_index : 0;
customerRenderer.moveCustomer(id, `vip-checkin-${vipCashierIdx}`);
customerRenderer.markAsVIP(id);
// Small timeout to let the movement start before triggering the animation
setTimeout(() => {
customerRenderer.triggerVIPAnimation(id);
}, 300);
break;
case 'queue_cashier':
const cIdx = data.cashier_index;
customerCashierMap.set(id, cIdx);
if (isNaN(cashierQueueLens[cIdx]) || cashierQueueLens[cIdx] === undefined) { cashierQueueLens[cIdx] = 0; }
cashierQueueLens[cIdx]++;
cashierOffsets.set(id, cashierQueueLens[cIdx] - 1);
customerRenderer.moveCustomer(id, `cashier-queue-${cIdx}`, cashierQueueLens[cIdx] - 1);
break;
case 'start_deciding':
const decIdx = data.cashier_index;
if (isNaN(cashierQueueLens[decIdx]) || cashierQueueLens[decIdx] === undefined) { cashierQueueLens[decIdx] = 0; }
cashierQueueLens[decIdx] = Math.max(0, cashierQueueLens[decIdx] - 1);
customerRenderer.moveCustomer(id, `cashier-queue-${decIdx}`, -1);
const decEl = customerRenderer.customers.get(id);
if (decEl) {
const badge = decEl.querySelector('.customer__badge');
if (badge) {
badge.innerText = '💭';
badge.style.display = 'flex';
}
}
// Start the cashier countdown timer!
if (data.duration) {
if (cashierTimers[decIdx]) clearInterval(cashierTimers[decIdx].interval);
let elapsed = 0;
const totalDuration = data.duration;
const speedFactor = 0.5; // Backend RealtimeEnvironment factor
const realTickMs = 100;
const simTickSec = (realTickMs / 1000) / speedFactor;
const timerEl = document.getElementById(`timer-cashier-${decIdx}`);
if (timerEl) {
timerEl.innerText = '0.0s';
timerEl.classList.remove('idle');
}
const interval = setInterval(() => {
if (simulationClient && simulationClient.isPaused) return;
elapsed += simTickSec;
if (elapsed >= totalDuration) {
elapsed = totalDuration;
clearInterval(interval);
// Freeze on final time until customer walks away
if (timerEl) timerEl.innerText = elapsed.toFixed(1) + 's';
} else {
if (timerEl) timerEl.innerText = elapsed.toFixed(1) + 's';
}
}, realTickMs);
cashierTimers[decIdx] = { interval };
}
// Advance everyone else in this specific queue forward by 1 spot
for (let [custId, cIdx] of customerCashierMap.entries()) {
if (cIdx === decIdx && custId !== id) {
let currentOffset = cashierOffsets.get(custId);
if (currentOffset !== undefined && currentOffset > 0) {
let newOffset = currentOffset - 1;
cashierOffsets.set(custId, newOffset);
customerRenderer.moveCustomer(custId, `cashier-queue-${decIdx}`, newOffset);
}
}
}
break;
case 'start_paying':
// Customer is paying, no physical move needed, just logical state
const payingCIdx = customerCashierMap.get(id);
if (payingCIdx !== undefined) {
const cAnim = document.getElementById(`working-anim-cashier-${payingCIdx}`);
if (cAnim) cAnim.style.display = 'block';
}
const payEl = customerRenderer.customers.get(id);
if (payEl) {
const badge = payEl.querySelector('.customer__badge');
if (badge) {
badge.innerText = '💳';
badge.style.display = 'flex';
}
}
break;
case 'waiting_pickup':
totalTakeout++;
document.getElementById('total-takeout').innerText = totalTakeout;
// Go to waiting/pickup area if tables are full, or if waiting for takeout
pickupAreaLen++;
pickupAreaOffsets.set(id, pickupAreaLen - 1);
customerRenderer.moveCustomer(id, 'pickup-area', pickupAreaLen - 1);
customerRenderer.updateFrustrationVisuals(id);
// Customer left the cashier, reset the cashier timer
const leftCIdxTakeout = customerCashierMap.get(id);
if (leftCIdxTakeout !== undefined && cashierTimers[leftCIdxTakeout]) {
clearInterval(cashierTimers[leftCIdxTakeout].interval);
const timerEl = document.getElementById(`timer-cashier-${leftCIdxTakeout}`);
if (timerEl) {
timerEl.innerText = 'Idle';
timerEl.classList.add('idle');
}
const cAnim = document.getElementById(`working-anim-cashier-${leftCIdxTakeout}`);
if (cAnim) cAnim.style.display = 'none';
showPopUpIcon(`cashier-staff-${leftCIdxTakeout}`, '💰');
}
break;
case 'waiting_table':
totalDineIn++;
document.getElementById('total-dine-in').innerText = totalDineIn;
// Go to waiting/pickup area if tables are full, or if waiting for takeout
waitingAreaLen++;
waitingAreaOffsets.set(id, waitingAreaLen - 1);
customerRenderer.moveCustomer(id, 'waiting-area', waitingAreaLen - 1);
customerRenderer.updateFrustrationVisuals(id);
// Customer left the cashier, reset the cashier timer
const leftCIdx = customerCashierMap.get(id);
if (leftCIdx !== undefined && cashierTimers[leftCIdx]) {
clearInterval(cashierTimers[leftCIdx].interval);
const timerEl = document.getElementById(`timer-cashier-${leftCIdx}`);
if (timerEl) {
timerEl.innerText = 'Idle';
timerEl.classList.add('idle');
}
const cAnim = document.getElementById(`working-anim-cashier-${leftCIdx}`);
if (cAnim) cAnim.style.display = 'none';
showPopUpIcon(`cashier-staff-${leftCIdx}`, '💰');
}
break;
case 'table_reserved':
totalReservations++;
const totalResEl = document.getElementById('total-reservations');
if (totalResEl) totalResEl.innerText = totalReservations;
tableRenderer.setReserved(data.table_id, data.time, data.limit);
break;
case 'table_unreserved':
tableRenderer.setUnreserved(data.table_id);
break;
case 'missed_reservation':
missedReservations++;
const missedResEl = document.getElementById('missed-reservations');
if (missedResEl) missedResEl.innerText = missedReservations;
break;
case 'seated_waiting_order':
waitingAreaLen = Math.max(0, waitingAreaLen - 1);
const tableId = data.table_id;
customerTableMap.set(id, tableId);
customerRenderer.moveCustomer(id, `table-${tableId}`);
tableRenderer.setOccupied(tableId, data.time, data.is_reservation, 'waiting');
break;
case 'start_prep':
// Barista started making it. We don't move the customer, they are still at the table.
const bIdx = data.barista_index;
customerBaristaMap.set(id, bIdx);
const bStatus = document.getElementById(`status-barista-${bIdx}`);
if (bStatus) {
bStatus.innerText = `Prep for #${id.replace('Cust-', '')}`;
bStatus.style.color = '#e94f37';
}
const bAnim = document.getElementById(`working-anim-${bIdx}`);
if (bAnim) bAnim.style.display = 'block';
// Start the barista countdown timer
if (data.duration) {
if (baristaTimers[bIdx]) clearInterval(baristaTimers[bIdx].interval);
let elapsed = 0;
const totalDuration = data.duration;
const speedFactor = 0.5; // Backend RealtimeEnvironment factor
const realTickMs = 100;
const simTickSec = (realTickMs / 1000) / speedFactor;
const timerEl = document.getElementById(`timer-barista-${bIdx}`);
if (timerEl) {
timerEl.innerText = '0.0s';
timerEl.classList.remove('idle');
}
const interval = setInterval(() => {
if (simulationClient && simulationClient.isPaused) return;
elapsed += simTickSec;
if (elapsed >= totalDuration) {
elapsed = totalDuration;
clearInterval(interval);
if (timerEl) timerEl.innerText = elapsed.toFixed(1) + 's';
} else {
if (timerEl) timerEl.innerText = elapsed.toFixed(1) + 's';
}
}, realTickMs);
baristaTimers[bIdx] = { interval };
}
break;
case 'served':
customerRenderer.frustrationMap.set(id, 0);
customerRenderer.updateFrustrationVisuals(id);
// Order arrived! Time to eat.
const tId = customerTableMap.get(id);
if (tId) {
// If it's a reserved customer, badge has already been hidden during setOccupied, but pass false to be safe
tableRenderer.setOccupied(tId, data.time, false, 'eating');
}
const finishedBIdx = customerBaristaMap.get(id);
if (finishedBIdx !== undefined) {
if (baristaTimers[finishedBIdx]) clearInterval(baristaTimers[finishedBIdx].interval);
const timerEl = document.getElementById(`timer-barista-${finishedBIdx}`);
if (timerEl) {
timerEl.innerText = 'Idle';
timerEl.classList.add('idle');
}
const finishedBStatus = document.getElementById(`status-barista-${finishedBIdx}`);
if (finishedBStatus) {
finishedBStatus.innerText = 'Idle';
finishedBStatus.style.color = '#aaa';
}
const bAnim = document.getElementById(`working-anim-${finishedBIdx}`);
if (bAnim) bAnim.style.display = 'none';
customerBaristaMap.delete(id);
showPopUpIcon(`barista-staff-${finishedBIdx}`, '☕');
}
break;
case 'leave':
servedCustomers++;
const el = customerRenderer.customers.get(id);
if (el) {
el.style.transform += ' translate(500px, 0)';
el.style.opacity = '0';
setTimeout(() => {
if (el.parentNode) el.parentNode.removeChild(el);
customerRenderer.customers.delete(id);
}, 800);
}
// Check if they were in the waiting/pickup area (Takeout customers)
if (waitingAreaOffsets.has(id)) {
waitingAreaLen = Math.max(0, waitingAreaLen - 1);
waitingAreaOffsets.delete(id);
} else if (pickupAreaOffsets.has(id)) {
pickupAreaLen = Math.max(0, pickupAreaLen - 1);
pickupAreaOffsets.delete(id);
}
// Clear table status and mark table as available again (Dine-in customers)
const leftTableId = customerTableMap.get(id);
if (leftTableId) {
tableRenderer.setEmpty(leftTableId);
customerTableMap.delete(id);
}
break;
case 'sim_end':
statusDot.className = 'dot';
statusDot.style.backgroundColor = '#4a90e2';
statusText.innerText = 'Completed';
break;
}
updateStats(data.time);
}