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:
888
public/app.js
Normal file
888
public/app.js
Normal 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'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user