Initial commit of step-competition project

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

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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
step_competition.db
*.db
.DS_Store
*.log
npm-debug.log*

132
QUICKSTART.md Normal file
View File

@@ -0,0 +1,132 @@
# Quick Start Guide - Step Competition
## Get Running in 2 Steps
### 1. Start the Server
```bash
cd /home/sascha/gitea/step-competition
npm start
```
The server will start on **http://localhost:3060**
### 2. Import Your 48 Participants
**Prepare Your CSV File:**
Create a CSV with 3 columns: `team_name`, `participant_name`, `email`
Example (see `sample_import.csv` for 48-person template):
```csv
team_name,participant_name,email
Team Alpha,Alice Johnson,alice@example.com
Team Alpha,Bob Smith,bob@example.com
Team Beta,Carol Williams,carol@example.com
```
**Import Steps:**
1. Open http://localhost:3060
2. On the "Log Your Steps" tab, scroll to "Admin: Bulk Import"
3. Select your CSV file
4. Click "Import CSV"
5. Done! All teams and participants are ready
## Using the App
### 3 Simple Tabs
**1. Log Your Steps**
- Select your team from dropdown
- Select your name from filtered list (only shows your team)
- See your personal stats and history on the right
- Enter date and steps, click Submit
**2. Team Standings**
- See team rankings (total steps, avg per person)
- See individual rankings (all 48 people)
- Gold/silver/bronze highlighting for top 3
**3. Competition Stats**
- Overall statistics cards
- Team progress chart (cumulative over time)
- Top 10 individuals bar chart
- Team comparison pie chart
## Accessing via Tailscale
Find your Tailscale IP:
```bash
tailscale ip -4
```
Share this URL with all participants:
```
http://YOUR_TAILSCALE_IP:3060
```
For example: `http://100.64.1.2:3060`
Everyone can access it from their phone or computer!
## Accessing via Local Network
Find your local IP:
```bash
hostname -I | awk '{print $1}'
```
Share this URL:
```
http://YOUR_LOCAL_IP:3060
```
For example: `http://192.168.1.100:3060`
## Tips for Your 48-Person Competition
1. **Pre-Import Everyone**: Use the CSV import before launch day
2. **Test First**: Try the sample_import.csv to see how it works
3. **Mobile-Friendly**: Participants can log from their phones
4. **Easy Name Finding**: Team dropdown first, then name dropdown shows only that team's 6 people
5. **Personal History**: Each person sees their entries immediately after selecting their name
6. **Daily Reminders**: Send daily reminders to log steps
7. **Display Leaderboard**: Show the "Team Standings" tab on a monitor or TV
## Sample Data
Want to test with fake data first?
```bash
node sample-data.js
```
This creates 3 teams with 9 people and 7 days of step data.
## Troubleshooting
**CSV won't import:**
- Make sure first line has: `team_name,participant_name,email`
- No spaces in the header row
- Save as CSV, not Excel format
**Can't find my name:**
- Did you select your team first?
- Names only show after team is selected
- Check spelling matches the import
**Port 3060 in use:**
- Edit `src/server.js` line 8 to change port
- Or: `PORT=3070 npm start`
## Resetting Everything
```bash
rm step_competition.db
npm start
```
This deletes all data and creates a fresh database.
## Need More Help?
See the full [README.md](README.md) for complete documentation, API endpoints, and advanced features.

212
README.md Normal file
View File

@@ -0,0 +1,212 @@
# Step Competition App
A modern, streamlined web application for hosting workplace step competitions with 48+ participants across multiple teams.
## Features
**3 Simple Tabs:**
1. **Log Your Steps** - Easy 2-step selection (team → name) + instant personal history view
2. **Team Standings** - Combined team and individual leaderboards
3. **Competition Stats** - Overall metrics and visual progress charts
**Key Capabilities:**
- CSV bulk import for quick setup (8 teams × 6 people = 48 participants)
- Team-first navigation makes finding your name fast
- Personal step history shown immediately after selecting your name
- Real-time leaderboards with gold/silver/bronze highlighting
- Visual charts showing team progress over time
- Mobile-friendly responsive design
- SQLite backend - no complex database setup
## Quick Start
### 1. Install and Start
```bash
cd /home/sascha/gitea/step-competition
npm install
npm start
```
### 2. Setup Teams & Participants
**Option A: CSV Import (Recommended for 48 people)**
1. Create a CSV file with 3 columns: `team_name`, `participant_name`, `email`
2. See `sample_import.csv` for reference format
3. Open the app and go to "Log Your Steps" tab
4. Scroll down to "Admin: Bulk Import" section
5. Select your CSV file and click "Import CSV"
Example CSV format:
```csv
team_name,participant_name,email
Team Alpha,Alice Johnson,alice@example.com
Team Alpha,Bob Smith,bob@example.com
Team Beta,Carol Williams,carol@example.com
```
**Option B: Use Sample Data**
```bash
node sample-data.js
```
### 3. Start Logging Steps
**For Participants:**
1. Open http://localhost:3060 (or your Tailscale URL)
2. Select your team from dropdown
3. Select your name from dropdown
4. See your personal stats and history on the right
5. Enter today's date and step count
6. Click "Submit Steps"
**For Viewing Results:**
- **Team Standings** tab: See which teams and individuals are winning
- **Competition Stats** tab: View overall progress and charts
## Accessing Remotely
The server listens on port 3060 and is accessible via:
**Via Tailscale:**
```bash
# Find your Tailscale IP
tailscale ip -4
# Share with participants
http://YOUR_TAILSCALE_IP:3060
```
**Via Local Network:**
```bash
# Find your local IP
hostname -I | awk '{print $1}'
# Share with participants
http://YOUR_LOCAL_IP:3060
```
## CSV Import Format
Your CSV must have these exact column headers:
- `team_name` - Name of the team
- `participant_name` - Person's full name
- `email` - Email address (optional, can be empty)
The import will:
- Create teams automatically if they don't exist
- Add participants to their teams
- Skip duplicates (same name + team)
- Report how many teams and participants were created
## Project Structure
```
step-competition/
├── src/
│ ├── server.js # Express server and API endpoints
│ └── database.js # SQLite database initialization
├── public/
│ ├── index.html # 3-tab interface
│ ├── styles.css # Responsive styling
│ └── app.js # Frontend JavaScript
├── sample_import.csv # Template for bulk import (8 teams, 48 people)
├── step_competition.db # SQLite database (auto-created)
└── README.md
```
## API Endpoints
### Teams & Participants
- `GET /api/teams` - Get all teams
- `POST /api/teams` - Create a team
- `GET /api/participants` - Get all participants
- `POST /api/participants` - Create a participant
- `POST /api/import/csv` - Bulk import from CSV
- `GET /api/participants/:id/history` - Get participant's step history
### Steps
- `POST /api/steps` - Submit or update steps
- `GET /api/steps/:participantId/:date` - Get steps for specific date
### Leaderboards
- `GET /api/leaderboard/individual` - Individual rankings
- `GET /api/leaderboard/team` - Team rankings
- `GET /api/progress/daily` - Daily progress data for charts
## Configuration
**Change Port:**
Edit `src/server.js` line 8:
```javascript
const PORT = process.env.PORT || 3060;
```
Or use environment variable:
```bash
PORT=8080 npm start
```
## Database Schema
### teams
- `id`, `name`, `created_at`
### participants
- `id`, `name`, `email`, `team_id`, `created_at`
### daily_steps
- `id`, `participant_id`, `date`, `steps`, `created_at`, `updated_at`
- Unique constraint on (participant_id, date)
## Tips for Running a Competition
1. **Pre-populate**: Use CSV import to set up all 48 participants before launch
2. **Test First**: Import sample_import.csv and test with a few entries
3. **Share URL**: Send the Tailscale or local network URL to all participants
4. **Daily Reminders**: Encourage participants to log steps daily
5. **Showcase Leaderboards**: Display the Team Standings tab on a monitor
6. **Backup Data**: Periodically copy `step_competition.db` file
## Troubleshooting
**CSV import fails:**
- Check that your CSV has the exact column headers: `team_name,participant_name,email`
- Make sure there are no extra spaces or special characters
- Verify the file is saved as CSV format, not Excel
**Can't find my name:**
- Make sure you select the correct team first
- Names are listed alphabetically within each team
- Check that your name was imported correctly
**Steps not saving:**
- Verify you selected both team and name
- Check that you entered a valid number for steps
- Make sure the date is in the correct format
**Port 3060 already in use:**
- Change the PORT in src/server.js to another number (e.g., 3070)
- Or stop the other service using that port
## Resetting the Competition
To start fresh:
```bash
rm step_competition.db
npm start
```
A new empty database will be created. You'll need to re-import your participants.
## Browser Compatibility
Works with all modern browsers:
- Chrome/Edge (recommended)
- Firefox
- Safari
- Mobile browsers (iOS/Android)
## License
ISC

