Initial commit of step-competition project

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-20 15:38:42 +02:00
commit 05e4a505b3
18 changed files with 5373 additions and 0 deletions

888
public/app.js Normal file
View File

@@ -0,0 +1,888 @@
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 = '<option value="">Choose your team...</option>';
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 = '<option value="">First select a team...</option>';
document.getElementById('team-stats').innerHTML = '<p class="help-text">Select your team to see team stats</p>';
return;
}
// Filter participants by team
const teamParticipants = participants.filter(p => p.team_id === teamId);
nameSelect.disabled = false;
nameSelect.innerHTML = '<option value="">Choose your name...</option>';
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 = '<p class="help-text">Select your name to see your history</p>';
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 = `
<div class="stat-item">
<span class="stat-label">Total Steps:</span>
<span class="stat-value">${personData.total_steps.toLocaleString()}</span>
</div>
<div class="stat-item">
<span class="stat-label">Days Logged:</span>
<span class="stat-value">${personData.days_logged}</span>
</div>
<div class="stat-item">
<span class="stat-label">Avg Steps/Day:</span>
<span class="stat-value">${avgSteps.toLocaleString()}</span>
</div>
`;
}
// Display history
const historyDiv = document.getElementById('personal-history');
if (history.length === 0) {
historyDiv.innerHTML = '<p class="help-text">No entries yet. Start logging your steps!</p>';
} else {
historyDiv.innerHTML = history.map(entry => `
<div class="history-item">
<span class="date">${formatDate(entry.date)}</span>
<span class="steps">${entry.steps.toLocaleString()} steps</span>
</div>
`).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 = `
<div class="stat-item">
<span class="stat-label">Team Total:</span>
<span class="stat-value">${team.total_steps.toLocaleString()}</span>
</div>
${caughtMonsters.length > 0 ? `
<div class="stat-item">
<span class="stat-label">Monsters Caught:</span>
<span class="stat-value">${caughtMonsters.length} 🏆</span>
</div>
<div class="caught-monsters-list">
${caughtMonsters.map(m => `
<span class="monster-badge" title="${m.monster_name} - ${formatDate(m.date)}">${m.monster_icon}</span>
`).join('')}
</div>
` : ''}
<h4 style="margin: 15px 0 10px 0; color: #8a2be2;">Team Members:</h4>
${teamMembers.map((member, index) => `
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #444; color: #fff;">
<span style="color: ${member.total_steps > 0 ? '#fff' : '#999'};">${index + 1}. ${member.name}</span>
<span style="font-weight: 600; color: ${member.total_steps > 0 ? '#ff7700' : '#999'};">${member.total_steps.toLocaleString()}</span>
</div>
`).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 => `<span title="${m.monster_name} - ${m.date}">${m.monster_icon}</span>`).join(' ')
: '-';
row.innerHTML = `
<td>${index + 1}</td>
<td>${team.team_name}</td>
<td class="number-large">${team.total_steps.toLocaleString()}</td>
<td>${team.active_count}</td>
<td>${avgPerPerson.toLocaleString()}</td>
<td style="font-size: 20px; text-align: center;">${monsterBadges}</td>
`;
});
// 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 = `
<td>${index + 1}</td>
<td>${person.name}</td>
<td>${person.team_name}</td>
<td class="number-large">${person.total_steps.toLocaleString()}</td>
<td>${person.days_logged}</td>
<td>${avgSteps.toLocaleString()}</td>
`;
});
} 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 = '<p class="help-text">No monster challenge available. Check back tomorrow!</p>';
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 = `
<div style="background: rgba(138, 43, 226, 0.1); border: 1px solid rgba(138, 43, 226, 0.3); border-radius: 8px; padding: 12px; margin-bottom: 15px;">
<p style="margin: 0; color: #bb86fc; font-size: 14px;">
<strong>📅 Active Challenge:</strong> Log your steps from <strong>${formattedDate}</strong> (yesterday)
</p>
</div>
<div class="monster-icon-large">${monster.monster_icon}</div>
<h2 class="monster-name">${monster.monster_name}</h2>
<p class="monster-description">${monster.monster_description || ''}</p>
<div class="monster-goal">
<span class="goal-label">Goal:</span>
<span class="goal-value">${monster.step_goal.toLocaleString()} steps</span>
</div>
<div class="caught-count">${caughtCount} of ${progress.length} teams caught this monster!</div>
<div class="team-progress-list">
<h4>Team Progress:</h4>
${progress.map(team => {
const percentage = Math.min((team.total_steps / monster.step_goal) * 100, 100);
const caught = team.caught;
return `
<div class="team-progress-item ${caught ? 'caught' : ''}">
<div class="team-progress-header">
<span class="team-name">${team.team_name}</span>
<span class="team-steps">${team.total_steps.toLocaleString()} steps ${caught ? '🏆' : ''}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${percentage}%"></div>
</div>
</div>
`;
}).join('')}
</div>
`;
}
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 = '<p class="help-text">No monsters have been configured yet.</p>';
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 `
<div class="monster-card ${statusClass}">
<div class="monster-card-header">
<div class="monster-icon">${monster.monster_icon}</div>
<div class="monster-info">
<h4 class="monster-card-name">${monster.monster_name}</h4>
<div class="monster-date">${formatDate(monster.date)}</div>
</div>
<div class="monster-status ${statusClass}">${statusText}</div>
</div>
<p class="monster-card-description">${monster.monster_description || ''}</p>
<div class="monster-card-goal">
<strong>Goal:</strong> ${monster.step_goal.toLocaleString()} steps
</div>
${monster.catches.length > 0 ? `
<div class="monster-catches">
<strong>Caught by:</strong>
<ul>
${monster.catches.map(c => `
<li>${c.team_name} - ${c.final_steps.toLocaleString()} steps</li>
`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}).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'
});
}