const API_URL = window.location.origin; // Humorous step tips const STEP_TIPS = [ "Never underestimate a 5-minute dance break. Your step count (and mood) will thank you.", "Don't let the high-steppers get you down. Focus on crushing whoever's ranked right above you.", "Pacing while on phone calls counts. Your colleagues will understand the extra enthusiasm.", "Pro tip: Park in the farthest spot. It's not laziness, it's strategy.", "Taking the stairs? That's just vertical stepping with extra credit.", "Your fitness tracker thinks you're training for a marathon. Let's not disappoint it.", "Remember: Every journey to the fridge and back is an opportunity for greatness.", "Walking meetings are just step competitions in disguise. You're welcome.", "Forgot something upstairs? That's not forgetfulness, that's bonus cardio.", "Why run when you can aggressively walk? Same steps, less judgment.", "Your dog wants a walk. Your step count wants a walk. It's basically a win-win-woof.", "A monster a day keeps the last place away.", "Reading this tip instead of walking? Bold strategy. Let's see how it plays out.", "Pro tip about tips: They're less effective if you're sitting down while reading them.", "Winners log their steps before noon. Legends log them before breakfast. -Mike (probably)", "Infrastructure as a Service is down? Legs as a Service is always running.", "Strapping the tracker to a ceiling fan was attempted in 2019. It didn't work then either.", "Following your dog around the house may not be the most efficient way to get steps, but it's definitely the most confusing for the dog.", "Dancing in the school pickup line may not be the most efficient way to get steps, but it's definitely the most embarrassing for your kids.", "4 out of 5 dentists agree: this has nothing to do with teeth, but walking is still good.", "Rumor has it the winner of the last competition was just trying to learn the latest Katseye choreo.", "Alexa can't count your steps - make your light switch your new favorite app.", ]; // State let teams = []; let participants = []; let charts = {}; let selectedParticipant = null; let currentTipIndex = 0; let tipRotationInterval = null; // Initialize app document.addEventListener('DOMContentLoaded', () => { initializeTabs(); loadInitialData(); setupEventListeners(); setDefaultDate(); handleRouting(); checkAdminMode(); initializeTipRotation(); }); // Admin mode function checkAdminMode() { const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('admin') === 'true') { document.querySelectorAll('.import-section').forEach(el => { el.style.display = 'block'; }); } } // Routing function handleRouting() { // Handle initial load and hash changes window.addEventListener('hashchange', navigateToTab); navigateToTab(); } function navigateToTab() { const hash = window.location.hash.slice(1) || 'log'; // Default to 'log' tab switchTab(hash); } function switchTab(tabName) { const tabButtons = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); // Remove active class from all tabButtons.forEach(btn => btn.classList.remove('active')); tabContents.forEach(content => content.classList.remove('active')); // Add active class to selected tab const activeButton = document.querySelector(`[data-tab="${tabName}"]`); const activeContent = document.getElementById(`${tabName}-tab`); if (activeButton && activeContent) { activeButton.classList.add('active'); activeContent.classList.add('active'); // Load data for specific tabs if (tabName === 'teams') loadTeamStandings(); if (tabName === 'stats') loadCompetitionStats(); if (tabName === 'monsters') loadDailyMonsters(); } } // Tab management function initializeTabs() { const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); const tabName = button.dataset.tab; window.location.hash = tabName; }); }); } // Set default date to yesterday (since we enter steps the day after) function setDefaultDate() { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayStr = yesterday.toISOString().split('T')[0]; const dateInput = document.getElementById('step-date'); dateInput.value = yesterdayStr; // Set max date to October 31, 2025 dateInput.max = '2025-10-31'; dateInput.min = '2025-10-20'; } // Load initial data async function loadInitialData() { await loadTeams(); await loadParticipants(); } // Event listeners function setupEventListeners() { document.getElementById('team-select').addEventListener('change', onTeamSelect); document.getElementById('name-select').addEventListener('change', onNameSelect); document.getElementById('submit-steps').addEventListener('click', submitSteps); document.getElementById('import-csv').addEventListener('click', importCSV); document.getElementById('import-monsters').addEventListener('click', importMonsters); } // API calls async function apiCall(endpoint, method = 'GET', body = null) { const options = { method, headers: { 'Content-Type': 'application/json' } }; if (body) { options.body = JSON.stringify(body); } const response = await fetch(`${API_URL}${endpoint}`, options); if (!response.ok) { throw new Error(`API call failed: ${response.statusText}`); } return response.json(); } // Teams async function loadTeams() { try { teams = await apiCall('/api/teams'); updateTeamSelect(); } catch (error) { console.error('Error loading teams:', error); } } function updateTeamSelect() { const select = document.getElementById('team-select'); select.innerHTML = ''; teams.forEach(team => { const option = document.createElement('option'); option.value = team.id; option.textContent = team.name; select.appendChild(option); }); } // Participants async function loadParticipants() { try { participants = await apiCall('/api/participants'); } catch (error) { console.error('Error loading participants:', error); } } async function onTeamSelect(e) { const teamId = parseInt(e.target.value); const nameSelect = document.getElementById('name-select'); if (!teamId) { nameSelect.disabled = true; nameSelect.innerHTML = ''; document.getElementById('team-stats').innerHTML = '

