feat: Add mobile-first frontend for training tracker
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
476
frontend/css/styles.css
Normal file
476
frontend/css/styles.css
Normal 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
259
frontend/index.html
Normal 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">🏠</span>
|
||||||
|
<span class="nav-label">Home</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-view="sessions">
|
||||||
|
<span class="nav-icon">📝</span>
|
||||||
|
<span class="nav-label">Sessions</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-view="exercises">
|
||||||
|
<span class="nav-icon">💪</span>
|
||||||
|
<span class="nav-label">Exercises</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" data-view="plans">
|
||||||
|
<span class="nav-icon">📋</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
139
frontend/js/api.js
Normal 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
718
frontend/js/app.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user