Files
step-competition/public/app.js
2025-11-04 02:40:32 +01:00

1006 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 = '2025-10-31';
if (date > contestEndDate) {
showMessage(messageDiv, 'Contest ended on October 31, 2025. Cannot enter steps after this date.', 'error');
return;
}
try {
await apiCall('/api/steps', 'POST', {
participant_id: parseInt(participantId),
date,
steps
});
document.getElementById('step-count').value = '';
showMessage(messageDiv, 'Steps submitted successfully!', 'success');
// Reload personal history and team stats
if (selectedParticipant) {
await loadPersonalHistory(selectedParticipant.id);
const teamId = document.getElementById('team-select').value;
if (teamId) {
await loadTeamStats(parseInt(teamId));
}
}
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
}
// CSV Import
async function importCSV() {
const fileInput = document.getElementById('csv-file');
const messageDiv = document.getElementById('import-message');
if (!fileInput.files || !fileInput.files[0]) {
showMessage(messageDiv, 'Please select a CSV file', 'error');
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const csvText = e.target.result;
const lines = csvText.split('\n').filter(line => line.trim());
// Skip header row
const dataLines = lines.slice(1);
const data = dataLines.map(line => {
const [team_name, participant_name, email] = line.split(',').map(s => s.trim());
return { team_name, participant_name, email };
});
const result = await apiCall('/api/import/csv', 'POST', { data });
showMessage(
messageDiv,
`Success! Created ${result.teamsCreated} teams and ${result.participantsCreated} participants`,
'success'
);
// Reload data
await loadTeams();
await loadParticipants();
fileInput.value = '';
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
};
reader.readAsText(file);
}
// Team Standings Tab
async function loadTeamStandings() {
try {
const [teamData, individualData, timeline] = await Promise.all([
apiCall('/api/leaderboard/team'),
apiCall('/api/leaderboard/individual'),
apiCall('/api/monsters/timeline')
]);
// Team Leaderboard
const teamTbody = document.querySelector('#team-leaderboard tbody');
teamTbody.innerHTML = '';
teamData.forEach((team, index) => {
const row = teamTbody.insertRow();
const avgPerPerson = team.member_count > 0 ? Math.round(team.total_steps / team.member_count) : 0;
// Find monsters caught by this team
const caughtMonsters = timeline.filter(monster =>
monster.catches.some(c => c.team_id === team.id)
);
const monsterBadges = caughtMonsters.length > 0
? caughtMonsters.map(m => `<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);
}