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

476
frontend/css/styles.css Normal file
View File

@@ -0,0 +1,476 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4f46e5;
--primary-dark: #4338ca;
--secondary-color: #6b7280;
--success-color: #10b981;
--danger-color: #ef4444;
--background-color: #f3f4f6;
--card-background: #ffffff;
--text-color: #1f2937;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--radius: 12px;
--radius-sm: 8px;
--nav-height: 64px;
--header-height: 56px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
background: var(--card-background);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 0 16px;
}
.header-title {
font-size: 18px;
font-weight: 600;
}
/* Main Content */
.main-content {
flex: 1;
padding: calc(var(--header-height) + 16px) 16px calc(var(--nav-height) + 16px);
max-width: 600px;
margin: 0 auto;
width: 100%;
}
/* Views */
.view {
display: none;
}
.view.active {
display: block;
}
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.view-header h2 {
font-size: 24px;
font-weight: 600;
}
/* Sections */
.section {
margin-top: 24px;
}
.section h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
}
.section h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--border-color);
color: var(--text-color);
}
.btn-secondary:hover {
background: #d1d5db;
}
.btn-text {
background: transparent;
color: var(--primary-color);
padding: 8px 12px;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-large {
width: 100%;
padding: 14px 24px;
font-size: 16px;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
/* Quick Actions */
.quick-actions {
margin-bottom: 24px;
}
/* Cards */
.card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
background: var(--card-background);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.card-subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.card-body {
margin-top: 8px;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.badge-strength {
background: #dbeafe;
color: #1e40af;
}
.badge-cardio {
background: #fef3c7;
color: #92400e;
}
/* Empty State */
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 32px 16px;
}
/* Forms */
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 16px;
background: var(--card-background);
color: var(--text-color);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 4px;
}
.tab {
padding: 8px 16px;
border: none;
background: var(--border-color);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tab.active {
background: var(--primary-color);
color: white;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 200;
}
.modal.hidden {
display: none;
}
.modal-content {
background: var(--card-background);
border-radius: var(--radius) var(--radius) 0 0;
padding: 20px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
/* Bottom Navigation */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--nav-height);
background: var(--card-background);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 16px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
}
.nav-item.active {
color: var(--primary-color);
}
.nav-icon {
font-size: 20px;
}
.nav-label {
font-size: 11px;
font-weight: 500;
}
/* Plan Exercise Item */
.plan-exercise-item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 12px;
background: var(--background-color);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.plan-exercise-item select {
flex: 1;
}
.plan-exercise-item input {
width: 70px;
}
.plan-exercise-item .btn {
padding: 8px;
}
/* Session Entry */
.session-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--background-color);
border-radius: var(--radius-sm);
}
.entry-info {
flex: 1;
}
.entry-name {
font-weight: 500;
}
.entry-details {
font-size: 13px;
color: var(--text-secondary);
}
/* Utilities */
.hidden {
display: none !important;
}
.text-center {
text-align: center;
}
.mt-2 {
margin-top: 8px;
}
.mt-4 {
margin-top: 16px;
}
/* Responsive adjustments for larger screens */
@media (min-width: 640px) {
.modal-content {
border-radius: var(--radius);
margin-bottom: 20px;
}
.modal {
align-items: center;
}
}
@media (min-width: 768px) {
:root {
--header-height: 64px;
}
.header-title {
font-size: 20px;
}
.main-content {
padding: calc(var(--header-height) + 24px) 24px calc(var(--nav-height) + 24px);
}
}

259
frontend/index.html Normal file
View File