View File

@@ -0,0 +1,49 @@
team_name,participant_name,email
Van Helsing,Alexandra Perex,aperez@adaptivehealth.com
Van Helsing,Katrina Greene,kgreene@adaptivehealth.com
Van Helsing,Brittany Washington,bwashington@adaptivehealth.com
Van Helsing,Taylor Seay,taylor.seay@adaptivehealth.com
Van Helsing,Nicole Kane,nkane@adaptivehealth.com
Van Helsing,Mary Todd,mtodd@adaptivehealth.com
Mulder and Scully,Mikala Hukka,mhukka@adaptivehealth.com
Mulder and Scully,Tina Jackse,tjackse@wellful.com
Mulder and Scully,Alice Staehle,astaehle@adaptivehealth.com
Mulder and Scully,Amina Sarraf,asarraf@healthydirections.com
Mulder and Scully,Paula Harrell,pharrell@adaptivehealth.com
Mulder and Scully,Dianne Lidiak,dlidiak@healthydirections.com
Mystery Inc,Joe Robba,jrobba@adaptivehealth.com
Mystery Inc,Jayanth Chintireddy,jchintireddy@adaptivehealth.com
Mystery Inc,Renee Clark,rclark@adaptivehealth.com
Mystery Inc,Joel Clark,Jclark@adaptivehealth.com
Mystery Inc,Nicole Simpson,nsimpson@adaptivehealth.com
Mystery Inc,Izabela Kondracka,ikondracka@adaptivehealth.com
Blade,Ray Urrutia,rurrutia@adaptivehealth.com
Blade,Colleen Manner,cmanner@adaptivehealth.com
Blade,Taylor Wall,twall@adaptivehealth.com
Blade,Aaron Gentry,agentry@wellful.com
Blade,Samantha Downs,sdowns@biovationlabs.com
Blade,Megan McFadden,mmcfadden@adaptivehealth.com
Buffy,Jeanene Medley,jmedley@adaptivehealth.com
Buffy,Trevera Brathwaite,TBrathwaite@adaptivehealth.com
Buffy,Amol Pachpande,apachpande@adaptivehealth.com
Buffy,Anne Marie Kiesling,amkiesling@adaptivehealth.com
Buffy,Tatiana Nunes,tatiana@adaptivehealth.com
Buffy,Kathy Cadeaux,KCADEAUX@adaptivehealth.com
Ghostbusters,Gabriel McCann,gmccann@adaptivehealth.com
Ghostbusters,Emily Stillion,estillion@adaptivehealth.com
Ghostbusters,Andy Giroux,agiroux@adaptivehealth.com
Ghostbusters,Erica Bennerman,erica.bennerman@adaptivehealth.com
Ghostbusters,Orietta Santa Cruz,osantacruz@adaptivehealth.com
Ghostbusters,Tisha Verma,tverma@adaptivehealth.com
Winchesters,Sascha Maraj,smaraj@adaptivehealth.com
Winchesters,Lauren Whitehead,lwhitehead@adaptivehealth.com
Winchesters,Jenna Washington,jwashington@adaptivehealth.com
Winchesters,Erin Fusco,efusco@adaptivehealth.com
Winchesters,Ashley Delaney,adelaney@adaptivehealth.com
Winchesters,Kelsey Ford,kford@adaptivehealth.com
Daryl Dixon,Dimpal Ahir,dahir@adaptivehealth.com
Daryl Dixon,Carrie Flynn,cflynn@adaptivehealth.com
Daryl Dixon,Jackie Margolis,Jmargolis@adaptivehealth.com
Daryl Dixon,Hayley Chittum,hchittum@adaptivehealth.com
Daryl Dixon,Emily Bay,emily@adaptivehealth.com
Daryl Dixon,Scott Lewis,SLewis@adaptivehealth.com
1 team_name participant_name email
2 Van Helsing Alexandra Perex aperez@adaptivehealth.com
3 Van Helsing Katrina Greene kgreene@adaptivehealth.com
4 Van Helsing Brittany Washington bwashington@adaptivehealth.com
5 Van Helsing Taylor Seay taylor.seay@adaptivehealth.com
6 Van Helsing Nicole Kane nkane@adaptivehealth.com
7 Van Helsing Mary Todd mtodd@adaptivehealth.com
8 Mulder and Scully Mikala Hukka mhukka@adaptivehealth.com
9 Mulder and Scully Tina Jackse tjackse@wellful.com
10 Mulder and Scully Alice Staehle astaehle@adaptivehealth.com
11 Mulder and Scully Amina Sarraf asarraf@healthydirections.com
12 Mulder and Scully Paula Harrell pharrell@adaptivehealth.com
13 Mulder and Scully Dianne Lidiak dlidiak@healthydirections.com
14 Mystery Inc Joe Robba jrobba@adaptivehealth.com
15 Mystery Inc Jayanth Chintireddy jchintireddy@adaptivehealth.com
16 Mystery Inc Renee Clark rclark@adaptivehealth.com
17 Mystery Inc Joel Clark Jclark@adaptivehealth.com
18 Mystery Inc Nicole Simpson nsimpson@adaptivehealth.com
19 Mystery Inc Izabela Kondracka ikondracka@adaptivehealth.com
20 Blade Ray Urrutia rurrutia@adaptivehealth.com
21 Blade Colleen Manner cmanner@adaptivehealth.com
22 Blade Taylor Wall twall@adaptivehealth.com
23 Blade Aaron Gentry agentry@wellful.com
24 Blade Samantha Downs sdowns@biovationlabs.com
25 Blade Megan McFadden mmcfadden@adaptivehealth.com
26 Buffy Jeanene Medley jmedley@adaptivehealth.com
27 Buffy Trevera Brathwaite TBrathwaite@adaptivehealth.com
28 Buffy Amol Pachpande apachpande@adaptivehealth.com
29 Buffy Anne Marie Kiesling amkiesling@adaptivehealth.com
30 Buffy Tatiana Nunes tatiana@adaptivehealth.com
31 Buffy Kathy Cadeaux KCADEAUX@adaptivehealth.com
32 Ghostbusters Gabriel McCann gmccann@adaptivehealth.com
33 Ghostbusters Emily Stillion estillion@adaptivehealth.com
34 Ghostbusters Andy Giroux agiroux@adaptivehealth.com
35 Ghostbusters Erica Bennerman erica.bennerman@adaptivehealth.com
36 Ghostbusters Orietta Santa Cruz osantacruz@adaptivehealth.com
37 Ghostbusters Tisha Verma tverma@adaptivehealth.com
38 Winchesters Sascha Maraj smaraj@adaptivehealth.com
39 Winchesters Lauren Whitehead lwhitehead@adaptivehealth.com
40 Winchesters Jenna Washington jwashington@adaptivehealth.com
41 Winchesters Erin Fusco efusco@adaptivehealth.com
42 Winchesters Ashley Delaney adelaney@adaptivehealth.com
43 Winchesters Kelsey Ford kford@adaptivehealth.com
44 Daryl Dixon Dimpal Ahir dahir@adaptivehealth.com
45 Daryl Dixon Carrie Flynn cflynn@adaptivehealth.com
46 Daryl Dixon Jackie Margolis Jmargolis@adaptivehealth.com
47 Daryl Dixon Hayley Chittum hchittum@adaptivehealth.com
48 Daryl Dixon Emily Bay emily@adaptivehealth.com
49 Daryl Dixon Scott Lewis SLewis@adaptivehealth.com