Select your team to see team stats

'; return; } // Filter participants by team const teamParticipants = participants.filter(p => p.team_id === teamId); nameSelect.disabled = false; nameSelect.innerHTML = ''; teamParticipants.forEach(participant => { const option = document.createElement('option'); option.value = participant.id; option.textContent = participant.name; nameSelect.appendChild(option); }); // Load team stats await loadTeamStats(teamId); } async function onNameSelect(e) { const participantId = parseInt(e.target.value); if (!participantId) { selectedParticipant = null; document.getElementById('personal-stats').innerHTML = '

Select your name to see your history

'; document.getElementById('personal-history').innerHTML = ''; return; } selectedParticipant = participants.find(p => p.id === participantId); await loadPersonalHistory(participantId); } async function loadPersonalHistory(participantId) { try { const history = await apiCall(`/api/participants/${participantId}/history`); const individualData = await apiCall('/api/leaderboard/individual'); const personData = individualData.find(p => p.id === participantId); // Display stats const statsBox = document.getElementById('personal-stats'); if (personData) { const avgSteps = personData.days_logged > 0 ? Math.round(personData.total_steps / personData.days_logged) : 0; statsBox.innerHTML = `
Total Steps: ${personData.total_steps.toLocaleString()}
Days Logged: ${personData.days_logged}
Avg Steps/Day: ${avgSteps.toLocaleString()}
`; } // Display history const historyDiv = document.getElementById('personal-history'); if (history.length === 0) { historyDiv.innerHTML = '

No entries yet. Start logging your steps!

'; } else { historyDiv.innerHTML = history.map(entry => `
${formatDate(entry.date)} ${entry.steps.toLocaleString()} steps
`).join(''); } } catch (error) { console.error('Error loading personal history:', error); } } async function loadTeamStats(teamId) { try { const [individualData, teamData, timeline] = await Promise.all([ apiCall('/api/leaderboard/individual'), apiCall('/api/leaderboard/team'), apiCall('/api/monsters/timeline') ]); const team = teamData.find(t => t.id === teamId); const teamMembers = individualData.filter(p => p.team_id === teamId); const teamStatsDiv = document.getElementById('team-stats'); if (team && teamMembers.length > 0) { // Sort team members by total steps teamMembers.sort((a, b) => b.total_steps - a.total_steps); // Find monsters caught by this team const caughtMonsters = timeline.filter(monster => monster.catches.some(c => c.team_id === teamId) ); teamStatsDiv.innerHTML = `
Team Total: ${team.total_steps.toLocaleString()}
${caughtMonsters.length > 0 ? `
Monsters Caught: ${caughtMonsters.length} 🏆
${caughtMonsters.map(m => ` ${m.monster_icon} `).join('')}
` : ''}

Team Members:

