DMV Queue & Waiting Area Announcer
DMV Queue Controller Local-only
Issue Regular
Issue Priority
Open Kiosk
Call Next
Recall
Skip
Transfer to…
Clear Served
Factory Reset
🔊 Announcement Options
Chime
Ding
Bell
None
Speak Announcement
On
Off
Tip: Put your public screen in Display Mode via the top-right toggle.
Shortcuts
N Next • R Recall • S Skip
Waiting Area Display
Toggle Display Mode
Open on TV for public view
Now Serving
Welcome. Please have your documents ready. Priority tickets are called first.
Queue
Ticket Type Requested Status
Tickets persist locally. Use Factory Reset to clear.
// DOM elements
const prefixEl = $('#prefix');
const serviceEl = $('#service');
const transferTargetEl = $('#transferTarget');
const nowTicketEl = $('#nowTicket');
const nowCounterEl = $('#nowCounter');
const queueRowsEl = $('#queueRows');
const servedRowsEl = $('#servedRows');
const addCounterBtn = $('#addCounter');
const countersWrap = $('#counters');
const langEl = $('#lang');
const volumeEl = $('#volume');
const chimeEl = $('#chime');
const speakEl = $('#speak');
// Utility functions
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
}
function ensurePrefixSeq(p) {
if (!(p in state.seq)) state.seq[p] = 1;
}
function padNumber(n) {
return String(n).padStart(3, '0');
}
// Ticket management
function issueTicket(isPriority = false) {
const prefix = prefixEl.value.trim().toUpperCase() || 'A';
ensurePrefixSeq(prefix);
const id = prefix + padNumber(state.seq[prefix]++);
const ticket = {
id,
ts: Date.now(),
prio: isPriority,
status: 'waiting',
history: []
};
state.tickets.push(ticket);
save();
render();
toast(`Issued ${id}${isPriority ? ' (Priority)' : ''}`, isPriority ? 'warn' : '');
return ticket;
}
function getWaitingTickets() {
const waiting = state.tickets.filter(t => t.status === 'waiting');
const priority = waiting.filter(t => t.prio).sort((a, b) => a.ts - b.ts);
const regular = waiting.filter(t => !t.prio).sort((a, b) => a.ts - b.ts);
return priority.concat(regular);
}
function callNext() {
const counter = serviceEl.value;
if (!counter) return toast('Select a counter first', 'danger');
const nextTicket = getWaitingTickets()[0];
if (!nextTicket) return toast('No waiting tickets', 'danger');
nextTicket.status = 'serving';
nextTicket.history.push({ at: Date.now(), op: 'call', counter });
state.now.ticket = nextTicket.id;
state.now.counter = counter;
announce(nextTicket.id, counter);
save();
render();
}
function recall() {
if (!state.now.ticket || !state.now.counter) {
return toast('Nothing to recall', 'danger');
}
announce(state.now.ticket, state.now.counter, true);
}
function skip() {
const ticket = state.tickets.find(t => t.id === state.now.ticket);
if (!ticket) return toast('No active ticket', 'danger');
ticket.status = 'skipped';
ticket.history.push({ at: Date.now(), op: 'skip', counter: state.now.counter });
state.now.ticket = null;
state.now.counter = null;
save();
render();
}
function markDone(ticketId, counterId) {
const ticket = state.tickets.find(t => t.id === ticketId);
if (!ticket) return;
ticket.status = 'done';
ticket.history.push({ at: Date.now(), op: 'done', counter: counterId });
state.served.unshift({ id: ticket.id, counter: counterId, at: Date.now() });
state.served = state.served.slice(0, 20);
if (state.now.ticket === ticketId) {
state.now.ticket = null;
state.now.counter = null;
}
save();
render();
}
function transfer(toCounter) {
const ticket = state.tickets.find(t => t.id === state.now.ticket);
if (!ticket) return toast('No active ticket', 'danger');
ticket.history.push({
at: Date.now(),
op: 'transfer',
from: state.now.counter,
to: toCounter
});
// Keep serving state but announce at new counter
state.now.counter = toCounter;
announce(ticket.id, toCounter, false, 'transferred');
save();
render();
}
function addCounter() {
const id = 'C' + (state.counters.length + 1);
state.counters.push({ id, name: 'Counter ' + (state.counters.length + 1) });
save();
render();
}
function removeCounter(id) {
state.counters = state.counters.filter(c => c.id !== id);
if (serviceEl.value === id) {
serviceEl.value = state.counters[0]?.id || '';
}
save();
render();
}
// Announcement system
function playChime() {
const key = chimeEl.value;
if (key === 'none') return;
const audioElement = chimes[key];
if (!audioElement) return;
audioElement.volume = Number(volumeEl.value || 1);
audioElement.currentTime = 0;
audioElement.play().catch(err => {
console.warn('Could not play chime:', err);
});
}
function speakText(text) {
if (speakEl.value === 'off') return;
// Check if speech synthesis is available
if (!('speechSynthesis' in window)) {
console.warn('Speech synthesis not supported');
return;
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = langEl.value;
utterance.volume = Number(volumeEl.value || 1);
// Cancel any ongoing speech
speechSynthesis.cancel();
// Speak the text
speechSynthesis.speak(utterance);
}
function announce(ticket, counter, isRecall = false, extra = '') {
playChime();
const phrase = isRecall ? 'recall' : 'now serving';
const message = `${phrase} ticket ${spellTicket(ticket)} at counter ${spellCounter(counter)} ${extra || ''}`.trim();
// Delay speech slightly to allow chime to play first
setTimeout(() => speakText(message), 150);
}
function spellTicket(code) {
// Improves clarity: A001 -> "A zero zero one"
return code.split('').map(char => {
if (/\d/.test(char)) {
const numbers = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
return numbers[Number(char)];
}
if (/[A-Z]/.test(char)) return char;
return char;
}).join(' ');
}
function spellCounter(counter) {
// Extract number from counter ID (e.g., "C1" -> "1")
const match = counter.match(/\d+/);
return match ? match[0] : counter;
}
// Rendering functions
function renderCounters() {
// Update selects
serviceEl.innerHTML = '';
transferTargetEl.innerHTML = '';
state.counters.forEach(counter => {
const serviceOption = document.createElement('option');
serviceOption.value = counter.id;
serviceOption.textContent = counter.name;
serviceEl.appendChild(serviceOption);
const transferOption = document.createElement('option');
transferOption.value = counter.id;
transferOption.textContent = counter.name;
transferTargetEl.appendChild(transferOption);
});
if (!serviceEl.value && state.counters[0]) {
serviceEl.value = state.counters[0].id;
}
// Render counter cards
countersWrap.innerHTML = '';
state.counters.forEach(counter => {
const div = document.createElement('div');
div.className = 'row';
div.style.justifyContent = 'space-between';
div.innerHTML = `
${counter.id}
Done
Remove
`;
countersWrap.appendChild(div);
});
// Bind event listeners
$$('.counter-name').forEach(input => {
input.addEventListener('change', e => {
const id = e.target.dataset.id;
const counter = state.counters.find(c => c.id === id);
if (counter) {
counter.name = e.target.value || counter.name;
save();
renderCounters();
}
});
});
$$('[data-del]').forEach(button => {
button.addEventListener('click', e => removeCounter(e.target.dataset.del));
});
$$('[data-done]').forEach(button => {
button.addEventListener('click', e => {
const counterId = e.target.dataset.done;
if (!state.now.ticket) return toast('No active ticket', 'danger');
markDone(state.now.ticket, counterId);
});
});
}
function renderQueue() {
queueRowsEl.innerHTML = '';
const waitingTickets = state.tickets
.filter(t => t.status === 'waiting' || t.status === 'serving')
.sort((a, b) => {
// Serving tickets first
if (a.status !== b.status) return a.status === 'serving' ? -1 : 1;
// Priority before regular
if (a.prio !== b.prio) return a.prio ? -1 : 1;
// Then by timestamp
return a.ts - b.ts;
});
waitingTickets.forEach(ticket => {
const row = document.createElement('tr');
row.innerHTML = `
${ticket.id}
${ticket.prio ? 'Priority ' : 'Regular '}
${formatTime(ticket.ts)}
${ticket.status === 'serving' ? 'Serving ' : 'Waiting'}
`;
queueRowsEl.appendChild(row);
});
}
function renderServed() {
servedRowsEl.innerHTML = '';
state.served.forEach(served => {
const row = document.createElement('tr');
row.innerHTML = `
${served.id}
${served.counter}
${formatTime(served.at)}
`;
servedRowsEl.appendChild(row);
});
}
function renderNow() {
nowTicketEl.textContent = state.now.ticket || '—';
if (state.now.counter) {
const counter = state.counters.find(c => c.id === state.now.counter);
const counterName = counter ? counter.name : `Counter ${state.now.counter.replace(/^C/, '').trim()}`;
nowCounterEl.textContent = counterName;
} else {
nowCounterEl.textContent = 'Counter —';
}
}
function renderSettings() {
langEl.value = state.settings.lang || 'en-US';
volumeEl.value = state.settings.volume ?? 1;
chimeEl.value = state.settings.chime || 'ding';
speakEl.value = state.settings.speak || 'on';
}
function render() {
renderCounters();
renderQueue();
renderServed();
renderNow();
renderSettings();
}
// Event listeners
function initializeEventListeners() {
// Ticket management
$('#issueRegular').addEventListener('click', () => issueTicket(false));
$('#issuePriority').addEventListener('click', () => issueTicket(true));
$('#callNext').addEventListener('click', callNext);
$('#recall').addEventListener('click', recall);
$('#skip').addEventListener('click', skip);
$('#transfer').addEventListener('click', () => transfer(transferTargetEl.value));
// Data management
$('#clearServed').addEventListener('click', () => {
state.served = [];
save();
renderServed();
toast('Cleared served list');
});
$('#resetAll').addEventListener('click', () => {
if (confirm('Factory reset? This clears tickets, counters, and settings.')) {
localStorage.removeItem(LS_KEY);
location.reload();
}
});
// Counter management
$('#addCounter').addEventListener('click', addCounter);
// Settings
langEl.addEventListener('change', () => {
state.settings.lang = langEl.value;
save();
});
volumeEl.addEventListener('input', () => {
state.settings.volume = Number(volumeEl.value);
save();
});
chimeEl.addEventListener('change', () => {
state.settings.chime = chimeEl.value;
save();
});
speakEl.addEventListener('change', () => {
state.settings.speak = speakEl.value;
save();
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
// Ignore if user is typing in an input field
if (e.target.matches('input, textarea, select')) return;
const key = e.key.toLowerCase();
if (key === 'n') callNext();
if (key === 'r') recall();
if (key === 's') skip();
});
}
// Display mode for public screens
function initializeDisplayMode() {
const toggleBtn = $('#toggleDisplay');
let displayMode = false;
toggleBtn.addEventListener('click', () => {
displayMode = !displayMode;
document.querySelector('aside').classList.toggle('hidden', displayMode);
toggleBtn.textContent = displayMode ? 'Exit Display Mode' : 'Toggle Display Mode';
// Update ARIA attributes for accessibility
document.querySelector('aside').setAttribute('aria-hidden', displayMode);
});
}
// Self-service kiosk
function initializeKiosk() {
$('#printKiosk').addEventListener('click', () => {
const kioskWindow = window.open('', '_blank', 'width=430,height=700');
if (!kioskWindow) {
toast('Please allow pop-ups for this site', 'danger');
return;
}
kioskWindow.document.write(`
Take a Number
Take a Number
Please select ticket type and press one button below.
Regular
Priority
Prefix
Close this window when finished.
`);
kioskWindow.document.close();
});
// Handle kiosk messages
window.addEventListener('message', e => {
if (!e.data || !e.data.__kiosk) return;
const { type, prefix } = e.data.data;
prefixEl.value = prefix;
issueTicket(type === 'priority');
});
}
// Toast notification system
function toast(message, type = '') {
// Remove existing toasts
$$('.toast').forEach(toast => toast.remove());
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.textContent = message;
toastElement.setAttribute('aria-live', 'polite');
document.body.appendChild(toastElement);
// Animate in
requestAnimationFrame(() => {
toastElement.style.transform = 'translateY(0)';
toastElement.style.opacity = '1';
});
// Remove after delay
setTimeout(() => {
toastElement.style.transform = 'translateY(20px)';
toastElement.style.opacity = '0';
setTimeout(() => {
if (toastElement.parentNode) {
toastElement.remove();
}
}, 300);
}, 2200);
}
// Initialize the application
function init() {
load();
initializeEventListeners();
initializeDisplayMode();
initializeKiosk();
render();
// Seed selects for existing counters on first load
renderCounters();
}
// Start the application when DOM is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.