20
ecosystem.config.js Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
apps: [{
name: 'monster-dash',
script: './src/server.js',
cwd: '/home/sascha/gitea/step-competition',
exec_mode: 'fork',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3060
},
error_file: '/home/sascha/.pm2/logs/monster-dash-error.log',
out_file: '/home/sascha/.pm2/logs/monster-dash-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true
}]
};

7
monsters.csv Normal file
View File

@@ -0,0 +1,7 @@
date,monster_name,monster_description,step_goal,monster_icon
2025-10-17,Test Monster,Can you catch this spooky creature?,30000,👻
2025-10-20,Dracula,The Count demands your steps!,60000,🧛
2025-10-21,Freddy Krueger,Don't fall asleep on your steps!,48000,🔪
2025-10-22,Mummy,Unwrap your potential!,48000,🧟
2025-10-23,Aliens,Take steps to another world!,72000,👽
2025-10-24,Predator,Hunt down those steps!,66000,👹
1 date monster_name monster_description step_goal monster_icon
2 2025-10-17 Test Monster Can you catch this spooky creature? 30000 👻
3 2025-10-20 Dracula The Count demands your steps! 60000 🧛
4 2025-10-21 Freddy Krueger Don't fall asleep on your steps! 48000 🔪
5 2025-10-22 Mummy Unwrap your potential! 48000 🧟
6 2025-10-23 Aliens Take steps to another world! 72000 👽
7 2025-10-24 Predator Hunt down those steps! 66000 👹

2219
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "step-competition",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node src/server.js",
"dev": "node src/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"sqlite3": "^5.1.7"
}
}

888
public/app.js Normal file
View File