@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Training Tracker</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<h1 class="header-title">Training Tracker</h1>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Dashboard View -->
<section id="view-dashboard" class="view active">
<h2>Dashboard</h2>
<div class="quick-actions">
<button class="btn btn-primary btn-large" onclick="app.startNewSession()">
Start New Session
</button>
</div>
<div class="section">
<h3>Recent Sessions</h3>
<div id="recent-sessions" class="card-list">
<p class="empty-state">No sessions yet</p>
</div>
</div>
</section>
<!-- Sessions View -->
<section id="view-sessions" class="view">
<div class="view-header">
<h2>Sessions</h2>
<button class="btn btn-primary" onclick="app.startNewSession()">New</button>
</div>
<div id="sessions-list" class="card-list">
<p class="empty-state">No sessions yet</p>
</div>
</section>
<!-- Session Detail View -->
<section id="view-session-detail" class="view">
<div class="view-header">
<button class="btn btn-text" onclick="app.navigateTo('sessions')">Back</button>
<h2 id="session-detail-title">Session</h2>
</div>
<div id="session-detail-content"></div>
</section>
<!-- Log Session View -->
<section id="view-log-session" class="view">
<div class="view-header">
<button class="btn btn-text" onclick="app.navigateTo('sessions')">Cancel</button>
<h2>Log Session</h2>
</div>
<form id="session-form" class="form">
<div class="form-group">
<label for="session-date">Date</label>
<input type="datetime-local" id="session-date" required>
</div>
<div class="form-group">
<label for="session-plan">Training Plan (Optional)</label>
<select id="session-plan">
<option value="">No plan</option>
</select>
</div>
<div class="form-group">
<label for="session-notes">Notes</label>
<textarea id="session-notes" rows="3" placeholder="How did it go?"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-large">Create Session</button>
</form>
<div id="session-entries-section" class="section hidden">
<h3>Exercises</h3>
<div id="session-entries" class="card-list"></div>
<button class="btn btn-secondary" onclick="app.showAddEntryForm()">Add Exercise</button>
</div>
</section>
<!-- Add Entry Modal -->
<div id="add-entry-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add Exercise Entry</h3>
<button class="btn btn-text" onclick="app.hideAddEntryForm()">Cancel</button>
</div>
<form id="entry-form" class="form">
<div class="form-group">
<label for="entry-exercise">Exercise</label>
<select id="entry-exercise" required></select>
</div>
<div id="strength-fields">
<div class="form-row">
<div class="form-group">
<label for="entry-weight">Weight (kg)</label>
<input type="number" id="entry-weight" step="0.5" min="0">
</div>
<div class="form-group">
<label for="entry-sets">Sets</label>
<input type="number" id="entry-sets" min="0">
</div>
<div class="form-group">
<label for="entry-reps">Reps</label>
<input type="number" id="entry-reps" min="0">
</div>
</div>
</div>
<div id="cardio-fields" class="hidden">
<div class="form-row">
<div class="form-group">
<label for="entry-duration">Duration (min)</label>
<input type="number" id="entry-duration" min="0">
</div>
<div class="form-group">
<label for="entry-distance">Distance (km)</label>
<input type="number" id="entry-distance" step="0.1" min="0">
</div>
</div>
<div class="form-group">
<label for="entry-hr">Avg Heart Rate</label>
<input type="number" id="entry-hr" min="0">
</div>
</div>
<div class="form-group">
<label for="entry-notes">Notes</label>
<input type="text" id="entry-notes" placeholder="Optional notes">
</div>
<button type="submit" class="btn btn-primary btn-large">Add Entry</button>
</form>
</div>
</div>
<!-- Exercises View -->
<section id="view-exercises" class="view">
<div class="view-header">
<h2>Exercises</h2>
<button class="btn btn-primary" onclick="app.showExerciseForm()">Add</button>
</div>
<div class="tabs">
<button class="tab active" data-filter="all">All</button>
<button class="tab" data-filter="strength">Strength</button>
<button class="tab" data-filter="cardio">Cardio</button>
</div>
<div id="exercises-list" class="card-list">
<p class="empty-state">No exercises yet</p>
</div>
</section>
<!-- Exercise Form Modal -->
<div id="exercise-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="exercise-modal-title">Add Exercise</h3>
<button class="btn btn-text" onclick="app.hideExerciseForm()">Cancel</button>
</div>
<form id="exercise-form" class="form">
<input type="hidden" id="exercise-id">
<div class="form-group">
<label for="exercise-name">Name</label>
<input type="text" id="exercise-name" required placeholder="e.g., Bench Press">
</div>
<div class="form-group">
<label for="exercise-type">Type</label>
<select id="exercise-type" required>
<option value="strength">Strength</option>
<option value="cardio">Cardio</option>
</select>
</div>
<div class="form-group">
<label for="exercise-muscle">Muscle Group</label>
<input type="text" id="exercise-muscle" placeholder="e.g., Chest">
</div>
<div class="form-group">
<label for="exercise-description">Description</label>
<textarea id="exercise-description" rows="2" placeholder="Optional description"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-large">Save Exercise</button>
</form>
</div>
</div>
<!-- Plans View -->
<section id="view-plans" class="view">
<div class="view-header">
<h2>Training Plans</h2>
<button class="btn btn-primary" onclick="app.showPlanForm()">Add</button>
</div>
<div id="plans-list" class="card-list">
<p class="empty-state">No plans yet</p>
</div>
</section>
<!-- Plan Form Modal -->
<div id="plan-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="plan-modal-title">Add Training Plan</h3>
<button class="btn btn-text" onclick="app.hidePlanForm()">Cancel</button>
</div>
<form id="plan-form" class="form">
<input type="hidden" id="plan-id">
<div class="form-group">
<label for="plan-name">Name</label>
<input type="text" id="plan-name" required placeholder="e.g., Push Day">
</div>
<div class="form-group">
<label for="plan-description">Description</label>
<textarea id="plan-description" rows="2" placeholder="Optional description"></textarea>
</div>
<div class="section">
<h4>Exercises</h4>
<div id="plan-exercises-list"></div>
<button type="button" class="btn btn-secondary" onclick="app.addPlanExercise()">Add Exercise</button>
</div>
<button type="submit" class="btn btn-primary btn-large">Save Plan</button>
</form>
</div>
</div>
<!-- Plan Detail View -->
<section id="view-plan-detail" class="view">
<div class="view-header">
<button class="btn btn-text" onclick="app.navigateTo('plans')">Back</button>
<h2 id="plan-detail-title">Plan</h2>
</div>
<div id="plan-detail-content"></div>
</section>
</main>
<!-- Bottom Navigation -->
<nav class="bottom-nav">
<button class="nav-item active" data-view="dashboard">
<span class="nav-icon">&#127968;</span>
<span class="nav-label">Home</span>
</button>
<button class="nav-item" data-view="sessions">
<span class="nav-icon">&#128221;</span>
<span class="nav-label">Sessions</span>
</button>
<button class="nav-item" data-view="exercises">
<span class="nav-icon">&#128170;</span>
<span class="nav-label">Exercises</span>
</button>
<button class="nav-item" data-view="plans">
<span class="nav-icon">&#128203;</span>
<span class="nav-label">Plans</span>
</button>
</nav>
</div>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
</body>
</html>

