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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
step_competition.db
|
||||
*.db
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
132
QUICKSTART.md
Normal file
132
QUICKSTART.md
Normal 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
212
README.md
Normal 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
|
||||
49
competition_participants.csv
Normal file
49
competition_participants.csv
Normal 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
|
||||
|
20
ecosystem.config.js
Normal file
20
ecosystem.config.js
Normal 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
7
monsters.csv
Normal 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,👹
|
||||
|
2219
package-lock.json
generated
Normal file
2219
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
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;
|
||||
}
|
||||
}
|
||||
81
sample-data.js
Normal file
81
sample-data.js
Normal 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
49
sample_import.csv
Normal 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
|
||||
|
118
src/database.js
Normal file
118
src/database.js
Normal 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
581
src/server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user