const API_URL = window.location.origin; // State let teams = []; let participants = []; let charts = {}; let selectedParticipant = null; // Initialize app document.addEventListener('DOMContentLoaded', () => { initializeTabs(); loadInitialData(); setupEventListeners(); setDefaultDate(); handleRouting(); }); // 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 today function setDefaultDate() { const today = new Date().toISOString().split('T')[0]; const dateInput = document.getElementById('step-date'); dateInput.value = today; // Set max date to October 31, 2025 dateInput.max = '2025-10-31'; dateInput.min = '2025-10-15'; } // 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 = new Date('2025-10-31'); const selectedDate = new Date(date + 'T00:00:00'); if (selectedDate > 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 messageDiv = document.getElementById('monster-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 [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 }); showMessage( messageDiv, `Success! Created ${result.monstersCreated} monsters, updated ${result.monstersUpdated}`, 'success' ); // Reload monsters await loadDailyMonsters(); fileInput.value = ''; } 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' }); }