Join Shared Counter
Collaborate & count together live in real time
Increment (+1)
Space / ↑
Decrement (-1)
↓ Arrow
Increment by Step
Shift + ↑ / PgUp
Decrement by Step
Shift + ↓ / PgDn
Add New Counter
N
Reset Active Counter
R
Lock / Unlock Counter
L
Toggle Direction
M
Start / Pause Timer
T
Toggle Dark Mode
D
View Modes
1 / 2 / 3
Dashboard Stats
S
Show Keyboard Commands Guide
? or H
`;
const printWindow = window.open('', '_blank');
printWindow.document.write(reportHTML); printWindow.document.close();
if (autoPrint) { printWindow.onload = () => { printWindow.focus(); printWindow.print(); printWindow.close(); }; }
this.closeModal('exportModal');
}confirmImport(){
const fileInput = document.getElementById('importFileInput');
if (!fileInput.files.length) { alert('Please select a file.'); return; }
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
if (data.counters && data.settings && data.groups) {
this.counters = data.counters;
this.settings = data.settings;
this.groups = data.groups;
localStorage.setItem('tallySettings', JSON.stringify(this.settings));
localStorage.setItem('tallyGroups', JSON.stringify(this.groups));
alert('Data imported successfully! Page will reload.');
location.reload();
} else { alert('Invalid file format.'); }
} catch (error) { alert('Error reading file.'); }
};
reader.readAsText(fileInput.files[0]);
}handleKeyboard(e) {
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
const key = e.key.toLowerCase();
// Global Keyboard Commands
if (key === '?' || key === 'h') {
e.preventDefault();
const modal = document.getElementById('keyboardModal');
if (modal.classList.contains('hidden')) {
this.showModal('keyboardModal');
} else {
this.closeModal('keyboardModal');
}
return;
}
if (key === 'n') { e.preventDefault(); this.addCounter(); return; }
if (key === 's') { e.preventDefault(); this.showStatsModal(); return; }
if (key === 'd') { e.preventDefault(); this.toggleDarkMode(); return; }
if (key === '1') { e.preventDefault(); this.setActiveView('list'); return; }
if (key === '2') { e.preventDefault(); this.setActiveView('grid'); return; }
if (key === '3') { e.preventDefault(); this.setActiveView('single'); return; }const activeId = this.activeCounterId;
if (!activeId) return;
const c = this.counters.find(x => x.id == activeId);
if (c?.isLocked) {
if ([' ', 'arrowup', 'arrowdown', 'r', 'l', 'm', 't', 'pageup', 'pagedown'].includes(key)) {
if (key === 'l') { e.preventDefault(); this.toggleLock(activeId); return; }
e.preventDefault(); alert("Counter is locked."); return;
}
}// Step Increments
if (e.shiftKey && (key === 'arrowup' || key === 'pageup')) {
e.preventDefault();
this.updateCounter(activeId, c ? c.incrementStep : 1);
return;
}
if (e.shiftKey && (key === 'arrowdown' || key === 'pagedown')) {
e.preventDefault();
this.updateCounter(activeId, -(c ? c.incrementStep : 1));
return;
}switch (key) {
case ' ': case 'arrowup': e.preventDefault(); this.updateCounter(activeId, 1); break;
case 'arrowdown': e.preventDefault(); this.updateCounter(activeId, -1); break;
case 'pageup': e.preventDefault(); this.updateCounter(activeId, c ? c.incrementStep : 1); break;
case 'pagedown': e.preventDefault(); this.updateCounter(activeId, -(c ? c.incrementStep : 1)); break;
case 'r': e.preventDefault(); this.resetCounter(activeId); break;
case 'l': e.preventDefault(); this.toggleLock(activeId); break;
case 'm': e.preventDefault(); this.toggleMode(activeId); break;
case 't': e.preventDefault(); this.toggleAutoIncrement(activeId); break;
}
}showEmptyState(type){
const e = document.getElementById('emptyState'), t = document.getElementById('emptyTitle'), m = document.getElementById('emptyMessage'), b = e.querySelector('button');
if (type === 'initial') { t.textContent = 'No Counters Yet'; m.textContent = 'Click the "+" icon to create your first counter.'; b.classList.remove('hidden'); }
else { t.textContent = 'No Counters Found'; m.textContent = 'Your search or filter did not match any counters.'; b.classList.add('hidden'); }
e.classList.remove('hidden');
}renderSingleViewDropdown(counters) {
const options = counters.map(counter => `
`).join('');
return `
`;
}playSound(isIncrement) {
try {
if (!this.audioCtx || this.audioCtx.state === 'closed') {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
const ac = this.audioCtx;
const now = ac.currentTime;
const o = ac.createOscillator();
const g = ac.createGain();
o.type = 'triangle';
o.frequency.setValueAtTime(isIncrement ? 960 : 480, now);
if (isIncrement) {
o.frequency.exponentialRampToValueAtTime(1200, now + 0.04);
} else {
o.frequency.exponentialRampToValueAtTime(320, now + 0.04);
}
g.gain.setValueAtTime(0.55, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
o.connect(g);
g.connect(ac.destination);
o.start(now);
o.stop(now + 0.08);
} catch (e) {}
}toggleLock(id) {
const c = this.counters.find(x => x.id == id);
if (!c) return;
c.isLocked = !c.isLocked;
if (this.currentUser || this.isSharedSession || !String(id).startsWith('guest_')) {
database.ref(`counters/${id}/isLocked`).set(c.isLocked);
} else {
this.saveGuestCounters();
}
this.renderCounters();
}toggleMode(id) {
const c = this.counters.find(x => x.id == id);
if (!c) return;
if (c.isLocked) { alert("This counter is locked."); return; }
const wasRunning = c.isAutoIncrementing;
if (wasRunning) this.stopTimer(id);
c.mode = c.mode === 'up' ? 'down' : 'up';
if (c.mode === 'down' && c.value === 0 && c.target > 0) c.value = c.target;
if (c.mode === 'up' && c.target > 0 && c.value === c.target) c.value = 0;
c.history.push({ timestamp: new Date().toISOString(), value: c.value, type: 'mode-change' });
if (this.currentUser || this.isSharedSession || !String(id).startsWith('guest_')) {
database.ref(`counters/${id}`).update({ mode: c.mode, value: c.value, history: c.history });
} else {
this.saveGuestCounters();
}
if (wasRunning) this.startTimer(id);
this.renderCounters();
const dropdown = document.querySelector(`[data-id="${id}"] .three-dot-dropdown`);
if (dropdown) dropdown.classList.remove('show');
}applyQuickStep(id, step) {
const c = this.counters.find(x => x.id == id);
if (!c || c.isLocked) return;
const direction = step > 0 ? 1 : -1;
const absStep = Math.abs(step);
let newValue = c.value + step;
if (!this.settings.allowNegativeValues) newValue = Math.max(0, newValue);
if (c.mode === 'down' && newValue <= 0) { newValue = 0; if (c.isAutoIncrementing) this.stopTimer(id); }
if (c.mode === 'up' && c.target > 0 && newValue >= c.target) { newValue = c.target; if (c.isAutoIncrementing) this.stopTimer(id); }
c.value = newValue;
c.history.push({ timestamp: new Date().toISOString(), value: c.value });
if (this.currentUser || this.isSharedSession || !String(id).startsWith('guest_')) {
database.ref(`counters/${id}`).update({ value: newValue, history: c.history });
} else {
this.saveGuestCounters();
}
if (this.settings.soundEffects) this.playSound(step > 0);
this.renderCounters();
this.updateStats();
this.pulseElement(id);
}startDirectEntry(id) {
const c = this.counters.find(x => x.id == id);
if (!c || c.isLocked) return;
const display = document.querySelector(`[data-id="${id}"] .counter-number-display`);
if (!display) return;
const currentValue = c.value;
display.innerHTML = `
`;
const input = display.querySelector('input');
input.focus(); input.select();
const commitValue = () => {
const newValue = parseInt(input.value);
if (!isNaN(newValue)) {
let finalValue = newValue;
if (!this.settings.allowNegativeValues) finalValue = Math.max(0, finalValue);
if (finalValue !== c.value) {
c.value = finalValue;
c.history.push({ timestamp: new Date().toISOString(), value: c.value, type: 'direct-entry' });
if (this.currentUser || this.isSharedSession || !String(id).startsWith('guest_')) {
database.ref(`counters/${id}`).update({ value: finalValue, history: c.history });
} else {
this.saveGuestCounters();
}
this.updateStats();
}
}
this.renderCounters();
};
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commitValue(); } else if (e.key === 'Escape') this.renderCounters(); });
input.addEventListener('blur', commitValue);
}toggleTimerDirection(id) {
const c = this.counters.find(x => x.id == id);
if (!c || c.isLocked) return;
const wasRunning = c.isAutoIncrementing;
if (wasRunning) this.stopTimer(id);
c.timerDirection = c.timerDirection === 'up' ? 'down' : 'up';
if (this.currentUser || this.isSharedSession || !String(id).startsWith('guest_')) {
database.ref(`counters/${id}/timerDirection`).set(c.timerDirection);
} else {
this.saveGuestCounters();
}
if (wasRunning) this.startTimer(id);
this.renderCounters();
}togglePasswordVisibility(inputId, iconId) {
const input = document.getElementById(inputId);
const icon = document.getElementById(iconId);
if (!input) return;
if (input.type === 'password') {
input.type = 'text';
if (icon) { icon.classList.remove('fa-eye'); icon.classList.add('fa-eye-slash'); }
} else {
input.type = 'password';
if (icon) { icon.classList.remove('fa-eye-slash'); icon.classList.add('fa-eye'); }
}
}showAuthModal() { this.showModal('authModal'); }
showShareModal() {
if (!this.currentUser) {
const notice = document.getElementById('authNoticeText');
if (notice) notice.textContent = 'Log in or Register to save & share your counters.';
this.showAuthModal();
return;
}
const select = document.getElementById('shareCounterSelect');
const myCounters = this.counters.filter(c => c.ownerId === this.currentUser.uid);
if (myCounters.length === 0) { alert('Create a counter first before sharing.'); return; }
select.innerHTML = myCounters.map(c => `
`).join('');
document.getElementById('shareResultCard').classList.add('hidden');
document.getElementById('sharePassword').value = '';
this.loadMyShares();
this.showModal('shareModal');
}generateShareLink() {
if (!this.currentUser) { this.showAuthModal(); return; }
const counterId = document.getElementById('shareCounterSelect').value;
const password = document.getElementById('sharePassword').value.trim();
const c = this.counters.find(x => x.id == counterId);
if (!c) return;const shareUrl = `${window.location.origin}${window.location.pathname}?counter=${counterId}`;
const shareCreatedAt = Date.now();database.ref(`counters/${counterId}`).update({
shared: true,
sharePassword: password || null,
shareCreatedAt: shareCreatedAt
}).then(() => {
this.saveUserShareToLocal(counterId, {
counterId: counterId,
counterName: c.name,
sharePassword: password || null,
createdAt: shareCreatedAt,
shareUrl: shareUrl
});this.displayGeneratedShareLink(shareUrl, password, c.name);
this.loadMyShares();
alert('Share link generated & saved to your account!');
}).catch(err => {
this.saveUserShareToLocal(counterId, {
counterId: counterId,
counterName: c.name,
sharePassword: password || null,
createdAt: shareCreatedAt,
shareUrl: shareUrl
});
this.displayGeneratedShareLink(shareUrl, password, c.name);
this.loadMyShares();
alert('Share link generated!');
});
}saveUserShareToLocal(counterId, shareData) {
if (!this.currentUser) return;
const key = `tallyUserShares_${this.currentUser.uid}`;
const saved = JSON.parse(localStorage.getItem(key) || '{}');
saved[counterId] = shareData;
localStorage.setItem(key, JSON.stringify(saved));
}displayGeneratedShareLink(shareUrl, password, counterName) {
document.getElementById('shareLink').value = shareUrl;
const pwdDisplay = document.getElementById('sharePasswordDisplay');
pwdDisplay.dataset.realPassword = password || '';
pwdDisplay.dataset.isMasked = 'true';
pwdDisplay.textContent = password ? '••••••' : 'None';
const eyeBtn = document.getElementById('passwordDisplayEye');
const labelBtn = document.getElementById('passwordDisplayLabel');
if (eyeBtn) eyeBtn.className = 'fas fa-eye';
if (labelBtn) labelBtn.textContent = password ? 'Show' : '';this.currentActiveShare = { shareUrl, password, counterName };
document.getElementById('shareResultCard').classList.remove('hidden');
}togglePasswordDisplay() {
const pwdDisplay = document.getElementById('sharePasswordDisplay');
const eyeBtn = document.getElementById('passwordDisplayEye');
const labelBtn = document.getElementById('passwordDisplayLabel');
const realPassword = pwdDisplay.dataset.realPassword;
if (!realPassword) return;if (pwdDisplay.dataset.isMasked === 'true') {
pwdDisplay.textContent = realPassword;
pwdDisplay.dataset.isMasked = 'false';
if (eyeBtn) eyeBtn.className = 'fas fa-eye-slash';
if (labelBtn) labelBtn.textContent = 'Hide';
} else {
pwdDisplay.textContent = '••••••';
pwdDisplay.dataset.isMasked = 'true';
if (eyeBtn) eyeBtn.className = 'fas fa-eye';
if (labelBtn) labelBtn.textContent = 'Show';
}
}copyShareLink() {
const input = document.getElementById('shareLink');
if (!input.value) { alert('Generate a link first'); return; }
navigator.clipboard.writeText(input.value).then(() => {
alert('Link copied to clipboard!');
}).catch(() => {
input.select(); document.execCommand('copy');
alert('Link copied to clipboard!');
});
}shareViaWhatsApp(url, password, name) {
const shareUrl = url || (this.currentActiveShare ? this.currentActiveShare.shareUrl : document.getElementById('shareLink').value);
const pwd = password !== undefined ? password : (this.currentActiveShare ? this.currentActiveShare.password : document.getElementById('sharePasswordDisplay').dataset.realPassword);
const counterName = name || (this.currentActiveShare ? this.currentActiveShare.counterName : 'Counter');
if (!shareUrl) { alert('Generate a link first'); return; }
let text = `📊 *Tally Counter: ${counterName}*\n🔗 Link: ${shareUrl}`;
if (pwd) text += `\n🔑 Password: ${pwd}`;
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank');
}shareViaEmail(url, password, name) {
const shareUrl = url || (this.currentActiveShare ? this.currentActiveShare.shareUrl : document.getElementById('shareLink').value);
const pwd = password !== undefined ? password : (this.currentActiveShare ? this.currentActiveShare.password : document.getElementById('sharePasswordDisplay').dataset.realPassword);
const counterName = name || (this.currentActiveShare ? this.currentActiveShare.counterName : 'Counter');
if (!shareUrl) { alert('Generate a link first'); return; }
const subject = `Shared Tally Counter - ${counterName}`;
let body = `Hello,\n\nYou have been shared access to the Tally Counter "${counterName}".\n\nLink: ${shareUrl}\n`;
if (pwd) body += `Password: ${pwd}\n`;
body += `\nOpen the link to view or update the count.`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}copyAllShareDetails(url, password, name) {
const shareUrl = url || (this.currentActiveShare ? this.currentActiveShare.shareUrl : document.getElementById('shareLink').value);
const pwd = password !== undefined ? password : (this.currentActiveShare ? this.currentActiveShare.password : document.getElementById('sharePasswordDisplay').dataset.realPassword);
const counterName = name || (this.currentActiveShare ? this.currentActiveShare.counterName : 'Counter');
if (!shareUrl) { alert('Generate a link first'); return; }
let text = `Tally Counter: ${counterName}\nLink: ${shareUrl}`;
if (pwd) text += `\nPassword: ${pwd}`;navigator.clipboard.writeText(text).then(() => {
alert('Link & Password copied to clipboard!');
}).catch(() => {
alert('Copied details:\n' + text);
});
}loadMyShares() {
if (!this.currentUser) return;
const container = document.getElementById('mySharesList');
const countBadge = document.getElementById('mySharesCount');
if (!container || !countBadge) return;const key = `tallyUserShares_${this.currentUser.uid}`;
const localShares = JSON.parse(localStorage.getItem(key) || '{}');
const sharesMap = { ...localShares };this.counters.forEach(c => {
if (c.ownerId === this.currentUser.uid && c.shared) {
sharesMap[c.id] = {
counterId: c.id,
counterName: c.name,
sharePassword: c.sharePassword || null,
createdAt: c.shareCreatedAt || c.createdAt || Date.now(),
shareUrl: `${window.location.origin}${window.location.pathname}?counter=${c.id}`
};
}
});const shareItems = Object.values(sharesMap);
countBadge.textContent = shareItems.length;if (shareItems.length === 0) {
container.innerHTML = '
No saved share links yet.
';
return;
}container.innerHTML = shareItems.map(item => {
const pwdText = item.sharePassword ? item.sharePassword : 'None';
const dateStr = new Date(item.createdAt).toLocaleDateString();
return `
${this.escapeHtml(item.counterName || 'Counter')}
${dateStr}
Link:
Password:
${this.escapeHtml(pwdText)}
`;
}).join('');
}deleteShareLink(counterId) {
if (!confirm('Remove this share link? (Others will no longer be able to access via this link)')) return;
if (!this.currentUser) return;
const key = `tallyUserShares_${this.currentUser.uid}`;
const saved = JSON.parse(localStorage.getItem(key) || '{}');
delete saved[counterId];
localStorage.setItem(key, JSON.stringify(saved));database.ref(`counters/${counterId}`).update({ shared: false, sharePassword: null }).then(() => {
this.loadMyShares();
alert('Share link deleted.');
}).catch(() => {
this.loadMyShares();
alert('Share link deleted.');
});
}checkUrlForSharedCounter() {
const urlParams = new URLSearchParams(window.location.search);
const counterId = urlParams.get('counter');
if (counterId) {
this.sharedCounterId = counterId;
this.connectToSharedCounter(counterId);
}
}showJoinModal(presetId = '') {
if (!this.currentUser) {
alert('Please log in, register, or continue as a guest before joining a counter.');
const notice = document.getElementById('authNoticeText');
if (notice) notice.textContent = 'Please log in, register, or continue as a guest to join counters.';
this.showAuthModal();
return;
}
if (presetId && document.getElementById('joinCounterInput')) {
document.getElementById('joinCounterInput').value = presetId;
}
document.getElementById('joinPassword').value = '';
this.showModal('joinModal');
}async joinSharedCounter() {
if (!this.currentUser) {
alert('Please log in, register, or continue as a guest before joining a counter.');
this.closeModal('joinModal');
const notice = document.getElementById('authNoticeText');
if (notice) notice.textContent = 'Please log in, register, or continue as a guest to join counters.';
this.showAuthModal();
return;
}let inputVal = document.getElementById('joinCounterInput').value.trim();
const password = document.getElementById('joinPassword').value.trim();
if (!inputVal && this.sharedCounterId) {
inputVal = this.sharedCounterId;
}if (!inputVal) {
alert('Please enter a Share Link or Counter ID');
return;
}let counterId = inputVal;
try {
if (inputVal.includes('counter=')) {
const u = new URL(inputVal.startsWith('http') ? inputVal : `http://${inputVal}`);
const p = u.searchParams.get('counter');
if (p) counterId = p;
}
} catch (e) {}this.connectToSharedCounter(counterId, password);
}updateHeaderJoinButton(isLive) {
const btn = document.getElementById('headerJoinBtn');
if (!btn) return;
if (isLive) {
btn.className = 'bg-gradient-to-r from-red-500 to-indigo-600 hover:from-red-600 hover:to-indigo-700 text-white px-3 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition shadow-sm';
btn.title = 'Click to Exit Live Session';
btn.onclick = () => app.exitSharedSession();
btn.innerHTML = `
Live (Exit)`;
} else {
btn.className = 'bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition shadow-sm';
btn.title = 'Join Shared Counter';
btn.onclick = () => app.showJoinModal();
btn.innerHTML = `
Join Counter`;
}
}connectToSharedCounter(counterId, enteredPassword = '') {
if (this.countersListener) {
this.countersListener.off('value');
this.countersListener = null;
}
this.isSharedSession = true;database.ref(`counters/${counterId}`).once('value', snapshot => {
const c = snapshot.val();
if (!c) {
alert('Shared counter not found or has been deleted.');
this.exitSharedSession();
return;
}if (c.sharePassword) {
if (!enteredPassword || enteredPassword !== c.sharePassword) {
this.sharedCounterId = counterId;
this.showJoinModal(counterId);
if (enteredPassword) {
alert('Incorrect password for this shared counter!');
}
return;
}
}this.sharedCounterId = counterId;
this.closeModal('joinModal');const newUrl = `${window.location.pathname}?counter=${counterId}`;
window.history.replaceState({}, document.title, newUrl);if (this.sharedListener) this.sharedListener.off('value');
this.sharedListener = database.ref(`counters/${counterId}`);
this.sharedListener.on('value', snap => {
const liveData = snap.val();
if (liveData) {
liveData.id = counterId;
liveData.isSharedLive = true;
this.counters = [liveData];
this.activeCounterId = counterId;
this.renderAll();
this.updateSyncStatus('online');
this.updateHeaderJoinButton(true);
} else {
alert('Shared counter was deleted by owner.');
this.exitSharedSession();
}
});
});
}exitSharedSession() {
this.isSharedSession = false;
if (this.sharedListener) {
this.sharedListener.off('value');
this.sharedListener = null;
}
this.sharedCounterId = null;
window.history.replaceState({}, document.title, window.location.pathname);
this.updateHeaderJoinButton(false);if (this.currentUser) {
this.loadCounters();
} else {
this.loadGuestCounters();
}
}updateSyncStatus(status) {
const el = document.getElementById('syncStatus');
if (!el) return;
const icons = { online: '
', offline: '
', syncing: '
' };
el.className = `sync-indicator sync-${status}`;
el.innerHTML = `${icons[status]}`;
}
}document.addEventListener('DOMContentLoaded', () => { window.app = new TallyCounter(); });