139
frontend/js/api.js Normal file
View File

@@ -0,0 +1,139 @@
// API Client for Training Tracker
const API_BASE_URL = 'http://localhost:8080/api';
class ApiClient {
constructor(baseUrl = API_BASE_URL) {
this.baseUrl = baseUrl;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
}
try {
const response = await fetch(url, config);
if (response.status === 204) {
return null;
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'API request failed');
}
return data;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
// Exercises
async getExercises() {
return this.request('/exercises');
}
async getExercise(id) {
return this.request(`/exercises/${id}`);
}
async createExercise(data) {
return this.request('/exercises', {
method: 'POST',
body: data,
});
}
async updateExercise(id, data) {
return this.request(`/exercises/${id}`, {
method: 'PUT',
body: data,
});
}
async deleteExercise(id) {
return this.request(`/exercises/${id}`, {
method: 'DELETE',
});
}
// Training Plans
async getPlans() {
return this.request('/plans');
}
async getPlan(id) {
return this.request(`/plans/${id}`);
}
async createPlan(data) {
return this.request('/plans', {
method: 'POST',
body: data,
});
}
async updatePlan(id, data) {
return this.request(`/plans/${id}`, {
method: 'PUT',
body: data,
});
}
async deletePlan(id) {
return this.request(`/plans/${id}`, {
method: 'DELETE',
});
}
// Sessions
async getSessions() {
return this.request('/sessions');
}
async getSession(id) {
return this.request(`/sessions/${id}`);
}
async createSession(data) {
return this.request('/sessions', {
method: 'POST',
body: data,
});
}
async updateSession(id, data) {
return this.request(`/sessions/${id}`, {
method: 'PUT',
body: data,
});
}
async deleteSession(id) {
return this.request(`/sessions/${id}`, {
method: 'DELETE',
});
}
async addSessionEntry(sessionId, data) {
return this.request(`/sessions/${sessionId}/entries`, {
method: 'POST',
body: data,
});
}
}
const api = new ApiClient();

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();
});