feat: Add mobile-first frontend for training tracker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan Novak
2026-01-19 19:50:33 +01:00
parent 49fe79d2dc
commit e048b59ede
4 changed files with 1592 additions and 0 deletions

718
frontend/js/app.js Normal file
View File

@@ -0,0 +1,718 @@
// Training Tracker App
const app = {
currentView: 'dashboard',
exercises: [],
plans: [],
sessions: [],
currentSession: null,
currentPlan: null,
exerciseFilter: 'all',
// Initialize the app
async init() {
this.setupNavigation();
this.setupForms();
this.setupTabs();
await this.loadData();
this.render();
},
// Setup navigation
setupNavigation() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
const view = item.dataset.view;
this.navigateTo(view);
});
});
},
// Navigate to a view
navigateTo(viewName) {
// Hide all views
document.querySelectorAll('.view').forEach(view => {
view.classList.remove('active');
});
// Show target view
const targetView = document.getElementById(`view-${viewName}`);
if (targetView) {
targetView.classList.add('active');
}
// Update nav items
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.view === viewName);
});
this.currentView = viewName;
this.render();
},
// Setup form handlers
setupForms() {
// Exercise form
document.getElementById('exercise-form').addEventListener('submit', async (e) => {
e.preventDefault();
await this.saveExercise();
});
// Plan form
document.getElementById('plan-form').addEventListener('submit', async (e) => {
e.preventDefault();
await this.savePlan();
});
// Session form
document.getElementById('session-form').addEventListener('submit', async (e) => {
e.preventDefault();
await this.saveSession();
});
// Entry form
document.getElementById('entry-form').addEventListener('submit', async (e) => {
e.preventDefault();
await this.saveEntry();
});
// Exercise select change (for entry form)
document.getElementById('entry-exercise').addEventListener('change', (e) => {
this.updateEntryFields(e.target.value);
});
},
// Setup tabs
setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const filter = tab.dataset.filter;
this.exerciseFilter = filter;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this.renderExercises();
});
});
},
// Load all data
async loadData() {
try {
[this.exercises, this.plans, this.sessions] = await Promise.all([
api.getExercises(),
api.getPlans(),
api.getSessions(),
]);
} catch (error) {
console.error('Failed to load data:', error);
}
},
// Render current view
render() {
switch (this.currentView) {
case 'dashboard':
this.renderDashboard();
break;
case 'sessions':
this.renderSessions();
break;
case 'exercises':
this.renderExercises();
break;
case 'plans':
this.renderPlans();
break;
}
},
// Render dashboard
renderDashboard() {
const container = document.getElementById('recent-sessions');
const recentSessions = this.sessions.slice(0, 5);
if (recentSessions.length === 0) {
container.innerHTML = '<p class="empty-state">No sessions yet. Start your first workout!</p>';
return;
}
container.innerHTML = recentSessions.map(session => this.renderSessionCard(session)).join('');
},
// Render sessions list
renderSessions() {
const container = document.getElementById('sessions-list');
if (this.sessions.length === 0) {
container.innerHTML = '<p class="empty-state">No sessions yet</p>';
return;
}
container.innerHTML = this.sessions.map(session => this.renderSessionCard(session)).join('');
},
// Render session card
renderSessionCard(session) {
const date = new Date(session.date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const planName = session.plan ? session.plan.name : 'No plan';
const entryCount = session.entries ? session.entries.length : 0;
return `
<div class="card" onclick="app.viewSession(${session.id})">
<div class="card-header">
<div>
<div class="card-title">${date}</div>
<div class="card-subtitle">${planName}</div>
</div>
<span class="badge badge-strength">${entryCount} exercises</span>
</div>
${session.notes ? `<div class="card-body">${session.notes}</div>` : ''}
</div>
`;
},
// View session details
async viewSession(id) {
try {
this.currentSession = await api.getSession(id);
this.renderSessionDetail();
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById('view-session-detail').classList.add('active');
} catch (error) {
alert('Failed to load session');
}
},
// Render session detail
renderSessionDetail() {
const session = this.currentSession;
if (!session) return;
const date = new Date(session.date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
document.getElementById('session-detail-title').textContent = date;
let html = '';
if (session.plan) {
html += `<p class="card-subtitle">Plan: ${session.plan.name}</p>`;
}
if (session.notes) {
html += `<p class="mt-2">${session.notes}</p>`;
}
html += '<div class="section"><h3>Exercises</h3><div class="card-list">';
if (session.entries && session.entries.length > 0) {
session.entries.forEach(entry => {
const exercise = entry.exercise || {};
let details = '';
if (exercise.type === 'cardio') {
const parts = [];
if (entry.duration) parts.push(`${Math.floor(entry.duration / 60)} min`);
if (entry.distance) parts.push(`${entry.distance} km`);
if (entry.heart_rate) parts.push(`${entry.heart_rate} bpm`);
details = parts.join(' | ');
} else {
const parts = [];
if (entry.weight) parts.push(`${entry.weight} kg`);
if (entry.sets_completed) parts.push(`${entry.sets_completed} sets`);
if (entry.reps) parts.push(`${entry.reps} reps`);
details = parts.join(' x ');
}
html += `
<div class="session-entry">
<div class="entry-info">
<div class="entry-name">${exercise.name || 'Exercise'}</div>
<div class="entry-details">${details}</div>
${entry.notes ? `<div class="entry-details">${entry.notes}</div>` : ''}
</div>
<span class="badge ${exercise.type === 'cardio' ? 'badge-cardio' : 'badge-strength'}">${exercise.type || 'strength'}</span>
</div>
`;
});
} else {
html += '<p class="empty-state">No exercises logged</p>';
}
html += '</div></div>';
html += `
<div class="card-actions mt-4">
<button class="btn btn-danger" onclick="app.deleteSession(${session.id})">Delete Session</button>
</div>
`;
document.getElementById('session-detail-content').innerHTML = html;
},
// Start new session
startNewSession() {
this.currentSession = null;
document.getElementById('session-form').reset();
document.getElementById('session-date').value = new Date().toISOString().slice(0, 16);
document.getElementById('session-entries-section').classList.add('hidden');
document.getElementById('session-entries').innerHTML = '';
// Populate plans dropdown
const planSelect = document.getElementById('session-plan');
planSelect.innerHTML = '<option value="">No plan</option>' +
this.plans.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById('view-log-session').classList.add('active');
},
// Save session
async saveSession() {
const date = document.getElementById('session-date').value;
const planId = document.getElementById('session-plan').value;
const notes = document.getElementById('session-notes').value;
const data = {
date: new Date(date).toISOString(),
notes: notes,
};
if (planId) {
data.plan_id = parseInt(planId);
}
try {
if (this.currentSession) {
this.currentSession = await api.updateSession(this.currentSession.id, data);
} else {
this.currentSession = await api.createSession(data);
}
// Show entries section
document.getElementById('session-entries-section').classList.remove('hidden');
this.renderSessionEntries();
// Reload sessions
this.sessions = await api.getSessions();
} catch (error) {
alert('Failed to save session');
}
},
// Render session entries
renderSessionEntries() {
const container = document.getElementById('session-entries');
const entries = this.currentSession?.entries || [];
if (entries.length === 0) {
container.innerHTML = '<p class="empty-state">No exercises yet. Add your first one!</p>';
return;
}
container.innerHTML = entries.map(entry => {
const exercise = entry.exercise || {};
let details = '';
if (exercise.type === 'cardio') {
const parts = [];
if (entry.duration) parts.push(`${Math.floor(entry.duration / 60)} min`);
if (entry.distance) parts.push(`${entry.distance} km`);
details = parts.join(' | ');
} else {
const parts = [];
if (entry.weight) parts.push(`${entry.weight} kg`);
if (entry.sets_completed) parts.push(`${entry.sets_completed} sets`);
if (entry.reps) parts.push(`${entry.reps} reps`);
details = parts.join(' x ');
}
return `
<div class="session-entry">
<div class="entry-info">
<div class="entry-name">${exercise.name || 'Exercise'}</div>
<div class="entry-details">${details}</div>
</div>
</div>
`;
}).join('');
},
// Show add entry form
showAddEntryForm() {
const select = document.getElementById('entry-exercise');
select.innerHTML = '<option value="">Select exercise</option>' +
this.exercises.map(e => `<option value="${e.id}" data-type="${e.type}">${e.name}</option>`).join('');
document.getElementById('entry-form').reset();
document.getElementById('strength-fields').classList.remove('hidden');
document.getElementById('cardio-fields').classList.add('hidden');
document.getElementById('add-entry-modal').classList.remove('hidden');
},
// Hide add entry form
hideAddEntryForm() {
document.getElementById('add-entry-modal').classList.add('hidden');
},
// Update entry fields based on exercise type
updateEntryFields(exerciseId) {
const exercise = this.exercises.find(e => e.id === parseInt(exerciseId));
const isCardio = exercise && exercise.type === 'cardio';
document.getElementById('strength-fields').classList.toggle('hidden', isCardio);
document.getElementById('cardio-fields').classList.toggle('hidden', !isCardio);
},
// Save entry
async saveEntry() {
if (!this.currentSession) return;
const exerciseId = parseInt(document.getElementById('entry-exercise').value);
const exercise = this.exercises.find(e => e.id === exerciseId);
const data = {
exercise_id: exerciseId,
notes: document.getElementById('entry-notes').value,
};
if (exercise && exercise.type === 'cardio') {
const duration = parseInt(document.getElementById('entry-duration').value);
if (duration) data.duration = duration * 60; // convert to seconds
const distance = parseFloat(document.getElementById('entry-distance').value);
if (distance) data.distance = distance;
const hr = parseInt(document.getElementById('entry-hr').value);
if (hr) data.heart_rate = hr;
} else {
const weight = parseFloat(document.getElementById('entry-weight').value);
if (weight) data.weight = weight;
const sets = parseInt(document.getElementById('entry-sets').value);
if (sets) data.sets_completed = sets;
const reps = parseInt(document.getElementById('entry-reps').value);
if (reps) data.reps = reps;
}
try {
await api.addSessionEntry(this.currentSession.id, data);
this.currentSession = await api.getSession(this.currentSession.id);
this.renderSessionEntries();
this.hideAddEntryForm();
} catch (error) {
alert('Failed to add entry');
}
},
// Delete session
async deleteSession(id) {
if (!confirm('Are you sure you want to delete this session?')) return;
try {
await api.deleteSession(id);
this.sessions = await api.getSessions();
this.navigateTo('sessions');
} catch (error) {
alert('Failed to delete session');
}
},
// Render exercises list
renderExercises() {
const container = document.getElementById('exercises-list');
let filtered = this.exercises;
if (this.exerciseFilter !== 'all') {
filtered = this.exercises.filter(e => e.type === this.exerciseFilter);
}
if (filtered.length === 0) {
container.innerHTML = '<p class="empty-state">No exercises yet</p>';
return;
}
container.innerHTML = filtered.map(exercise => `
<div class="card">
<div class="card-header">
<div>
<div class="card-title">${exercise.name}</div>
${exercise.muscle_group ? `<div class="card-subtitle">${exercise.muscle_group}</div>` : ''}
</div>
<span class="badge ${exercise.type === 'cardio' ? 'badge-cardio' : 'badge-strength'}">${exercise.type}</span>
</div>
${exercise.description ? `<div class="card-body">${exercise.description}</div>` : ''}
<div class="card-actions">
<button class="btn btn-secondary btn-small" onclick="app.editExercise(${exercise.id})">Edit</button>
<button class="btn btn-danger btn-small" onclick="app.deleteExercise(${exercise.id})">Delete</button>
</div>
</div>
`).join('');
},
// Show exercise form
showExerciseForm(exercise = null) {
document.getElementById('exercise-modal-title').textContent = exercise ? 'Edit Exercise' : 'Add Exercise';
document.getElementById('exercise-id').value = exercise ? exercise.id : '';
document.getElementById('exercise-name').value = exercise ? exercise.name : '';
document.getElementById('exercise-type').value = exercise ? exercise.type : 'strength';
document.getElementById('exercise-muscle').value = exercise ? exercise.muscle_group : '';
document.getElementById('exercise-description').value = exercise ? exercise.description : '';
document.getElementById('exercise-modal').classList.remove('hidden');
},
// Hide exercise form
hideExerciseForm() {
document.getElementById('exercise-modal').classList.add('hidden');
},
// Edit exercise
async editExercise(id) {
const exercise = this.exercises.find(e => e.id === id);
if (exercise) {
this.showExerciseForm(exercise);
}
},
// Save exercise
async saveExercise() {
const id = document.getElementById('exercise-id').value;
const data = {
name: document.getElementById('exercise-name').value,
type: document.getElementById('exercise-type').value,
muscle_group: document.getElementById('exercise-muscle').value,
description: document.getElementById('exercise-description').value,
};
try {
if (id) {
await api.updateExercise(id, data);
} else {
await api.createExercise(data);
}
this.exercises = await api.getExercises();
this.hideExerciseForm();
this.renderExercises();
} catch (error) {
alert('Failed to save exercise');
}
},
// Delete exercise
async deleteExercise(id) {
if (!confirm('Are you sure you want to delete this exercise?')) return;
try {
await api.deleteExercise(id);
this.exercises = await api.getExercises();
this.renderExercises();
} catch (error) {
alert('Failed to delete exercise');
}
},
// Render plans list
renderPlans() {
const container = document.getElementById('plans-list');
if (this.plans.length === 0) {
container.innerHTML = '<p class="empty-state">No training plans yet</p>';
return;
}
container.innerHTML = this.plans.map(plan => `
<div class="card" onclick="app.viewPlan(${plan.id})">
<div class="card-header">
<div>
<div class="card-title">${plan.name}</div>
${plan.description ? `<div class="card-subtitle">${plan.description}</div>` : ''}
</div>
</div>
<div class="card-actions">
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation(); app.editPlan(${plan.id})">Edit</button>
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); app.deletePlan(${plan.id})">Delete</button>
</div>
</div>
`).join('');
},
// View plan detail
async viewPlan(id) {
try {
this.currentPlan = await api.getPlan(id);
this.renderPlanDetail();
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById('view-plan-detail').classList.add('active');
} catch (error) {
alert('Failed to load plan');
}
},
// Render plan detail
renderPlanDetail() {
const plan = this.currentPlan;
if (!plan) return;
document.getElementById('plan-detail-title').textContent = plan.name;
let html = '';
if (plan.description) {
html += `<p class="card-subtitle">${plan.description}</p>`;
}
html += '<div class="section"><h3>Exercises</h3><div class="card-list">';
if (plan.exercises && plan.exercises.length > 0) {
plan.exercises.forEach((pe, index) => {
const exercise = pe.exercise || {};
let details = [];
if (pe.sets) details.push(`${pe.sets} sets`);
if (pe.reps) details.push(`${pe.reps} reps`);
if (pe.duration) details.push(`${Math.floor(pe.duration / 60)} min`);
html += `
<div class="session-entry">
<div class="entry-info">
<div class="entry-name">${index + 1}. ${exercise.name || 'Exercise'}</div>
<div class="entry-details">${details.join(' x ') || 'No targets set'}</div>
</div>
<span class="badge ${exercise.type === 'cardio' ? 'badge-cardio' : 'badge-strength'}">${exercise.type || 'strength'}</span>
</div>
`;
});
} else {
html += '<p class="empty-state">No exercises in this plan</p>';
}
html += '</div></div>';
html += `
<div class="card-actions mt-4">
<button class="btn btn-primary" onclick="app.startSessionFromPlan(${plan.id})">Start Session</button>
</div>
`;
document.getElementById('plan-detail-content').innerHTML = html;
},
// Start session from plan
startSessionFromPlan(planId) {
this.startNewSession();
document.getElementById('session-plan').value = planId;
},
// Show plan form
showPlanForm(plan = null) {
document.getElementById('plan-modal-title').textContent = plan ? 'Edit Plan' : 'Add Training Plan';
document.getElementById('plan-id').value = plan ? plan.id : '';
document.getElementById('plan-name').value = plan ? plan.name : '';
document.getElementById('plan-description').value = plan ? plan.description : '';
const exercisesList = document.getElementById('plan-exercises-list');
exercisesList.innerHTML = '';
if (plan && plan.exercises) {
plan.exercises.forEach(pe => this.addPlanExerciseRow(pe));
}
document.getElementById('plan-modal').classList.remove('hidden');
},
// Hide plan form
hidePlanForm() {
document.getElementById('plan-modal').classList.add('hidden');
},
// Add plan exercise row
addPlanExercise() {
this.addPlanExerciseRow();
},
addPlanExerciseRow(data = null) {
const container = document.getElementById('plan-exercises-list');
const index = container.children.length;
const div = document.createElement('div');
div.className = 'plan-exercise-item';
div.innerHTML = `
<select name="exercise_id" required>
<option value="">Select</option>
${this.exercises.map(e => `<option value="${e.id}" ${data && data.exercise_id === e.id ? 'selected' : ''}>${e.name}</option>`).join('')}
</select>
<input type="number" name="sets" placeholder="Sets" min="0" value="${data?.sets || ''}">
<input type="number" name="reps" placeholder="Reps" min="0" value="${data?.reps || ''}">
<button type="button" class="btn btn-danger btn-small" onclick="this.parentElement.remove()">X</button>
`;
container.appendChild(div);
},
// Edit plan
async editPlan(id) {
try {
const plan = await api.getPlan(id);
this.showPlanForm(plan);
} catch (error) {
alert('Failed to load plan');
}
},
// Save plan
async savePlan() {
const id = document.getElementById('plan-id').value;
const exercises = [];
document.querySelectorAll('#plan-exercises-list .plan-exercise-item').forEach((item, index) => {
const exerciseId = item.querySelector('[name="exercise_id"]').value;
const sets = item.querySelector('[name="sets"]').value;
const reps = item.querySelector('[name="reps"]').value;
if (exerciseId) {
exercises.push({
exercise_id: parseInt(exerciseId),
sets: sets ? parseInt(sets) : 0,
reps: reps ? parseInt(reps) : 0,
order: index,
});
}
});
const data = {
name: document.getElementById('plan-name').value,
description: document.getElementById('plan-description').value,
exercises: exercises,
};
try {
if (id) {
await api.updatePlan(id, data);
} else {
await api.createPlan(data);
}
this.plans = await api.getPlans();
this.hidePlanForm();
this.renderPlans();
} catch (error) {
alert('Failed to save plan');
}
},
// Delete plan
async deletePlan(id) {
if (!confirm('Are you sure you want to delete this plan?')) return;
try {
await api.deletePlan(id);
this.plans = await api.getPlans();
this.renderPlans();
} catch (error) {
alert('Failed to delete plan');
}
},
};
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
app.init();
});