1007 lines
32 KiB
JavaScript
1007 lines
32 KiB
JavaScript
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 = '<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 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);
|
|
}
|