Compare commits

..

9 Commits

Author SHA1 Message Date
abbefd0e80 last day 2025-11-04 02:40:32 +01:00
7995daa6e5 resync 2025-10-31 17:08:49 +01:00
14baafe1aa daily sync 2025-10-30 14:41:17 +01:00
acf5d4bc5c Resync 2025-10-28 15:10:28 +01:00
cc5b064e81 wednesday sync 2025-10-24 05:38:00 +02:00
1e19e5e2dd Db sync and tips 2025-10-23 22:08:26 +02:00
13ba04ae48 Added sqlite db just in case lol 2025-10-22 19:17:54 +02:00
a26c92ff2b Resync 2025-10-21 22:29:44 +02:00
04891bf449 Refactor monster_catches from table to view and add overwrite option
- Convert monster_catches from table to view for automatic calculation
- Add overwrite checkbox to monster import UI
- Remove manual INSERT/UPDATE logic for catches (now handled by view)
- Simplify API endpoints to query view instead of managing state
- Add confirmation dialog for overwrite operation

Benefits:
- No data duplication
- Always accurate catch status based on current steps
- Simpler codebase with less state management
- Easier to reset steps without orphaned catch records

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 16:08:02 +02:00
8 changed files with 275 additions and 123 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,7 +1,11 @@
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-23,Xenomorph,The perfect organism hunts in the shadows!,72000,👽
2025-10-24,Predator,Hunt down those steps!,66000,👹
2025-10-27,Blob,This oozing creature is slow and easy to catch!,36000,🟢
2025-10-28,Godzilla,Each massive stride covers miles!,72000,🦖
2025-10-29,Werewolf,Fast and agile under the full moon!,78000,🐺
2025-10-30,Frankenstein's Monster,A lumbering creation seeks companionship!,48000,
2025-10-31,Dr. Jekyll/Mr. Hyde,Two personalities - one fast chase!,66000,🧪
1 date monster_name monster_description step_goal monster_icon
2025-10-17 Test Monster Can you catch this spooky creature? 30000 👻
2 2025-10-20 Dracula The Count demands your steps! 60000 🧛
3 2025-10-21 Freddy Krueger Don't fall asleep on your steps! 48000 🔪
4 2025-10-22 Mummy Unwrap your potential! 48000 🧟
5 2025-10-23 Aliens Xenomorph Take steps to another world! The perfect organism hunts in the shadows! 72000 👽
6 2025-10-24 Predator Hunt down those steps! 66000 👹
7 2025-10-27 Blob This oozing creature is slow and easy to catch! 36000 🟢
8 2025-10-28 Godzilla Each massive stride covers miles! 72000 🦖
9 2025-10-29 Werewolf Fast and agile under the full moon! 78000 🐺
10 2025-10-30 Frankenstein's Monster A lumbering creation seeks companionship! 48000
11 2025-10-31 Dr. Jekyll/Mr. Hyde Two personalities - one fast chase! 66000 🧪

View File

