feat: implement Phase 2 UI - Equipment and Training Zones

This commit is contained in:
Cipher Vance
2025-11-22 19:51:47 -06:00
parent 1f2eb1e836
commit 0c0e72991d
5 changed files with 992 additions and 57 deletions

View 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>