feat: implement Phase 2 UI - Equipment and Training Zones
This commit is contained in:
353
src/components/TrainingZones.vue
Normal file
353
src/components/TrainingZones.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="zones-page">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<h1>RideAware</h1>
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
||||
<RouterLink to="/profile">Profile</RouterLink>
|
||||
<button @click="handleLogout" class="btn-logout">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="zones-content">
|
||||
<h2>Training Zones</h2>
|
||||
<p class="subtitle">Your personalized training zones based on FTP and Max HR</p>
|
||||
|
||||
<div v-if="!profile.profile?.ftp && !profile.profile?.max_hr" class="empty-state">
|
||||
<p>Set your FTP and Max Heart Rate in your profile to see training zones</p>
|
||||
<RouterLink to="/profile" class="btn-primary">Go to Profile</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="zones-container">
|
||||
<!-- HR Zones -->
|
||||
<div v-if="profile.profile?.max_hr" class="zones-section">
|
||||
<h3>Heart Rate Zones</h3>
|
||||
<p class="zone-info">
|
||||
<strong>Max HR:</strong> {{ profile.profile.max_hr }} bpm |
|
||||
<strong>Resting HR:</strong> {{ profile.profile.resting_hr || '??' }} bpm
|
||||
</p>
|
||||
|
||||
<div class="zones-grid">
|
||||
<div v-for="(zone, index) in hrZones" :key="index" class="zone-card" :style="{ borderLeftColor: zone.color }">
|
||||
<h4>{{ zone.name }}</h4>
|
||||
<div class="zone-range">{{ zone.min }} - {{ zone.max }} bpm</div>
|
||||
<div class="zone-description">
|
||||
<span v-if="index === 0">Light recovery</span>
|
||||
<span v-else-if="index === 1">Endurance base</span>
|
||||
<span v-else-if="index === 2">Tempo work</span>
|
||||
<span v-else-if="index === 3">Lactate threshold</span>
|
||||
<span v-else-if="index === 4">Max effort</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Power Zones -->
|
||||
<div v-if="profile.profile?.ftp" class="zones-section">
|
||||
<h3>Power Zones (Watts)</h3>
|
||||
<p class="zone-info">
|
||||
<strong>FTP:</strong> {{ profile.profile.ftp }}W
|
||||
</p>
|
||||
|
||||
<div class="zones-grid">
|
||||
<div v-for="(zone, index) in powerZones" :key="index" class="zone-card" :style="{ borderLeftColor: zone.color }">
|
||||
<h4>{{ zone.name }}</h4>
|
||||
<div class="zone-range">{{ zone.min }} - {{ zone.max }}W</div>
|
||||
<div class="zone-percentage">
|
||||
{{ calculatePercentage(zone.min) }}% - {{ calculatePercentage(zone.max) }}% FTP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone Guides -->
|
||||
<div class="guides-section">
|
||||
<h3>Training Guide</h3>
|
||||
<div class="guide-cards">
|
||||
<div class="guide-card">
|
||||
<h4>Recovery Rides</h4>
|
||||
<p>Zone 1 - Low intensity recovery work. Focus on technique and breathing.</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h4>Endurance Builds Base</h4>
|
||||
<p>Zone 2 - Sustainable pace for building aerobic base. Should be able to hold conversation.</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h4>Tempo Improves Efficiency</h4>
|
||||
<p>Zone 3 - Comfortably hard. Improves lactate threshold and sustainable power.</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h4>Threshold Builds Power</h4>
|
||||
<p>Zone 4 - Hard efforts around your FTP. Key for improving functional threshold power.</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h4>VO2 Max Builds Capacity</h4>
|
||||
<p>Zone 5 - Max efforts. Improves aerobic capacity and power output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
|
||||
const profile = ref({ profile: null })
|
||||
const zones = ref({})
|
||||
|
||||
const hrZones = computed(() => {
|
||||
return zones.value.hr_zones ? [
|
||||
zones.value.hr_zones.zone_1,
|
||||
zones.value.hr_zones.zone_2,
|
||||
zones.value.hr_zones.zone_3,
|
||||
zones.value.hr_zones.zone_4,
|
||||
zones.value.hr_zones.zone_5,
|
||||
] : []
|
||||
})
|
||||
|
||||
const powerZones = computed(() => {
|
||||
return zones.value.power_zones ? [
|
||||
zones.value.power_zones.zone_1,
|
||||
zones.value.power_zones.zone_2,
|
||||
zones.value.power_zones.zone_3,
|
||||
zones.value.power_zones.zone_4,
|
||||
zones.value.power_zones.zone_5,
|
||||
zones.value.power_zones.zone_6,
|
||||
zones.value.power_zones.zone_7,
|
||||
] : []
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const profileRes = await api.get('/api/protected/profile')
|
||||
profile.value = profileRes.data
|
||||
|
||||
const zonesRes = await api.get('/api/protected/zones')
|
||||
zones.value = zonesRes.data
|
||||
} catch (error) {
|
||||
console.error('Failed to load zones:', error)
|
||||
}
|
||||
})
|
||||
|
||||
function calculatePercentage(watts) {
|
||||
return Math.round((watts / profile.value.profile.ftp) * 100)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zones-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: white;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-brand h1 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-menu a {
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.navbar-menu a:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.zones-content {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.zones-content h2 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: white;
|
||||
padding: 3rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.zones-container {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.zones-section {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.zones-section h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.zone-info {
|
||||
color: #555;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.zones-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.zone-card {
|
||||
background: linear-gradient(135deg, #f5f5f5 0%, #ffffff 100%);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-left: 5px solid #667eea;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.zone-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.zone-card h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.zone-range {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.zone-percentage {
|
||||
font-size: 0.85rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.zone-description {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.guides-section {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.guides-section h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.guide-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.guide-card h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.guide-card p {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user