@@ -1,10 +1,39 @@
const API_URL = window.location.origin;
// Humorous step tips
const STEP_TIPS = [
"Never underestimate a 5-minute dance break. Your step count (and mood) will thank you.",
"Don't let the high-steppers get you down. Focus on crushing whoever's ranked right above you.",
"Pacing while on phone calls counts. Your colleagues will understand the extra enthusiasm.",
"Pro tip: Park in the farthest spot. It's not laziness, it's strategy.",
"Taking the stairs? That's just vertical stepping with extra credit.",
"Your fitness tracker thinks you're training for a marathon. Let's not disappoint it.",
"Remember: Every journey to the fridge and back is an opportunity for greatness.",
"Walking meetings are just step competitions in disguise. You're welcome.",
"Forgot something upstairs? That's not forgetfulness, that's bonus cardio.",
"Why run when you can aggressively walk? Same steps, less judgment.",
"Your dog wants a walk. Your step count wants a walk. It's basically a win-win-woof.",
"A monster a day keeps the last place away.",
"Reading this tip instead of walking? Bold strategy. Let's see how it plays out.",
"Pro tip about tips: They're less effective if you're sitting down while reading them.",
"Winners log their steps before noon. Legends log them before breakfast. -Mike (probably)",
"Infrastructure as a Service is down? Legs as a Service is always running.",
"Strapping the tracker to a ceiling fan was attempted in 2019. It didn't work then either.",
"Following your dog around the house may not be the most efficient way to get steps, but it's definitely the most confusing for the dog.",
"Dancing in the school pickup line may not be the most efficient way to get steps, but it's definitely the most embarrassing for your kids.",
"4 out of 5 dentists agree: this has nothing to do with teeth, but walking is still good.",
"Rumor has it the winner of the last competition was just trying to learn the latest Katseye choreo.",
"Alexa can't count your steps - make your light switch your new favorite app.",
];
// State
let teams = [];
let participants = [];
let charts = {};
let selectedParticipant = null;
let currentTipIndex = 0;
let tipRotationInterval = null;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
@@ -13,8 +42,20 @@ document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
setDefaultDate();
handleRouting();
checkAdminMode();
initializeTipRotation();
});
// Admin mode
function checkAdminMode() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('admin') === 'true') {
document.querySelectorAll('.import-section').forEach(el => {
el.style.display = 'block';
});
}
}
// Routing
function handleRouting() {
// Handle initial load and hash changes
@@ -63,15 +104,17 @@ function initializeTabs() {
});
}
// Set default date to today
// Set default date to yesterday (since we enter steps the day after)
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
const dateInput = document.getElementById('step-date');
dateInput.value = today;
dateInput.value = yesterdayStr;
// Set max date to October 31, 2025
dateInput.max = '2025-10-31';
dateInput.min = '2025-10-15';
dateInput.min = '2025-10-20';
}
// Load initial data
@@ -288,10 +331,9 @@ async function submitSteps() {
}
// Validate date is not after October 31, 2025
const contestEndDate = new Date('2025-10-31');
const selectedDate = new Date(date + 'T00:00:00');
const contestEndDate = '2025-10-31';
if (selectedDate > contestEndDate) {
if (date > contestEndDate) {
showMessage(messageDiv, 'Contest ended on October 31, 2025. Cannot enter steps after this date.', 'error');
return;
}
@@ -823,6 +865,7 @@ function renderMonsterTimeline(timeline) {
// Monster CSV Import
async function importMonsters() {
const fileInput = document.getElementById('monster-csv-file');
const overwriteCheckbox = document.getElementById('monster-overwrite');
const messageDiv = document.getElementById('monster-import-message');
if (!fileInput.files || !fileInput.files[0]) {
@@ -830,6 +873,20 @@ async function importMonsters() {
return;
}
const overwrite = overwriteCheckbox.checked;
console.log('Overwrite checkbox checked:', overwrite);
// Confirm if overwrite is selected
if (overwrite) {
const confirmed = confirm(
'WARNING: This will DELETE all existing monsters and monster catches!\n\n' +
'Are you sure you want to proceed?'
);
if (!confirmed) {
return;
}
}
const file = fileInput.files[0];
const reader = new FileReader();
@@ -846,18 +903,22 @@ async function importMonsters() {
return { date, monster_name, monster_description, step_goal, monster_icon };
});
const result = await apiCall('/api/monsters/import', 'POST', { data });
const result = await apiCall('/api/monsters/import', 'POST', { data, overwrite });
showMessage(
messageDiv,
`Success! Created ${result.monstersCreated} monsters, updated ${result.monstersUpdated}`,
'success'
);
let successMessage;
if (overwrite) {
successMessage = `Success! Deleted ${result.monstersDeleted} old monsters, created ${result.monstersCreated} new monsters`;
} else {
successMessage = `Success! Created ${result.monstersCreated} monsters, updated ${result.monstersUpdated}`;
}
showMessage(messageDiv, successMessage, 'success');
// Reload monsters
await loadDailyMonsters();
fileInput.value = '';
overwriteCheckbox.checked = false;
} catch (error) {
showMessage(messageDiv, `Error: ${error.message}`, 'error');
}
@@ -886,3 +947,59 @@ function formatDate(dateStr) {
day: 'numeric'
});
}
// Tip rotation functionality
function shuffleTips(array) {
// Fisher-Yates shuffle algorithm for true randomization
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function initializeTipRotation() {
// Clear any existing interval to prevent duplicates
if (tipRotationInterval) {
clearInterval(tipRotationInterval);
}
// Shuffle the tips array for randomized order
window.shuffledTips = shuffleTips(STEP_TIPS);
currentTipIndex = 0;
// Display first tip
displayTip();
// Rotate every 20 seconds
tipRotationInterval = setInterval(rotateTip, 20000);
}
function displayTip() {
const tipTextElement = document.getElementById('tip-text');
if (!tipTextElement || !window.shuffledTips) return;
tipTextElement.textContent = window.shuffledTips[currentTipIndex];
}
function rotateTip() {
const tipTextElement = document.getElementById('tip-text');
if (!tipTextElement || !window.shuffledTips) return;
// Fade out
tipTextElement.classList.add('fade-out');
// After fade out, change text and fade in
setTimeout(() => {
currentTipIndex = (currentTipIndex + 1) % window.shuffledTips.length;
// If we've completed a full cycle, re-shuffle for next cycle
if (currentTipIndex === 0) {
window.shuffledTips = shuffleTips(STEP_TIPS);
}
tipTextElement.textContent = window.shuffledTips[currentTipIndex];
tipTextElement.classList.remove('fade-out');
}, 500);
}

View File

@@ -20,6 +20,12 @@
</nav>
</header>
<!-- Tip Banner -->
<div id="tip-banner" class="tip-banner">
<span class="tip-icon">💡</span>
<span id="tip-text" class="tip-text"></span>
</div>
<!-- Log Your Steps Tab -->
<div id="log-tab" class="tab-content active">
<div class="two-column-layout">
@@ -96,16 +102,19 @@
<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">
<div class="import-section" style="display: none;">
<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">
<div style="margin: 10px 0;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="monster-overwrite" style="cursor: pointer;">
<span style="color: #ff7700;">Overwrite all existing monsters (deletes all current monsters and catches)</span>
</label>
</div>
<button id="import-monsters" class="btn-secondary">Import Monsters</button>
<div id="monster-import-message" class="message"></div>
</div>

View File

@@ -39,6 +39,40 @@ header h1 {
letter-spacing: 2px;
}
/* Tip Banner */
.tip-banner {
background: linear-gradient(135deg, #8a2be2 0%, #ff7700 100%);
padding: 15px 30px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border-bottom: 2px solid #ff7700;
box-shadow: 0 4px 15px rgba(138, 43, 226, 0.3);
min-height: 60px;
}
.tip-icon {
font-size: 1.5rem;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.tip-text {
color: white;
font-size: 1.1rem;
font-weight: 500;
line-height: 1.4;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
opacity: 1;
transition: opacity 0.5s ease-in-out;
}
.tip-text.fade-out {
opacity: 0;
}
.header-logo {
max-width: 600px;
width: 100%;
@@ -720,6 +754,19 @@ nav {
padding: 20px;
}
.tip-banner {
padding: 12px 20px;
min-height: 50px;
}
.tip-text {
font-size: 0.95rem;
}
.tip-icon {
font-size: 1.2rem;
}
.stats-grid {
grid-template-columns: 1fr;
}

View File

@@ -55,18 +55,25 @@ function initializeDatabase() {
)
`);
// Monster catches table
// Monster catches view - derived from actual step data
// Drop the old table/view if it exists and recreate as a view
db.run(`DROP VIEW IF EXISTS monster_catches`);
db.run(`DROP TABLE IF EXISTS monster_catches`);
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 VIEW IF NOT EXISTS monster_catches AS
SELECT
dm.id as monster_id,
dm.date,
t.id as team_id,
t.name as team_name,
COALESCE(SUM(ds.steps), 0) as final_steps,
dm.step_goal
FROM daily_monsters dm
CROSS JOIN 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 = dm.date
GROUP BY dm.id, dm.date, t.id, t.name, dm.step_goal
HAVING final_steps >= dm.step_goal
`);
// Create indexes for better query performance
@@ -74,8 +81,6 @@ function initializeDatabase() {
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');
});

View File

@@ -352,54 +352,17 @@ 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
// For each monster, get catches from the view
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,
team_id,
team_name,
final_steps
FROM monster_catches
WHERE monster_id = ?
ORDER BY final_steps DESC
`, [monster.id]);
return {
@@ -430,7 +393,7 @@ app.get('/api/monsters/:date', async (req, res) => {
// 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}]
const { data, overwrite } = 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' });
@@ -438,8 +401,16 @@ app.post('/api/monsters/import', async (req, res) => {
let monstersCreated = 0;
let monstersUpdated = 0;
let monstersDeleted = 0;
let errors = [];
// If overwrite is true, delete all existing monsters first
// Note: monster_catches is now a view, so no need to delete from it
if (overwrite === true) {
const deleteMonstersResult = await runQuery('DELETE FROM daily_monsters');
monstersDeleted = deleteMonstersResult.changes || 0;
}
for (let i = 0; i < data.length; i++) {
const row = data[i];
@@ -449,38 +420,54 @@ app.post('/api/monsters/import', async (req, res) => {
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
if (overwrite) {
// When overwriting, always insert (since we deleted everything)
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++;
} else {
// 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({
const result = {
success: true,
monstersCreated,
monstersUpdated,
errors: errors.length > 0 ? errors : null
});
};
if (overwrite) {
result.monstersDeleted = monstersDeleted;
} else {
result.monstersUpdated = monstersUpdated;
}
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -500,12 +487,13 @@ app.get('/api/monsters/:date/catches', async (req, res) => {
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,
team_id,
team_name,
final_steps
FROM monster_catches
WHERE monster_id = ?
ORDER BY final_steps DESC
`, [monster.id]);
res.json(catches);
@@ -534,6 +522,7 @@ app.get('/api/monsters/status/today', async (req, res) => {
}
// Get team progress for yesterday's date
// The view automatically calculates which teams caught it
const progress = await allQuery(`
SELECT
t.id as team_id,
@@ -547,25 +536,6 @@ app.get('/api/monsters/status/today', async (req, res) => {
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 });

BIN
step_competition.db Normal file

Binary file not shown.