@@ -0,0 +1,888 @@
const API_URL = window.location.origin;
// State
let teams = [];
let participants = [];
let charts = {};
let selectedParticipant = null;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
initializeTabs();
loadInitialData();
setupEventListeners();
setDefaultDate();
handleRouting();
});
// Routing
function handleRouting() {
// Handle initial load and hash changes
window.addEventListener('hashchange', navigateToTab);
navigateToTab();
}
function navigateToTab() {
const hash = window.location.hash.slice(1) || 'log'; // Default to 'log' tab
switchTab(hash);
}
function switchTab(tabName) {
const tabButtons = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
// Remove active class from all
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to selected tab
const activeButton = document.querySelector(`[data-tab="${tabName}"]`);
const activeContent = document.getElementById(`${tabName}-tab`);
if (activeButton && activeContent) {
activeButton.classList.add('active');
activeContent.classList.add('active');
// Load data for specific tabs
if (tabName === 'teams') loadTeamStandings();
if (tabName === 'stats') loadCompetitionStats();
if (tabName === 'monsters') loadDailyMonsters();
}
}
// Tab management
function initializeTabs() {
const tabButtons = document.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const tabName = button.dataset.tab;
window.location.hash = tabName;
});
});
}
// Set default date to today
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
const dateInput = document.getElementById('step-date');
dateInput.value = today;
// Set max date to October 31, 2025
dateInput.max = '2025-10-31';
dateInput.min = '2025-10-15';
}
// Load initial data
async function loadInitialData() {
await loadTeams();
await loadParticipants();
}
// Event listeners
function setupEventListeners() {
document.getElementById('team-select').addEventListener('change', onTeamSelect);
document.getElementById('name-select').addEventListener('change', onNameSelect);
document.getElementById('submit-steps').addEventListener('click', submitSteps);
document.getElementById('import-csv').addEventListener('click', importCSV);
document.getElementById('import-monsters').addEventListener('click', importMonsters);
}
// API calls
async function apiCall(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_URL}${endpoint}`, options);
if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
return response.json();
}
// Teams
async function loadTeams() {
try {
teams = await apiCall('/api/teams');
updateTeamSelect();
} catch (error) {
console.error('Error loading teams:', error);
}
}
function updateTeamSelect() {
const select = document.getElementById('team-select');
select.innerHTML = '<option value="">Choose your team...</option>';
teams.forEach(team => {
const option = document.createElement('option');
option.value = team.id;
option.textContent = team.name;
select.appendChild(option);
});
}
// Participants
async function loadParticipants() {
try {
participants = await apiCall('/api/participants');
} catch (error) {
console.error('Error loading participants:', error);
}
}
async function onTeamSelect(e) {
const teamId = parseInt(e.target.value);
const nameSelect = document.getElementById('name-select');
if (!teamId) {
nameSelect.disabled = true;
nameSelect.innerHTML = '<option value="">First select a team...</option>';
document.getElementById('team-stats').innerHTML = '<p class="help-text">Select your team to see team stats</p>';
return;
}
// Filter participants by team
const teamParticipants = participants.filter(p => p.team_id === teamId);
nameSelect.disabled = false;
nameSelect.innerHTML = '<option value="">Choose your name...</option>';
teamParticipants.forEach(participant => {
const option = document.createElement('option');
option.value = participant.id;
option.textContent = participant.name;
nameSelect.appendChild(option);
});
// Load team stats
await loadTeamStats(teamId);
}
async function onNameSelect(e) {
const participantId = parseInt(e.target.value);
if (!participantId) {
selectedParticipant = null;
document.getElementById('personal-stats').innerHTML = '<p class="help-text">Select your name to see your history</p>';
document.getElementById('personal-history').innerHTML = '';
return;
}
selectedParticipant = participants.find(p => p.id === participantId);
await loadPersonalHistory(participantId);
}
async function loadPersonalHistory(participantId) {
try {
const history = await apiCall(`/api/participants/${participantId}/history`);
const individualData = await apiCall('/api/leaderboard/individual');
const personData = individualData.find(p => p.id === participantId);
// Display stats
const statsBox = document.getElementById('personal-stats');
if (personData) {
const avgSteps = personData.days_logged > 0 ? Math.round(personData.total_steps / personData.days_logged) : 0;
statsBox.innerHTML = `
<div class="stat-item">
<span class="stat-label">Total Steps:</span>
<span class="stat-value">${personData.total_steps.toLocaleString()}</span>
</div>
<div class="stat-item">
<span class="stat-label">Days Logged:</span>
<span class="stat-value">${personData.days_logged}</span>
</div>
<div class="stat-item">
<span class="stat-label">Avg Steps/Day:</span>
<span class="stat-value">${avgSteps.toLocaleString()}</span>
</div>
`;
}
// Display history
const historyDiv = document.getElementById('personal-history');
if (history.length === 0) {
historyDiv.innerHTML = '<p class="help-text">No entries yet. Start logging your steps!</p>';
} else {
historyDiv.innerHTML = history.map(entry => `
<div class="history-item">
<span class="date">${formatDate(entry.date)}</span>
<span class="steps">${entry.steps.toLocaleString()} steps</span>
</div>
`).join('');
}
} catch (error) {
console.error('Error loading personal history:', error);
}
}
async function loadTeamStats(teamId) {
try {
const [individualData, teamData, timeline] = await Promise.all([
apiCall('/api/leaderboard/individual'),
apiCall('/api/leaderboard/team'),
apiCall('/api/monsters/timeline')
]);
const team = teamData.find(t => t.id === teamId);
const teamMembers = individualData.filter(p => p.team_id === teamId);
const teamStatsDiv = document.getElementById('team-stats');
if (team && teamMembers.length > 0) {
// Sort team members by total steps
teamMembers.sort((a, b) => b.total_steps - a.total_steps);
// Find monsters caught by this team
const caughtMonsters = timeline.filter(monster =>
monster.catches.some(c => c.team_id === teamId)
);
teamStatsDiv.innerHTML = `
<div class="stat-item">
<span class="stat-label">Team Total:</span>
<span class="stat-value">${team.total_steps.toLocaleString()}</span>
</div>
${caughtMonsters.length > 0 ? `
<div class="stat-item">
<span class="stat-label">Monsters Caught:</span>
<span class="stat-value">${caughtMonsters.length} 🏆</span>
</div>
<div class="caught-monsters-list">
${caughtMonsters.map(m => `
<span class="monster-badge" title="${m.monster_name} - ${formatDate(m.date)}">${m.monster_icon}</span>
`).join('')}
</div>
` : ''}
<h4 style="margin: 15px 0 10px 0; color: #8a2be2;">Team Members:</h4>
${teamMembers.map((member, index) => `
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #444; color: #fff;">
<span style="color: ${member.total_steps > 0 ? '#fff' : '#999'};">${index + 1}. ${member.name}</span>
<span style="font-weight: 600; color: ${member.total_steps > 0 ? '#ff7700' : '#999'};">${member.total_steps.toLocaleString()}</span>
</div>
`).join('')}
`;
}
} catch (error) {
console.error('Error loading team stats:', error);
}
}
// Step submission
async function submitSteps() {
const participantId = document.getElementById('name-select').value;
const date = document.getElementById('step-date').value;
const steps = parseInt(document.getElementById('step-count').value);
const messageDiv = document.getElementById('entry-message');
if (!participantId || !date || isNaN(steps)) {
showMessage(messageDiv, 'Please fill in all fields', 'error');
return;
}
// Validate date is not after October 31, 2025
const contestEndDate = new Date('2025-10-31');
const selectedDate = new Date(date + 'T00:00:00');
if (selectedDate > contestEndDate) {
showMessage(messageDiv, 'Contest ended on October 31, 2025. Cannot enter steps after this date.', 'error');
return;
}
try {
await apiCall('/api/steps', 'POST', {
participant_id: parseInt(participantId),
date,
steps
});
document.getElementById('step-count').value = '';
showMessage(messageDiv, 'Steps submitted successfully!', 'success');
// Reload personal history and team stats
if (selectedParticipant) {
await loadPersonalHistory(selectedParticipant.id);
const teamId = document.getElementById('team-select').value;
if (teamId) {
await loadTeamStats(parseInt(teamId));
}
}
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
}
// CSV Import
async function importCSV() {
const fileInput = document.getElementById('csv-file');
const messageDiv = document.getElementById('import-message');
if (!fileInput.files || !fileInput.files[0]) {
showMessage(messageDiv, 'Please select a CSV file', 'error');
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const csvText = e.target.result;
const lines = csvText.split('\n').filter(line => line.trim());
// Skip header row
const dataLines = lines.slice(1);
const data = dataLines.map(line => {
const [team_name, participant_name, email] = line.split(',').map(s => s.trim());
return { team_name, participant_name, email };
});
const result = await apiCall('/api/import/csv', 'POST', { data });
showMessage(
messageDiv,
`Success! Created ${result.teamsCreated} teams and ${result.participantsCreated} participants`,
'success'
);
// Reload data
await loadTeams();
await loadParticipants();
fileInput.value = '';
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
};
reader.readAsText(file);
}
// Team Standings Tab
async function loadTeamStandings() {
try {
const [teamData, individualData, timeline] = await Promise.all([
apiCall('/api/leaderboard/team'),
apiCall('/api/leaderboard/individual'),
apiCall('/api/monsters/timeline')
]);
// Team Leaderboard
const teamTbody = document.querySelector('#team-leaderboard tbody');
teamTbody.innerHTML = '';
teamData.forEach((team, index) => {
const row = teamTbody.insertRow();
const avgPerPerson = team.member_count > 0 ? Math.round(team.total_steps / team.member_count) : 0;
// Find monsters caught by this team
const caughtMonsters = timeline.filter(monster =>
monster.catches.some(c => c.team_id === team.id)
);
const monsterBadges = caughtMonsters.length > 0
? caughtMonsters.map(m => `<span title="${m.monster_name} - ${m.date}">${m.monster_icon}</span>`).join(' ')
: '-';
row.innerHTML = `
<td>${index + 1}</td>
<td>${team.team_name}</td>
<td class="number-large">${team.total_steps.toLocaleString()}</td>
<td>${team.active_count}</td>
<td>${avgPerPerson.toLocaleString()}</td>
<td style="font-size: 20px; text-align: center;">${monsterBadges}</td>
`;
});
// Individual Leaderboard
const individualTbody = document.querySelector('#individual-leaderboard tbody');
individualTbody.innerHTML = '';
individualData.forEach((person, index) => {
const row = individualTbody.insertRow();
const avgSteps = person.days_logged > 0 ? Math.round(person.total_steps / person.days_logged) : 0;
row.innerHTML = `
<td>${index + 1}</td>
<td>${person.name}</td>
<td>${person.team_name}</td>
<td class="number-large">${person.total_steps.toLocaleString()}</td>
<td>${person.days_logged}</td>
<td>${avgSteps.toLocaleString()}</td>
`;
});
} catch (error) {
console.error('Error loading team standings:', error);
}
}
// Competition Stats Tab
async function loadCompetitionStats() {
try {
const [individualData, dailyProgress, teamData] = await Promise.all([
apiCall('/api/leaderboard/individual'),
apiCall('/api/progress/daily'),
apiCall('/api/leaderboard/team')
]);
// Calculate overall stats
const totalSteps = individualData.reduce((sum, p) => sum + p.total_steps, 0);
const totalParticipants = individualData.length;
const totalDaysLogged = individualData.reduce((sum, p) => sum + p.days_logged, 0);
const avgDailySteps = totalDaysLogged > 0 ? Math.round(totalSteps / totalDaysLogged) : 0;
// Get unique dates
const uniqueDates = [...new Set(dailyProgress.map(d => d.date))];
const daysActive = uniqueDates.length;
// Update stat cards
document.getElementById('total-steps').textContent = totalSteps.toLocaleString();
document.getElementById('total-participants').textContent = totalParticipants;
document.getElementById('avg-daily-steps').textContent = avgDailySteps.toLocaleString();
document.getElementById('days-active').textContent = daysActive;
// Render charts
renderTeamProgressChart(dailyProgress);
renderTopIndividualsChart(individualData.slice(0, 10));
await renderTeamComparisonChart(teamData);
} catch (error) {
console.error('Error loading competition stats:', error);
}
}
function renderTeamProgressChart(data) {
const ctx = document.getElementById('team-progress-chart');
// Group by team
const teamData = {};
data.forEach(entry => {
if (!teamData[entry.team_name]) {
teamData[entry.team_name] = { dates: [], steps: [] };
}
teamData[entry.team_name].dates.push(entry.date);
teamData[entry.team_name].steps.push(entry.daily_steps);
});
// Get all unique dates sorted
const allDates = [...new Set(data.map(d => d.date))].sort();
// Create datasets
const colors = [
'rgba(102, 126, 234, 1)',
'rgba(118, 75, 162, 1)',
'rgba(237, 100, 166, 1)',
'rgba(255, 154, 158, 1)',
'rgba(250, 208, 196, 1)',
'rgba(136, 140, 255, 1)',
'rgba(72, 1, 255, 1)',
'rgba(255, 193, 7, 1)'
];
const datasets = Object.entries(teamData).map(([teamName, data], index) => {
const cumulativeSteps = [];
let total = 0;
allDates.forEach(date => {
const dateIndex = data.dates.indexOf(date);
if (dateIndex !== -1) {
total += data.steps[dateIndex];
}
cumulativeSteps.push(total);
});
return {
label: teamName,
data: cumulativeSteps,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length].replace('1)', '0.1)'),
borderWidth: 3,
fill: true,
tension: 0.4
};
});
if (charts.teamProgress) {
charts.teamProgress.destroy();
}
charts.teamProgress = new Chart(ctx, {
type: 'line',
data: {
labels: allDates,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: true,
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value.toLocaleString();
}
}
}
}
}
});
}
function renderTopIndividualsChart(data) {
const ctx = document.getElementById('top-individuals-chart');
if (charts.topIndividuals) {
charts.topIndividuals.destroy();
}
charts.topIndividuals = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(p => p.name),
datasets: [{
label: 'Total Steps',
data: data.map(p => p.total_steps),
backgroundColor: 'rgba(102, 126, 234, 0.8)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value.toLocaleString();
}
}
}
}
}
});
}
async function renderTeamComparisonChart(teamData) {
const ctx = document.getElementById('team-comparison-chart');
if (charts.teamComparison) {
charts.teamComparison.destroy();
}
// Get individual data to break down each team
const individualData = await apiCall('/api/leaderboard/individual');
// Generate distinct colors for each person
const personColors = [
'rgba(102, 126, 234, 0.8)',
'rgba(118, 75, 162, 0.8)',
'rgba(237, 100, 166, 0.8)',
'rgba(255, 154, 158, 0.8)',
'rgba(250, 208, 196, 0.8)',
'rgba(136, 140, 255, 0.8)',
'rgba(72, 1, 255, 0.8)',
'rgba(255, 193, 7, 0.8)',
'rgba(0, 188, 212, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(255, 87, 34, 0.8)',
'rgba(76, 175, 80, 0.8)'
];
// Get team labels sorted by total steps
const sortedTeams = teamData.sort((a, b) => b.total_steps - a.total_steps);
const teamLabels = sortedTeams.map(t => t.team_name);
// Create a dataset for each unique participant
const allParticipants = individualData.sort((a, b) => b.total_steps - a.total_steps);
const datasets = allParticipants.map((person, index) => {
// Create data array with this person's steps for their team, 0 for others
const data = teamLabels.map(teamName => {
return person.team_name === teamName ? person.total_steps : 0;
});
return {
label: person.name,
data: data,
backgroundColor: personColors[index % personColors.length],
borderWidth: 1,
borderColor: 'white'
};
});
charts.teamComparison = new Chart(ctx, {
type: 'bar',
data: {
labels: teamLabels,
datasets: datasets
},
options: {
indexAxis: 'y', // Makes it horizontal
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
callbacks: {
label: function(context) {
const label = context.dataset.label || '';
const value = context.parsed.x;
return value > 0 ? `${label}: ${value.toLocaleString()} steps` : null;
},
afterBody: function(tooltipItems) {
// Calculate the team total by summing all items in this tooltip
const total = tooltipItems.reduce((sum, item) => sum + item.parsed.x, 0);
return `\nTeam Total: ${total.toLocaleString()} steps`;
}
}
}
},
scales: {
x: {
stacked: true,
beginAtZero: true,
ticks: {
callback: function(value) {
return value.toLocaleString();
}
}
},
y: {
stacked: true
}
}
}
});
}
// Daily Monsters Tab
async function loadDailyMonsters() {
try {
const [todayStatus, timeline] = await Promise.all([
apiCall('/api/monsters/status/today'),
apiCall('/api/monsters/timeline')
]);
// Render today's monster
renderTodaysMonster(todayStatus);
// Render timeline
renderMonsterTimeline(timeline);
} catch (error) {
console.error('Error loading daily monsters:', error);
}
}
function renderTodaysMonster(status) {
const cardDiv = document.getElementById('todays-monster-card');
if (!status.monster) {
cardDiv.innerHTML = '<p class="help-text">No monster challenge available. Check back tomorrow!</p>';
return;
}
const { monster, progress } = status;
// Calculate how many teams caught it
const caughtCount = progress.filter(p => p.caught).length;
// Format the date nicely
const monsterDate = new Date(monster.date + 'T00:00:00');
const formattedDate = monsterDate.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric'
});
cardDiv.innerHTML = `
<div style="background: rgba(138, 43, 226, 0.1); border: 1px solid rgba(138, 43, 226, 0.3); border-radius: 8px; padding: 12px; margin-bottom: 15px;">
<p style="margin: 0; color: #bb86fc; font-size: 14px;">
<strong>📅 Active Challenge:</strong> Log your steps from <strong>${formattedDate}</strong> (yesterday)
</p>
</div>
<div class="monster-icon-large">${monster.monster_icon}</div>
<h2 class="monster-name">${monster.monster_name}</h2>
<p class="monster-description">${monster.monster_description || ''}</p>
<div class="monster-goal">
<span class="goal-label">Goal:</span>
<span class="goal-value">${monster.step_goal.toLocaleString()} steps</span>
</div>
<div class="caught-count">${caughtCount} of ${progress.length} teams caught this monster!</div>
<div class="team-progress-list">
<h4>Team Progress:</h4>
${progress.map(team => {
const percentage = Math.min((team.total_steps / monster.step_goal) * 100, 100);
const caught = team.caught;
return `
<div class="team-progress-item ${caught ? 'caught' : ''}">
<div class="team-progress-header">
<span class="team-name">${team.team_name}</span>
<span class="team-steps">${team.total_steps.toLocaleString()} steps ${caught ? '🏆' : ''}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${percentage}%"></div>
</div>
</div>
`;
}).join('')}
</div>
`;
}
function renderMonsterTimeline(timeline) {
const timelineDiv = document.getElementById('monster-timeline');
// Handle null, undefined, or empty array
if (!timeline || !Array.isArray(timeline) || timeline.length === 0) {
timelineDiv.innerHTML = '<p class="help-text">No monsters have been configured yet.</p>';
return;
}
// Get yesterday's date (active challenge - enter yesterday's steps)
const now = new Date();
now.setDate(now.getDate() - 1);
const yesterday = now.toISOString().split('T')[0];
const today = new Date().toISOString().split('T')[0];
timelineDiv.innerHTML = timeline.map(monster => {
const monsterDate = monster.date;
const isPast = monsterDate < yesterday;
const isYesterday = monsterDate === yesterday; // Active challenge - enter yesterday's steps
const isToday = monsterDate === today; // Next challenge (tomorrow)
const isFuture = monsterDate > today;
let statusClass = 'future';
let statusText = 'Upcoming';
if (isPast) {
statusClass = 'past';
statusText = monster.catches.length > 0 ? `${monster.catches.length} teams caught` : 'Escaped';
} else if (isYesterday) {
statusClass = 'today';
statusText = "Active - Enter Yesterday's Steps";
} else if (isToday) {
statusClass = 'tomorrow';
statusText = 'Next Challenge';
}
return `
<div class="monster-card ${statusClass}">
<div class="monster-card-header">
<div class="monster-icon">${monster.monster_icon}</div>
<div class="monster-info">
<h4 class="monster-card-name">${monster.monster_name}</h4>
<div class="monster-date">${formatDate(monster.date)}</div>
</div>
<div class="monster-status ${statusClass}">${statusText}</div>
</div>
<p class="monster-card-description">${monster.monster_description || ''}</p>
<div class="monster-card-goal">
<strong>Goal:</strong> ${monster.step_goal.toLocaleString()} steps
</div>
${monster.catches.length > 0 ? `
<div class="monster-catches">
<strong>Caught by:</strong>
<ul>
${monster.catches.map(c => `
<li>${c.team_name} - ${c.final_steps.toLocaleString()} steps</li>
`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}).join('');
}
// Monster CSV Import
async function importMonsters() {
const fileInput = document.getElementById('monster-csv-file');
const messageDiv = document.getElementById('monster-import-message');
if (!fileInput.files || !fileInput.files[0]) {
showMessage(messageDiv, 'Please select a CSV file', 'error');
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = async (e) => {
try {
const csvText = e.target.result;
const lines = csvText.split('\n').filter(line => line.trim());
// Skip header row
const dataLines = lines.slice(1);
const data = dataLines.map(line => {
const [date, monster_name, monster_description, step_goal, monster_icon] = line.split(',').map(s => s.trim());
return { date, monster_name, monster_description, step_goal, monster_icon };
});
const result = await apiCall('/api/monsters/import', 'POST', { data });
showMessage(
messageDiv,
`Success! Created ${result.monstersCreated} monsters, updated ${result.monstersUpdated}`,
'success'
);
// Reload monsters
await loadDailyMonsters();
fileInput.value = '';
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
};
reader.readAsText(file);
}
// Utility functions
function showMessage(element, message, type) {
element.textContent = message;
element.className = `message ${type}`;
element.style.display = 'block';
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
function formatDate(dateStr) {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
});
}

31
public/favicon.svg Normal file
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

749
public/styles.css Normal file
View 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;
}
}

81
sample-data.js Normal file
View File

@@ -0,0 +1,81 @@
// Sample data script to populate the database with test data
// Run with: node sample-data.js
const { initializeDatabase, runQuery } = require('./src/database');
async function populateSampleData() {
console.log('Initializing database...');
initializeDatabase();
// Wait a bit for database to initialize
await new Promise(resolve => setTimeout(resolve, 1000));
try {
// Create teams
console.log('Creating teams...');
const team1 = await runQuery('INSERT INTO teams (name) VALUES (?)', ['Team Velocity']);
const team2 = await runQuery('INSERT INTO teams (name) VALUES (?)', ['Team Momentum']);
const team3 = await runQuery('INSERT INTO teams (name) VALUES (?)', ['Team Endurance']);
console.log('Teams created!');
// Create participants
console.log('Creating participants...');
const participants = [
// Team Velocity
{ name: 'Alice Johnson', email: 'alice@example.com', team_id: team1.id },
{ name: 'Bob Smith', email: 'bob@example.com', team_id: team1.id },
{ name: 'Carol Williams', email: 'carol@example.com', team_id: team1.id },
// Team Momentum
{ name: 'David Brown', email: 'david@example.com', team_id: team2.id },
{ name: 'Eve Davis', email: 'eve@example.com', team_id: team2.id },
{ name: 'Frank Miller', email: 'frank@example.com', team_id: team2.id },
// Team Endurance
{ name: 'Grace Wilson', email: 'grace@example.com', team_id: team3.id },
{ name: 'Henry Moore', email: 'henry@example.com', team_id: team3.id },
{ name: 'Iris Taylor', email: 'iris@example.com', team_id: team3.id },
];
const participantIds = [];
for (const p of participants) {
const result = await runQuery(
'INSERT INTO participants (name, email, team_id) VALUES (?, ?, ?)',
[p.name, p.email, p.team_id]
);
participantIds.push(result.id);
}
console.log('Participants created!');
// Create sample step entries for the past week
console.log('Creating sample step entries...');
const today = new Date();
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
const date = new Date(today);
date.setDate(date.getDate() - dayOffset);
const dateStr = date.toISOString().split('T')[0];
for (const participantId of participantIds) {
// Random steps between 5000 and 15000
const steps = Math.floor(Math.random() * 10000) + 5000;
await runQuery(
'INSERT INTO daily_steps (participant_id, date, steps) VALUES (?, ?, ?)',
[participantId, dateStr, steps]
);
}
}
console.log('Sample data created successfully!');
console.log('\nYou can now start the app with: npm start');
console.log('Then visit: http://localhost:3060');
process.exit(0);
} catch (error) {
console.error('Error creating sample data:', error);
process.exit(1);
}
}
populateSampleData();

49
sample_import.csv Normal file
View File

@@ -0,0 +1,49 @@
team_name,participant_name,email
Team Alpha,Alice Johnson,alice@example.com
Team Alpha,Bob Smith,bob@example.com
Team Alpha,Carol Williams,carol@example.com
Team Alpha,David Brown,david@example.com
Team Alpha,Eve Davis,eve@example.com
Team Alpha,Frank Miller,frank@example.com
Team Beta,Grace Wilson,grace@example.com
Team Beta,Henry Moore,henry@example.com
Team Beta,Iris Taylor,iris@example.com
Team Beta,Jack Anderson,jack@example.com
Team Beta,Karen Thomas,karen@example.com
Team Beta,Leo Jackson,leo@example.com
Team Gamma,Maria White,maria@example.com
Team Gamma,Nathan Harris,nathan@example.com
Team Gamma,Olivia Martin,olivia@example.com
Team Gamma,Paul Thompson,paul@example.com
Team Gamma,Quinn Garcia,quinn@example.com
Team Gamma,Rachel Martinez,rachel@example.com
Team Delta,Sam Robinson,sam@example.com
Team Delta,Tina Clark,tina@example.com
Team Delta,Uma Rodriguez,uma@example.com
Team Delta,Victor Lewis,victor@example.com
Team Delta,Wendy Lee,wendy@example.com
Team Delta,Xavier Walker,xavier@example.com
Team Epsilon,Yara Hall,yara@example.com
Team Epsilon,Zane Allen,zane@example.com
Team Epsilon,Amy Young,amy@example.com
Team Epsilon,Brian King,brian@example.com
Team Epsilon,Cathy Wright,cathy@example.com
Team Epsilon,Derek Lopez,derek@example.com
Team Zeta,Emma Hill,emma@example.com
Team Zeta,Felix Scott,felix@example.com
Team Zeta,Gina Green,gina@example.com
Team Zeta,Hugo Adams,hugo@example.com
Team Zeta,Ivy Baker,ivy@example.com
Team Zeta,James Nelson,james@example.com
Team Eta,Kelly Carter,kelly@example.com
Team Eta,Larry Mitchell,larry@example.com
Team Eta,Monica Perez,monica@example.com
Team Eta,Noah Roberts,noah@example.com
Team Eta,Opal Turner,opal@example.com
Team Eta,Peter Phillips,peter@example.com
Team Theta,Quincy Campbell,quincy@example.com
Team Theta,Rosa Parker,rosa@example.com
Team Theta,Steve Evans,steve@example.com
Team Theta,Tracy Edwards,tracy@example.com
Team Theta,Ursula Collins,ursula@example.com
Team Theta,Vincent Stewart,vincent@example.com
1 team_name participant_name email
2 Team Alpha Alice Johnson alice@example.com
3 Team Alpha Bob Smith bob@example.com
4 Team Alpha Carol Williams carol@example.com
5 Team Alpha David Brown david@example.com
6 Team Alpha Eve Davis eve@example.com
7 Team Alpha Frank Miller frank@example.com
8 Team Beta Grace Wilson grace@example.com
9 Team Beta Henry Moore henry@example.com
10 Team Beta Iris Taylor iris@example.com
11 Team Beta Jack Anderson jack@example.com
12 Team Beta Karen Thomas karen@example.com
13 Team Beta Leo Jackson leo@example.com
14 Team Gamma Maria White maria@example.com
15 Team Gamma Nathan Harris nathan@example.com
16 Team Gamma Olivia Martin olivia@example.com
17 Team Gamma Paul Thompson paul@example.com
18 Team Gamma Quinn Garcia quinn@example.com
19 Team Gamma Rachel Martinez rachel@example.com
20 Team Delta Sam Robinson sam@example.com
21 Team Delta Tina Clark tina@example.com
22 Team Delta Uma Rodriguez uma@example.com
23 Team Delta Victor Lewis victor@example.com
24 Team Delta Wendy Lee wendy@example.com
25 Team Delta Xavier Walker xavier@example.com
26 Team Epsilon Yara Hall yara@example.com
27 Team Epsilon Zane Allen zane@example.com
28 Team Epsilon Amy Young amy@example.com
29 Team Epsilon Brian King brian@example.com
30 Team Epsilon Cathy Wright cathy@example.com
31 Team Epsilon Derek Lopez derek@example.com
32 Team Zeta Emma Hill emma@example.com
33 Team Zeta Felix Scott felix@example.com
34 Team Zeta Gina Green gina@example.com
35 Team Zeta Hugo Adams hugo@example.com
36 Team Zeta Ivy Baker ivy@example.com
37 Team Zeta James Nelson james@example.com
38 Team Eta Kelly Carter kelly@example.com
39 Team Eta Larry Mitchell larry@example.com
40 Team Eta Monica Perez monica@example.com
41 Team Eta Noah Roberts noah@example.com
42 Team Eta Opal Turner opal@example.com
43 Team Eta Peter Phillips peter@example.com
44 Team Theta Quincy Campbell quincy@example.com
45 Team Theta Rosa Parker rosa@example.com
46 Team Theta Steve Evans steve@example.com
47 Team Theta Tracy Edwards tracy@example.com
48 Team Theta Ursula Collins ursula@example.com
49 Team Theta Vincent Stewart vincent@example.com

118
src/database.js Normal file
View File

@@ -0,0 +1,118 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'step_competition.db');
const db = new sqlite3.Database(dbPath);
// Initialize database schema
function initializeDatabase() {
db.serialize(() => {
// Teams table
db.run(`
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Participants table
db.run(`
CREATE TABLE IF NOT EXISTS participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
team_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id)
)
`);
// Daily steps table
db.run(`
CREATE TABLE IF NOT EXISTS daily_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
participant_id INTEGER NOT NULL,
date DATE NOT NULL,
steps INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (participant_id) REFERENCES participants(id),
UNIQUE(participant_id, date)
)
`);
// Daily monsters table
db.run(`
CREATE TABLE IF NOT EXISTS daily_monsters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL UNIQUE,
monster_name TEXT NOT NULL,
monster_description TEXT,
step_goal INTEGER NOT NULL,
monster_icon TEXT DEFAULT '👹',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Monster catches table
db.run(`
CREATE TABLE IF NOT EXISTS monster_catches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
monster_id INTEGER NOT NULL,
caught_at DATETIME DEFAULT CURRENT_TIMESTAMP,
final_steps INTEGER NOT NULL,
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (monster_id) REFERENCES daily_monsters(id),
UNIQUE(team_id, monster_id)
)
`);
// Create indexes for better query performance
db.run(`CREATE INDEX IF NOT EXISTS idx_daily_steps_date ON daily_steps(date)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_daily_steps_participant ON daily_steps(participant_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_participants_team ON participants(team_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_daily_monsters_date ON daily_monsters(date)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_monster_catches_team ON monster_catches(team_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_monster_catches_monster ON monster_catches(monster_id)`);
console.log('Database initialized successfully');
});
}
// Helper function to run queries with promises
function runQuery(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ id: this.lastID, changes: this.changes });
});
});
}
function getQuery(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function allQuery(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
module.exports = {
db,
initializeDatabase,
runQuery,
getQuery,
allQuery
};

581
src/server.js Normal file
View File

@@ -0,0 +1,581 @@
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const path = require('path');
const { initializeDatabase, runQuery, getQuery, allQuery } = require('./database');
const app = express();
const PORT = process.env.PORT || 3060;
// Middleware
app.use(cors());
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, '..', 'public')));
// Initialize database
initializeDatabase();
// ===== TEAM ENDPOINTS =====
// Get all teams
app.get('/api/teams', async (req, res) => {
try {
const teams = await allQuery('SELECT * FROM teams ORDER BY name');
res.json(teams);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create a new team
app.post('/api/teams', async (req, res) => {
try {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Team name is required' });
}
const result = await runQuery('INSERT INTO teams (name) VALUES (?)', [name]);
res.json({ id: result.id, name });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ===== PARTICIPANT ENDPOINTS =====
// Get all participants
app.get('/api/participants', async (req, res) => {
try {
const participants = await allQuery(`
SELECT p.*, t.name as team_name
FROM participants p
JOIN teams t ON p.team_id = t.id
ORDER BY t.name, p.name
`);
res.json(participants);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get participants by team
app.get('/api/teams/:teamId/participants', async (req, res) => {
try {
const participants = await allQuery(
'SELECT * FROM participants WHERE team_id = ? ORDER BY name',
[req.params.teamId]
);
res.json(participants);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create a new participant
app.post('/api/participants', async (req, res) => {
try {
const { name, email, team_id } = req.body;
if (!name || !team_id) {
return res.status(400).json({ error: 'Name and team_id are required' });
}
const result = await runQuery(
'INSERT INTO participants (name, email, team_id) VALUES (?, ?, ?)',
[name, email, team_id]
);
res.json({ id: result.id, name, email, team_id });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Bulk import teams and participants from CSV data
app.post('/api/import/csv', async (req, res) => {
try {
const { data } = req.body; // Expected format: [{team_name, participant_name, email}]
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'Invalid data format' });
}
const teamMap = new Map();
let teamsCreated = 0;
let participantsCreated = 0;
let errors = [];
// Process each row
for (let i = 0; i < data.length; i++) {
const row = data[i];
try {
// Get or create team
let teamId = teamMap.get(row.team_name);
if (!teamId) {
// Check if team already exists
const existingTeam = await getQuery('SELECT id FROM teams WHERE name = ?', [row.team_name]);
if (existingTeam) {
teamId = existingTeam.id;
} else {
// Create new team
const teamResult = await runQuery('INSERT INTO teams (name) VALUES (?)', [row.team_name]);
teamId = teamResult.id;
teamsCreated++;
}
teamMap.set(row.team_name, teamId);
}
// Create participant (skip if already exists)
const existingParticipant = await getQuery(
'SELECT id FROM participants WHERE name = ? AND team_id = ?',
[row.participant_name, teamId]
);
if (!existingParticipant) {
await runQuery(
'INSERT INTO participants (name, email, team_id) VALUES (?, ?, ?)',
[row.participant_name, row.email || null, teamId]
);
participantsCreated++;
}
} catch (error) {
errors.push(`Row ${i + 1}: ${error.message}`);
}
}
res.json({
success: true,
teamsCreated,
participantsCreated,
errors: errors.length > 0 ? errors : null
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get participant's step history
app.get('/api/participants/:participantId/history', async (req, res) => {
try {
const history = await allQuery(
'SELECT date, steps FROM daily_steps WHERE participant_id = ? ORDER BY date DESC',
[req.params.participantId]
);
res.json(history);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ===== STEP TRACKING ENDPOINTS =====
// Get steps for a specific participant and date
app.get('/api/steps/:participantId/:date', async (req, res) => {
try {
const step = await getQuery(
'SELECT * FROM daily_steps WHERE participant_id = ? AND date = ?',
[req.params.participantId, req.params.date]
);
res.json(step || { steps: 0 });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Submit or update steps for a specific date
app.post('/api/steps', async (req, res) => {
try {
const { participant_id, date, steps } = req.body;
if (!participant_id || !date || steps === undefined) {
return res.status(400).json({ error: 'participant_id, date, and steps are required' });
}
// Check if entry exists
const existing = await getQuery(
'SELECT id FROM daily_steps WHERE participant_id = ? AND date = ?',
[participant_id, date]
);
if (existing) {
// Update existing entry
await runQuery(
'UPDATE daily_steps SET steps = ?, updated_at = CURRENT_TIMESTAMP WHERE participant_id = ? AND date = ?',
[steps, participant_id, date]
);
res.json({ message: 'Steps updated', participant_id, date, steps });
} else {
// Create new entry
const result = await runQuery(
'INSERT INTO daily_steps (participant_id, date, steps) VALUES (?, ?, ?)',
[participant_id, date, steps]
);
res.json({ id: result.id, participant_id, date, steps });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ===== LEADERBOARD ENDPOINTS =====
// Get individual leaderboard (total steps per person)
app.get('/api/leaderboard/individual', async (req, res) => {
try {
const startDate = req.query.start_date;
const endDate = req.query.end_date;
let query = `
SELECT
p.id,
p.name,
t.name as team_name,
t.id as team_id,
COALESCE(SUM(ds.steps), 0) as total_steps,
COUNT(DISTINCT ds.date) as days_logged
FROM participants p
LEFT JOIN daily_steps ds ON p.id = ds.participant_id
JOIN teams t ON p.team_id = t.id
`;
const params = [];
if (startDate && endDate) {
query += ' WHERE ds.date BETWEEN ? AND ?';
params.push(startDate, endDate);
}
query += ' GROUP BY p.id, p.name, t.name, t.id ORDER BY total_steps DESC';
const leaderboard = await allQuery(query, params);
res.json(leaderboard);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get team leaderboard (total steps per team)
app.get('/api/leaderboard/team', async (req, res) => {
try {
const startDate = req.query.start_date;
const endDate = req.query.end_date;
let query = `
SELECT
t.id,
t.name as team_name,
COALESCE(SUM(ds.steps), 0) as total_steps,
COUNT(DISTINCT CASE WHEN COALESCE(ds.steps, 0) > 0 THEN p.id END) as active_count,
COUNT(DISTINCT p.id) as member_count,
COALESCE(AVG(ds.steps), 0) as avg_steps_per_entry
FROM teams t
LEFT JOIN participants p ON t.id = p.team_id
LEFT JOIN daily_steps ds ON p.id = ds.participant_id
`;
const params = [];
if (startDate && endDate) {
query += ' WHERE ds.date BETWEEN ? AND ?';
params.push(startDate, endDate);
}
query += ' GROUP BY t.id, t.name ORDER BY total_steps DESC';
const leaderboard = await allQuery(query, params);
res.json(leaderboard);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get daily progress for all teams
app.get('/api/progress/daily', async (req, res) => {
try {
const startDate = req.query.start_date;
const endDate = req.query.end_date;
let query = `
SELECT
ds.date,
t.id as team_id,
t.name as team_name,
SUM(ds.steps) as daily_steps
FROM daily_steps ds
JOIN participants p ON ds.participant_id = p.id
JOIN teams t ON p.team_id = t.id
`;
const params = [];
if (startDate && endDate) {
query += ' WHERE ds.date BETWEEN ? AND ?';
params.push(startDate, endDate);
}
query += ' GROUP BY ds.date, t.id, t.name ORDER BY ds.date, t.name';
const progress = await allQuery(query, params);
res.json(progress);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get date range of competition
app.get('/api/competition/dates', async (req, res) => {
try {
const result = await getQuery(`
SELECT
MIN(date) as start_date,
MAX(date) as end_date
FROM daily_steps
`);
res.json(result || { start_date: null, end_date: null });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ===== DAILY MONSTERS ENDPOINTS =====
// Get all monsters (timeline view)
app.get('/api/monsters', async (req, res) => {
try {
const monsters = await allQuery('SELECT * FROM daily_monsters ORDER BY date');
res.json(monsters);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// IMPORTANT: Timeline route must come BEFORE the :date route to avoid conflicts
// Get all monsters with catch status
app.get('/api/monsters/timeline', async (req, res) => {
try {
const monsters = await allQuery('SELECT * FROM daily_monsters ORDER BY date');
// For each monster, check if teams caught it and update catches table
const timeline = await Promise.all(monsters.map(async (monster) => {
// Get team totals for this monster's date
const teamTotals = await allQuery(`
SELECT
t.id as team_id,
t.name as team_name,
COALESCE(SUM(ds.steps), 0) as total_steps
FROM teams t
LEFT JOIN participants p ON t.id = p.team_id
LEFT JOIN daily_steps ds ON p.id = ds.participant_id AND ds.date = ?
GROUP BY t.id, t.name
`, [monster.date]);
// Check and record catches for teams that met the goal
for (const team of teamTotals) {
if (team.total_steps >= monster.step_goal) {
// Check if catch already recorded
const existingCatch = await getQuery(
'SELECT id FROM monster_catches WHERE team_id = ? AND monster_id = ?',
[team.team_id, monster.id]
);
if (!existingCatch) {
// Record the catch
await runQuery(
'INSERT INTO monster_catches (team_id, monster_id, final_steps) VALUES (?, ?, ?)',
[team.team_id, monster.id, team.total_steps]
);
} else {
// Update the final_steps if it changed
await runQuery(
'UPDATE monster_catches SET final_steps = ? WHERE team_id = ? AND monster_id = ?',
[team.total_steps, team.team_id, monster.id]
);
}
}
}
// Now get the updated catches
const catches = await allQuery(`
SELECT
mc.*,
t.name as team_name
FROM monster_catches mc
JOIN teams t ON mc.team_id = t.id
WHERE mc.monster_id = ?
ORDER BY mc.caught_at
`, [monster.id]);
return {
...monster,
catches
};
}));
res.json(timeline);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get specific day's monster
app.get('/api/monsters/:date', async (req, res) => {
try {
const monster = await getQuery(
'SELECT * FROM daily_monsters WHERE date = ?',
[req.params.date]
);
res.json(monster || null);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Add/import monsters (bulk CSV import)
app.post('/api/monsters/import', async (req, res) => {
try {
const { data } = req.body; // Expected format: [{date, monster_name, monster_description, step_goal, monster_icon}]
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'Invalid data format' });
}
let monstersCreated = 0;
let monstersUpdated = 0;
let errors = [];
for (let i = 0; i < data.length; i++) {
const row = data[i];
try {
if (!row.date || !row.monster_name || !row.step_goal) {
errors.push(`Row ${i + 1}: Missing required fields (date, monster_name, step_goal)`);
continue;
}
// Check if monster already exists for this date
const existing = await getQuery(
'SELECT id FROM daily_monsters WHERE date = ?',
[row.date]
);
if (existing) {
// Update existing monster
await runQuery(
'UPDATE daily_monsters SET monster_name = ?, monster_description = ?, step_goal = ?, monster_icon = ? WHERE date = ?',
[row.monster_name, row.monster_description || null, parseInt(row.step_goal), row.monster_icon || '👹', row.date]
);
monstersUpdated++;
} else {
// Create new monster
await runQuery(
'INSERT INTO daily_monsters (date, monster_name, monster_description, step_goal, monster_icon) VALUES (?, ?, ?, ?, ?)',
[row.date, row.monster_name, row.monster_description || null, parseInt(row.step_goal), row.monster_icon || '👹']
);
monstersCreated++;
}
} catch (error) {
errors.push(`Row ${i + 1}: ${error.message}`);
}
}
res.json({
success: true,
monstersCreated,
monstersUpdated,
errors: errors.length > 0 ? errors : null
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get which teams caught a specific monster
app.get('/api/monsters/:date/catches', async (req, res) => {
try {
const monster = await getQuery(
'SELECT id FROM daily_monsters WHERE date = ?',
[req.params.date]
);
if (!monster) {
return res.json([]);
}
const catches = await allQuery(`
SELECT
mc.*,
t.name as team_name
FROM monster_catches mc
JOIN teams t ON mc.team_id = t.id
WHERE mc.monster_id = ?
ORDER BY mc.caught_at
`, [monster.id]);
res.json(catches);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get current day's monster + team progress
// Note: Since people enter steps the following day, "today's challenge" is actually yesterday's date
app.get('/api/monsters/status/today', async (req, res) => {
try {
// Get yesterday's date (the challenge people are working on today)
const now = new Date();
now.setDate(now.getDate() - 1);
const yesterday = now.toISOString().split('T')[0];
// Get yesterday's monster (today's challenge)
const monster = await getQuery(
'SELECT * FROM daily_monsters WHERE date = ?',
[yesterday]
);
if (!monster) {
return res.json({ monster: null, progress: [] });
}
// Get team progress for yesterday's date
const progress = await allQuery(`
SELECT
t.id as team_id,
t.name as team_name,
COALESCE(SUM(ds.steps), 0) as total_steps,
CASE WHEN COALESCE(SUM(ds.steps), 0) >= ? THEN 1 ELSE 0 END as caught
FROM teams t
LEFT JOIN participants p ON t.id = p.team_id
LEFT JOIN daily_steps ds ON p.id = ds.participant_id AND ds.date = ?
GROUP BY t.id, t.name
ORDER BY total_steps DESC
`, [monster.step_goal, yesterday]);
// Check and record new catches
for (const team of progress) {
if (team.caught) {
// Check if catch already recorded
const existingCatch = await getQuery(
'SELECT id FROM monster_catches WHERE team_id = ? AND monster_id = ?',
[team.team_id, monster.id]
);
if (!existingCatch) {
// Record the catch
await runQuery(
'INSERT INTO monster_catches (team_id, monster_id, final_steps) VALUES (?, ?, ?)',
[team.team_id, monster.id, team.total_steps]
);
}
}
}
res.json({ monster, progress });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Start server on all interfaces (0.0.0.0) to allow Tailscale access
app.listen(PORT, '0.0.0.0', () => {
console.log(`Step Competition app running on:`);
console.log(` - Local: http://localhost:${PORT}`);
console.log(` - Network: http://0.0.0.0:${PORT}`);
console.log(` - Tailscale: Access via your Tailscale IP on port ${PORT}`);
});