${teamMembers.map((member, index) => `
${index + 1}. ${member.name} ${member.total_steps.toLocaleString()}
`).join('')} `; } } catch (error) { console.error('Error loading team stats:', error); } } // Step submission async function submitSteps() { const participantId = document.getElementById('name-select').value; const date = document.getElementById('step-date').value; const steps = parseInt(document.getElementById('step-count').value); const messageDiv = document.getElementById('entry-message'); if (!participantId || !date || isNaN(steps)) { showMessage(messageDiv, 'Please fill in all fields', 'error'); return; } // Validate date is not after October 31, 2025 const contestEndDate = '2025-10-31'; if (date > contestEndDate) { showMessage(messageDiv, 'Contest ended on October 31, 2025. Cannot enter steps after this date.', 'error'); return; } try { await apiCall('/api/steps', 'POST', { participant_id: parseInt(participantId), date, steps }); document.getElementById('step-count').value = ''; showMessage(messageDiv, 'Steps submitted successfully!', 'success'); // Reload personal history and team stats if (selectedParticipant) { await loadPersonalHistory(selectedParticipant.id); const teamId = document.getElementById('team-select').value; if (teamId) { await loadTeamStats(parseInt(teamId)); } } } catch (error) { showMessage(messageDiv, `Error: ${error.message}`, 'error'); } } // CSV Import async function importCSV() { const fileInput = document.getElementById('csv-file'); const messageDiv = document.getElementById('import-message'); if (!fileInput.files || !fileInput.files[0]) { showMessage(messageDiv, 'Please select a CSV file', 'error'); return; } const file = fileInput.files[0]; const reader = new FileReader(); reader.onload = async (e) => { try { const csvText = e.target.result; const lines = csvText.split('\n').filter(line => line.trim()); // Skip header row const dataLines = lines.slice(1); const data = dataLines.map(line => { const [team_name, participant_name, email] = line.split(',').map(s => s.trim()); return { team_name, participant_name, email }; }); const result = await apiCall('/api/import/csv', 'POST', { data }); showMessage( messageDiv, `Success! Created ${result.teamsCreated} teams and ${result.participantsCreated} participants`, 'success' ); // Reload data await loadTeams(); await loadParticipants(); fileInput.value = ''; } catch (error) { showMessage(messageDiv, `Error: ${error.message}`, 'error'); } }; reader.readAsText(file); } // Team Standings Tab async function loadTeamStandings() { try { const [teamData, individualData, timeline] = await Promise.all([ apiCall('/api/leaderboard/team'), apiCall('/api/leaderboard/individual'), apiCall('/api/monsters/timeline') ]); // Team Leaderboard const teamTbody = document.querySelector('#team-leaderboard tbody'); teamTbody.innerHTML = ''; teamData.forEach((team, index) => { const row = teamTbody.insertRow(); const avgPerPerson = team.member_count > 0 ? Math.round(team.total_steps / team.member_count) : 0; // Find monsters caught by this team const caughtMonsters = timeline.filter(monster => monster.catches.some(c => c.team_id === team.id) ); const monsterBadges = caughtMonsters.length > 0 ? caughtMonsters.map(m => `${m.monster_icon}`).join(' ') : '-'; row.innerHTML = ` ${index + 1} ${team.team_name} ${team.total_steps.toLocaleString()} ${team.active_count} ${avgPerPerson.toLocaleString()} ${monsterBadges} `; }); // Individual Leaderboard const individualTbody = document.querySelector('#individual-leaderboard tbody'); individualTbody.innerHTML = ''; individualData.forEach((person, index) => { const row = individualTbody.insertRow(); const avgSteps = person.days_logged > 0 ? Math.round(person.total_steps / person.days_logged) : 0; row.innerHTML = ` ${index + 1} ${person.name} ${person.team_name} ${person.total_steps.toLocaleString()} ${person.days_logged} ${avgSteps.toLocaleString()} `; }); } catch (error) { console.error('Error loading team standings:', error); } } // Competition Stats Tab async function loadCompetitionStats() { try { const [individualData, dailyProgress, teamData] = await Promise.all([ apiCall('/api/leaderboard/individual'), apiCall('/api/progress/daily'), apiCall('/api/leaderboard/team') ]); // Calculate overall stats const totalSteps = individualData.reduce((sum, p) => sum + p.total_steps, 0); const totalParticipants = individualData.length; const totalDaysLogged = individualData.reduce((sum, p) => sum + p.days_logged, 0); const avgDailySteps = totalDaysLogged > 0 ? Math.round(totalSteps / totalDaysLogged) : 0; // Get unique dates const uniqueDates = [...new Set(dailyProgress.map(d => d.date))]; const daysActive = uniqueDates.length; // Update stat cards document.getElementById('total-steps').textContent = totalSteps.toLocaleString(); document.getElementById('total-participants').textContent = totalParticipants; document.getElementById('avg-daily-steps').textContent = avgDailySteps.toLocaleString(); document.getElementById('days-active').textContent = daysActive; // Render charts renderTeamProgressChart(dailyProgress); renderTopIndividualsChart(individualData.slice(0, 10)); await renderTeamComparisonChart(teamData); } catch (error) { console.error('Error loading competition stats:', error); } } function renderTeamProgressChart(data) { const ctx = document.getElementById('team-progress-chart'); // Group by team const teamData = {}; data.forEach(entry => { if (!teamData[entry.team_name]) { teamData[entry.team_name] = { dates: [], steps: [] }; } teamData[entry.team_name].dates.push(entry.date); teamData[entry.team_name].steps.push(entry.daily_steps); }); // Get all unique dates sorted const allDates = [...new Set(data.map(d => d.date))].sort(); // Create datasets const colors = [ 'rgba(102, 126, 234, 1)', 'rgba(118, 75, 162, 1)', 'rgba(237, 100, 166, 1)', 'rgba(255, 154, 158, 1)', 'rgba(250, 208, 196, 1)', 'rgba(136, 140, 255, 1)', 'rgba(72, 1, 255, 1)', 'rgba(255, 193, 7, 1)' ]; const datasets = Object.entries(teamData).map(([teamName, data], index) => { const cumulativeSteps = []; let total = 0; allDates.forEach(date => { const dateIndex = data.dates.indexOf(date); if (dateIndex !== -1) { total += data.steps[dateIndex]; } cumulativeSteps.push(total); }); return { label: teamName, data: cumulativeSteps, borderColor: colors[index % colors.length], backgroundColor: colors[index % colors.length].replace('1)', '0.1)'), borderWidth: 3, fill: true, tension: 0.4 }; }); if (charts.teamProgress) { charts.teamProgress.destroy(); } charts.teamProgress = new Chart(ctx, { type: 'line', data: { labels: allDates, datasets: datasets }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: true, position: 'top' } }, scales: { y: { beginAtZero: true, ticks: { callback: function(value) { return value.toLocaleString(); } } } } } }); } function renderTopIndividualsChart(data) { const ctx = document.getElementById('top-individuals-chart'); if (charts.topIndividuals) { charts.topIndividuals.destroy(); } charts.topIndividuals = new Chart(ctx, { type: 'bar', data: { labels: data.map(p => p.name), datasets: [{ label: 'Total Steps', data: data.map(p => p.total_steps), backgroundColor: 'rgba(102, 126, 234, 0.8)', borderColor: 'rgba(102, 126, 234, 1)', borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: function(value) { return value.toLocaleString(); } } } } } }); } async function renderTeamComparisonChart(teamData) { const ctx = document.getElementById('team-comparison-chart'); if (charts.teamComparison) { charts.teamComparison.destroy(); } // Get individual data to break down each team const individualData = await apiCall('/api/leaderboard/individual'); // Generate distinct colors for each person const personColors = [ 'rgba(102, 126, 234, 0.8)', 'rgba(118, 75, 162, 0.8)', 'rgba(237, 100, 166, 0.8)', 'rgba(255, 154, 158, 0.8)', 'rgba(250, 208, 196, 0.8)', 'rgba(136, 140, 255, 0.8)', 'rgba(72, 1, 255, 0.8)', 'rgba(255, 193, 7, 0.8)', 'rgba(0, 188, 212, 0.8)', 'rgba(156, 39, 176, 0.8)', 'rgba(255, 87, 34, 0.8)', 'rgba(76, 175, 80, 0.8)' ]; // Get team labels sorted by total steps const sortedTeams = teamData.sort((a, b) => b.total_steps - a.total_steps); const teamLabels = sortedTeams.map(t => t.team_name); // Create a dataset for each unique participant const allParticipants = individualData.sort((a, b) => b.total_steps - a.total_steps); const datasets = allParticipants.map((person, index) => { // Create data array with this person's steps for their team, 0 for others const data = teamLabels.map(teamName => { return person.team_name === teamName ? person.total_steps : 0; }); return { label: person.name, data: data, backgroundColor: personColors[index % personColors.length], borderWidth: 1, borderColor: 'white' }; }); charts.teamComparison = new Chart(ctx, { type: 'bar', data: { labels: teamLabels, datasets: datasets }, options: { indexAxis: 'y', // Makes it horizontal responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false }, tooltip: { mode: 'index', callbacks: { label: function(context) { const label = context.dataset.label || ''; const value = context.parsed.x; return value > 0 ? `${label}: ${value.toLocaleString()} steps` : null; }, afterBody: function(tooltipItems) { // Calculate the team total by summing all items in this tooltip const total = tooltipItems.reduce((sum, item) => sum + item.parsed.x, 0); return `\nTeam Total: ${total.toLocaleString()} steps`; } } } }, scales: { x: { stacked: true, beginAtZero: true, ticks: { callback: function(value) { return value.toLocaleString(); } } }, y: { stacked: true } } } }); } // Daily Monsters Tab async function loadDailyMonsters() { try { const [todayStatus, timeline] = await Promise.all([ apiCall('/api/monsters/status/today'), apiCall('/api/monsters/timeline') ]); // Render today's monster renderTodaysMonster(todayStatus); // Render timeline renderMonsterTimeline(timeline); } catch (error) { console.error('Error loading daily monsters:', error); } } function renderTodaysMonster(status) { const cardDiv = document.getElementById('todays-monster-card'); if (!status.monster) { cardDiv.innerHTML = '

No monster challenge available. Check back tomorrow!

'; return; } const { monster, progress } = status; // Calculate how many teams caught it const caughtCount = progress.filter(p => p.caught).length; // Format the date nicely const monsterDate = new Date(monster.date + 'T00:00:00'); const formattedDate = monsterDate.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }); cardDiv.innerHTML = `

