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'
|
||||
});
|
||||
}
|
||||
31
public/favicon.svg
Normal file
31
public/favicon.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Pumpkin body -->
|
||||
<ellipse cx="50" cy="55" rx="35" ry="30" fill="#ff7700"/>
|
||||
|
||||
<!-- Pumpkin ridges -->
|
||||
<path d="M 50 25 Q 45 40, 50 55 Q 55 40, 50 25" fill="#e66a00" opacity="0.3"/>
|
||||
<path d="M 35 30 Q 30 42, 30 55" stroke="#e66a00" stroke-width="2" fill="none" opacity="0.5"/>
|
||||
<path d="M 65 30 Q 70 42, 70 55" stroke="#e66a00" stroke-width="2" fill="none" opacity="0.5"/>
|
||||
|
||||
<!-- Stem -->
|
||||
<rect x="47" y="20" width="6" height="8" fill="#8b4513" rx="2"/>
|
||||
|
||||
<!-- Left eye -->
|
||||
<polygon points="35,45 40,45 42,50 38,55 33,50" fill="#000"/>
|
||||
|
||||
<!-- Right eye -->
|
||||
<polygon points="60,45 65,45 67,50 63,55 58,50" fill="#000"/>
|
||||
|
||||
<!-- Nose -->
|
||||
<polygon points="50,52 52,57 48,57" fill="#000"/>
|
||||
|
||||
<!-- Mouth - spooky grin -->
|
||||
<path d="M 32 65 Q 35 70, 40 68 Q 45 66, 50 70 Q 55 66, 60 68 Q 65 70, 68 65"
|
||||
stroke="#000" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Teeth -->
|
||||
<line x1="37" y1="65" x2="37" y2="68" stroke="#000" stroke-width="2"/>
|
||||
<line x1="45" y1="67" x2="45" y2="70" stroke="#000" stroke-width="2"/>
|
||||
<line x1="55" y1="67" x2="55" y2="70" stroke="#000" stroke-width="2"/>
|
||||
<line x1="63" y1="65" x2="63" y2="68" stroke="#000" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
212
public/index.html
Normal file
212
public/index.html
Normal file
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Monster Dash Step Competition</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<img src="monster-dash-logo.png" alt="Monster Dash Steps Contest" class="header-logo">
|
||||
<nav>
|
||||
<button class="tab-btn active" data-tab="log">Log Your Steps</button>
|
||||
<button class="tab-btn" data-tab="monsters">Daily Monsters</button>
|
||||
<button class="tab-btn" data-tab="teams">Team Standings</button>
|
||||
<button class="tab-btn" data-tab="stats">Competition Stats</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Log Your Steps Tab -->
|
||||
<div id="log-tab" class="tab-content active">
|
||||
<div class="two-column-layout">
|
||||
<!-- Left Column: Step Entry -->
|
||||
<div class="entry-column">
|
||||
<h2>Log Your Steps</h2>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="team-select">1. Select Your Team:</label>
|
||||
<select id="team-select" required>
|
||||
<option value="">Choose your team...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name-select">2. Select Your Name:</label>
|
||||
<select id="name-select" required disabled>
|
||||
<option value="">First select a team...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="step-date">3. Date:</label>
|
||||
<input type="date" id="step-date" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="step-count">4. Steps:</label>
|
||||
<input type="number" id="step-count" min="0" placeholder="Enter step count" required>
|
||||
</div>
|
||||
|
||||
<button id="submit-steps" class="btn-primary">Submit Steps</button>
|
||||
<div id="entry-message" class="message"></div>
|
||||
</div>
|
||||
|
||||
<!-- CSV Import Section -->
|
||||
<div class="import-section" style="display: none;">
|
||||
<h3>Admin: Bulk Import</h3>
|
||||
<p class="help-text">Upload CSV with columns: team_name, participant_name, email</p>
|
||||
<input type="file" id="csv-file" accept=".csv">
|
||||
<button id="import-csv" class="btn-secondary">Import CSV</button>
|
||||
<div id="import-message" class="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Your History -->
|
||||
<div class="history-column">
|
||||
<h2>Your Step History</h2>
|
||||
<div id="personal-stats" class="stats-box">
|
||||
<p class="help-text">Select your name to see your history</p>
|
||||
</div>
|
||||
<div id="personal-history"></div>
|
||||
|
||||
<h2 style="margin-top: 30px;">Your Team</h2>
|
||||
<div id="team-stats" class="stats-box">
|
||||
<p class="help-text">Select your team to see team stats</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Monsters Tab -->
|
||||
<div id="monsters-tab" class="tab-content">
|
||||
<h2>Daily Monster Challenge</h2>
|
||||
|
||||
<!-- How It Works Info Box -->
|
||||
<div style="background: rgba(138, 43, 226, 0.15); border: 2px solid rgba(138, 43, 226, 0.4); border-radius: 12px; padding: 20px; margin-bottom: 25px;">
|
||||
<h3 style="margin-top: 0; color: #bb86fc; font-size: 18px;">📖 How It Works</h3>
|
||||
<p style="margin: 8px 0; line-height: 1.6;">
|
||||
Each day, a new monster appears with a step goal. Your team must reach the goal to catch it!
|
||||
</p>
|
||||
<p style="margin: 8px 0; line-height: 1.6; color: #ff7700;">
|
||||
<strong>⏰ Important:</strong> The <strong>"Active Challenge"</strong> shows yesterday's monster.
|
||||
Log your steps from yesterday to see if your team caught it!
|
||||
</p>
|
||||
<p style="margin: 8px 0; line-height: 1.6; font-size: 14px; color: #aaa;">
|
||||
💡 You can enter steps for any past date, and catches will be updated automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Monster CSV Import Section -->
|
||||
<div class="import-section">
|
||||
<h3>Admin: Import Monsters</h3>
|
||||
<p class="help-text">Upload CSV with columns: date, monster_name, monster_description, step_goal, monster_icon</p>
|
||||
<input type="file" id="monster-csv-file" accept=".csv">
|
||||
<button id="import-monsters" class="btn-secondary">Import Monsters</button>
|
||||
<div id="monster-import-message" class="message"></div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Monster Section -->
|
||||
<div id="todays-monster-section" class="monster-today">
|
||||
<h3>Active Challenge</h3>
|
||||
<p style="color: #999; font-size: 14px; margin: -5px 0 15px 0;">Enter your steps from yesterday to catch this monster!</p>
|
||||
<div id="todays-monster-card" class="monster-card-today">
|
||||
<p class="help-text">Loading active challenge...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monster Timeline -->
|
||||
<div id="monster-timeline-section">
|
||||
<h3>Monster Timeline</h3>
|
||||
<div id="monster-timeline" class="monster-timeline">
|
||||
<p class="help-text">Loading monsters...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Standings Tab -->
|
||||
<div id="teams-tab" class="tab-content">
|
||||
<h2>Team Standings</h2>
|
||||
|
||||
<div class="leaderboard-section">
|
||||
<h3>Team Leaderboard</h3>
|
||||
<table id="team-leaderboard" class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Team Name</th>
|
||||
<th>Total Steps</th>
|
||||
<th>Active</th>
|
||||
<th>Avg Steps/Person</th>
|
||||
<th>Monsters Caught</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="leaderboard-section">
|
||||
<h3>Individual Leaderboard</h3>
|
||||
<table id="individual-leaderboard" class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Name</th>
|
||||
<th>Team</th>
|
||||
<th>Total Steps</th>
|
||||
<th>Days Logged</th>
|
||||
<th>Avg Steps/Day</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Competition Stats Tab -->
|
||||
<div id="stats-tab" class="tab-content">
|
||||
<h2>Competition Statistics</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Total Steps</h3>
|
||||
<div id="total-steps" class="stat-number">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Total Participants</h3>
|
||||
<div id="total-participants" class="stat-number">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Average Daily Steps</h3>
|
||||
<div id="avg-daily-steps" class="stat-number">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Days Active</h3>
|
||||
<div id="days-active" class="stat-number">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>Team Comparison</h3>
|
||||
<canvas id="team-comparison-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>Top 10 Individuals</h3>
|
||||
<canvas id="top-individuals-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>Team Progress Over Time</h3>
|
||||
<canvas id="team-progress-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/monster-dash-logo.jpg
Normal file
BIN
public/monster-dash-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 841 KiB |
BIN
public/monster-dash-logo.png
Normal file
BIN
public/monster-dash-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
749
public/styles.css
Normal file
749
public/styles.css
Normal file
@@ -0,0 +1,749 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d1b4e 50%, #1a1a1a 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: #1a1a1a;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(255, 119, 0, 0.3), 0 0 100px rgba(138, 43, 226, 0.2);
|
||||
overflow: hidden;
|
||||
border: 2px solid #ff7700;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(255, 119, 0, 0.4);
|
||||
border-bottom: 3px solid #ff7700;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 700;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 12px 30px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #ff7700;
|
||||
color: white;
|
||||
border-color: #ff7700;
|
||||
box-shadow: 0 0 20px rgba(255, 119, 0, 0.6);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 40px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab-content h2 {
|
||||
color: #ff7700;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.tab-content h3 {
|
||||
color: #8a2be2;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Two Column Layout */
|
||||
.two-column-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.entry-column, .history-column {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-section {
|
||||
background: #2a2a2a;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #ff7700;
|
||||
box-shadow: 0 4px 15px rgba(255, 119, 0, 0.2);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #ff7700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #8a2be2;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border 0.3s;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #ff7700;
|
||||
box-shadow: 0 0 10px rgba(255, 119, 0, 0.5);
|
||||
}
|
||||
|
||||
.form-group select:disabled {
|
||||
background: #333;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #ff7700 0%, #8a2be2 100%);
|
||||
box-shadow: 0 4px 15px rgba(255, 119, 0, 0.6);
|
||||
border: 2px solid #ff7700;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 119, 0, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #8a2be2 0%, #ff7700 100%);
|
||||
box-shadow: 0 4px 15px rgba(138, 43, 226, 0.6);
|
||||
border: 2px solid #8a2be2;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(138, 43, 226, 0.8);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Import Section */
|
||||
.import-section {
|
||||
background: #fff3cd;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
border: 2px dashed #ffc107;
|
||||
}
|
||||
|
||||
.import-section h3 {
|
||||
color: #856404;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.import-section input[type="file"] {
|
||||
display: block;
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
border: 2px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Personal Stats Box */
|
||||
.stats-box {
|
||||
background: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #8a2be2;
|
||||
box-shadow: 0 4px 15px rgba(138, 43, 226, 0.2);
|
||||
}
|
||||
|
||||
.stats-box .stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.stats-box .stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-box .stat-label {
|
||||
font-weight: 600;
|
||||
color: #8a2be2;
|
||||
}
|
||||
|
||||
.stats-box .stat-value {
|
||||
font-weight: 700;
|
||||
color: #ff7700;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* History List */
|
||||
#personal-history {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid #ff7700;
|
||||
box-shadow: 0 2px 8px rgba(255, 119, 0, 0.2);
|
||||
}
|
||||
|
||||
.history-item .date {
|
||||
font-weight: 600;
|
||||
color: #8a2be2;
|
||||
}
|
||||
|
||||
.history-item .steps {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #ff7700;
|
||||
}
|
||||
|
||||
/* Leaderboard */
|
||||
.leaderboard-section {
|
||||
margin-bottom: 40px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #2a2a2a;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(255, 119, 0, 0.3);
|
||||
border: 2px solid #8a2be2;
|
||||
}
|
||||
|
||||
.leaderboard th {
|
||||
background: linear-gradient(135deg, #ff7700 0%, #8a2be2 100%);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leaderboard td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(1) {
|
||||
background: linear-gradient(90deg, #ffd70020 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(1) td:first-child {
|
||||
color: #ffd700;
|
||||
font-weight: bold;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(2) {
|
||||
background: linear-gradient(90deg, #c0c0c020 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(2) td:first-child {
|
||||
color: #c0c0c0;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(3) {
|
||||
background: linear-gradient(90deg, #cd7f3220 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(3) td:first-child {
|
||||
color: #cd7f32;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.number-large {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #ff7700;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #ff7700 0%, #8a2be2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(255, 119, 0, 0.6);
|
||||
border: 2px solid #ff7700;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.chart-container {
|
||||
background: #2a2a2a;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #8a2be2;
|
||||
box-shadow: 0 4px 15px rgba(138, 43, 226, 0.3);
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 400px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Monster Cards */
|
||||
.monster-today {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.monster-card-today {
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #3a1a4a 100%);
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
border: 3px solid #ff7700;
|
||||
box-shadow: 0 8px 30px rgba(255, 119, 0, 0.4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.monster-icon-large {
|
||||
font-size: 8rem;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 20px rgba(255, 119, 0, 0.6));
|
||||
}
|
||||
|
||||
.monster-name {
|
||||
color: #ff7700;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 15px;
|
||||
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.monster-description {
|
||||
color: #bbb;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.monster-goal {
|
||||
background: rgba(138, 43, 226, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #8a2be2;
|
||||
}
|
||||
|
||||
.goal-label {
|
||||
color: #8a2be2;
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.goal-value {
|
||||
color: #ff7700;
|
||||
font-weight: 700;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.caught-count {
|
||||
color: #ff7700;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.team-progress-list {
|
||||
text-align: left;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.team-progress-list h4 {
|
||||
color: #8a2be2;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.team-progress-item {
|
||||
background: rgba(42, 42, 42, 0.8);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #444;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.team-progress-item.caught {
|
||||
border-color: #ff7700;
|
||||
background: rgba(255, 119, 0, 0.1);
|
||||
}
|
||||
|
||||
.team-progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.team-steps {
|
||||
color: #ff7700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #1a1a1a;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, #ff7700 0%, #8a2be2 100%);
|
||||
height: 100%;
|
||||
transition: width 0.5s ease;
|
||||
box-shadow: 0 0 10px rgba(255, 119, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Monster Timeline */
|
||||
.monster-timeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.monster-card {
|
||||
background: #2a2a2a;
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
border: 2px solid #444;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.monster-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.monster-card.today {
|
||||
border-color: #ff7700;
|
||||
box-shadow: 0 0 20px rgba(255, 119, 0, 0.4);
|
||||
}
|
||||
|
||||
.monster-card.past {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.monster-card.future {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.monster-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.monster-icon {
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.monster-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.monster-card-name {
|
||||
color: #ff7700;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.monster-date {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monster-status {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.monster-status.today {
|
||||
background: #ff7700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.monster-status.past {
|
||||
background: #444;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.monster-status.future {
|
||||
background: #8a2be2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.monster-card-description {
|
||||
color: #bbb;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.monster-card-goal {
|
||||
color: #ff7700;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.monster-catches {
|
||||
background: rgba(255, 119, 0, 0.1);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ff7700;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.monster-catches strong {
|
||||
color: #ff7700;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.monster-catches ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.monster-catches li {
|
||||
color: #fff;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.monster-catches li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Caught Monsters List (Team Stats) */
|
||||
.caught-monsters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.monster-badge {
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
filter: drop-shadow(0 2px 4px rgba(255, 119, 0, 0.6));
|
||||
}
|
||||
|
||||
.monster-badge:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.two-column-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.monster-timeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-logo {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
font-size: 0.9rem;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.leaderboard th,
|
||||
.leaderboard td {
|
||||
padding: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user