📅 Active Challenge: Log your steps from ${formattedDate} (yesterday)

${monster.monster_icon}

${monster.monster_name}

${monster.monster_description || ''}

Goal: ${monster.step_goal.toLocaleString()} steps
${caughtCount} of ${progress.length} teams caught this monster!

Team Progress:

${progress.map(team => { const percentage = Math.min((team.total_steps / monster.step_goal) * 100, 100); const caught = team.caught; return `
${team.team_name} ${team.total_steps.toLocaleString()} steps ${caught ? '🏆' : ''}
`; }).join('')}
`; } function renderMonsterTimeline(timeline) { const timelineDiv = document.getElementById('monster-timeline'); // Handle null, undefined, or empty array if (!timeline || !Array.isArray(timeline) || timeline.length === 0) { timelineDiv.innerHTML = '

No monsters have been configured yet.

'; return; } // Get yesterday's date (active challenge - enter yesterday's steps) const now = new Date(); now.setDate(now.getDate() - 1); const yesterday = now.toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0]; timelineDiv.innerHTML = timeline.map(monster => { const monsterDate = monster.date; const isPast = monsterDate < yesterday; const isYesterday = monsterDate === yesterday; // Active challenge - enter yesterday's steps const isToday = monsterDate === today; // Next challenge (tomorrow) const isFuture = monsterDate > today; let statusClass = 'future'; let statusText = 'Upcoming'; if (isPast) { statusClass = 'past'; statusText = monster.catches.length > 0 ? `${monster.catches.length} teams caught` : 'Escaped'; } else if (isYesterday) { statusClass = 'today'; statusText = "Active - Enter Yesterday's Steps"; } else if (isToday) { statusClass = 'tomorrow'; statusText = 'Next Challenge'; } return `
${monster.monster_icon}

${monster.monster_name}

${formatDate(monster.date)}
${statusText}

${monster.monster_description || ''}

Goal: ${monster.step_goal.toLocaleString()} steps
${monster.catches.length > 0 ? `
Caught by:
` : ''}
`; }).join(''); } // Monster CSV Import async function importMonsters() { const fileInput = document.getElementById('monster-csv-file'); const overwriteCheckbox = document.getElementById('monster-overwrite'); const messageDiv = document.getElementById('monster-import-message'); if (!fileInput.files || !fileInput.files[0]) { showMessage(messageDiv, 'Please select a CSV file', 'error'); return; } const overwrite = overwriteCheckbox.checked; console.log('Overwrite checkbox checked:', overwrite); // Confirm if overwrite is selected if (overwrite) { const confirmed = confirm( 'WARNING: This will DELETE all existing monsters and monster catches!\n\n' + 'Are you sure you want to proceed?' ); if (!confirmed) { return; } } const file = fileInput.files[0]; const reader = new FileReader(); reader.onload = async (e) => { try { const csvText = e.target.result; const lines = csvText.split('\n').filter(line => line.trim()); // Skip header row const dataLines = lines.slice(1); const data = dataLines.map(line => { const [date, monster_name, monster_description, step_goal, monster_icon] = line.split(',').map(s => s.trim()); return { date, monster_name, monster_description, step_goal, monster_icon }; }); const result = await apiCall('/api/monsters/import', 'POST', { data, overwrite }); let successMessage; if (overwrite) { successMessage = `Success! Deleted ${result.monstersDeleted} old monsters, created ${result.monstersCreated} new monsters`; } else { successMessage = `Success! Created ${result.monstersCreated} monsters, updated ${result.monstersUpdated}`; } showMessage(messageDiv, successMessage, 'success'); // Reload monsters await loadDailyMonsters(); fileInput.value = ''; overwriteCheckbox.checked = false; } catch (error) { showMessage(messageDiv, `Error: ${error.message}`, 'error'); } }; reader.readAsText(file); } // Utility functions function showMessage(element, message, type) { element.textContent = message; element.className = `message ${type}`; element.style.display = 'block'; setTimeout(() => { element.style.display = 'none'; }, 5000); } function formatDate(dateStr) { const date = new Date(dateStr + 'T00:00:00'); return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); } // Tip rotation functionality function shuffleTips(array) { // Fisher-Yates shuffle algorithm for true randomization const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } function initializeTipRotation() { // Clear any existing interval to prevent duplicates if (tipRotationInterval) { clearInterval(tipRotationInterval); } // Shuffle the tips array for randomized order window.shuffledTips = shuffleTips(STEP_TIPS); currentTipIndex = 0; // Display first tip displayTip(); // Rotate every 20 seconds tipRotationInterval = setInterval(rotateTip, 20000); } function displayTip() { const tipTextElement = document.getElementById('tip-text'); if (!tipTextElement || !window.shuffledTips) return; tipTextElement.textContent = window.shuffledTips[currentTipIndex]; } function rotateTip() { const tipTextElement = document.getElementById('tip-text'); if (!tipTextElement || !window.shuffledTips) return; // Fade out tipTextElement.classList.add('fade-out'); // After fade out, change text and fade in setTimeout(() => { currentTipIndex = (currentTipIndex + 1) % window.shuffledTips.length; // If we've completed a full cycle, re-shuffle for next cycle if (currentTipIndex === 0) { window.shuffledTips = shuffleTips(STEP_TIPS); } tipTextElement.textContent = window.shuffledTips[currentTipIndex]; tipTextElement.classList.remove('fade-out'); }, 500); }