Files
rideaware-ui/src/components/WorkoutCalendar.vue
2026-01-21 07:32:34 -06:00

1253 lines
35 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="calendar-page">
<ModernNavbar />
<div class="calendar-content">
<!-- Page Header -->
<div class="section-header">
<div>
<h1 class="section-title">Workout Calendar</h1>
<p class="section-description">Plan and track your training schedule</p>
</div>
<button @click="showAddWorkout = true" class="btn-modern btn-modern-primary btn-modern-large">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Workout
</button>
</div>
<!-- Month Navigation -->
<div class="month-navigation">
<button @click="previousMonth" class="btn-modern btn-modern-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Previous
</button>
<h2 class="current-month-display">{{ monthYear }}</h2>
<button @click="nextMonth" class="btn-modern btn-modern-secondary">
Next
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Add Workout Modal -->
<div v-if="showAddWorkout" class="modal-overlay" @click.self="showAddWorkout = false">
<div class="modal-content-modern">
<div class="modal-header-modern">
<h3 class="modal-title">Add New Workout</h3>
<button @click="showAddWorkout = false" class="btn-close-modern"></button>
</div>
<form @submit.prevent="handleAddWorkout" class="form-modern">
<!-- Title -->
<div class="form-group-modern">
<label class="form-label-modern">Workout Title *</label>
<input
v-model="newWorkout.title"
type="text"
class="form-input-modern"
placeholder="e.g., Morning Endurance Ride, Tempo Run, etc."
required
/>
<span class="form-helper-text">Give your workout a descriptive name</span>
</div>
<!-- Date and Type Row -->
<div class="form-row-modern">
<div class="form-group-modern">
<label class="form-label-modern">Scheduled Date *</label>
<input
v-model="newWorkout.scheduled_date"
type="date"
class="form-input-modern"
required
/>
<span class="form-helper-text">When do you plan to do this?</span>
</div>
<div class="form-group-modern">
<label class="form-label-modern">Workout Type</label>
<select v-model="newWorkout.type" class="form-input-modern">
<option value="">Select type</option>
<option v-for="type in workoutTypes" :key="type.id" :value="type.name">
{{ type.icon }} {{ type.name }}
</option>
</select>
<span class="form-helper-text">Choose activity type</span>
</div>
</div>
<!-- Duration -->
<div class="form-group-modern">
<label class="form-label-modern">Duration (minutes)</label>
<input
v-model.number="newWorkout.duration"
type="number"
class="form-input-modern"
step="1"
min="1"
max="999"
placeholder="60"
/>
<span class="form-helper-text">How long will this workout take?</span>
</div>
<!-- Description -->
<div class="form-group-modern">
<label class="form-label-modern">Description</label>
<textarea
v-model="newWorkout.description"
class="form-input-modern"
placeholder="Add workout notes, intervals, goals, or any other details..."
rows="4"
></textarea>
<span class="form-helper-text">Optional notes about your workout</span>
</div>
<!-- File Upload Section -->
<div class="form-group-modern">
<label class="form-label-modern">Import Workout File (Optional)</label>
<div class="info-box-info mb-sm">
<strong>Tip:</strong> Upload FIT or ZWO files from TrainerRoad, Zwift, or other platforms to automatically populate workout details and structure.
</div>
<div class="file-upload-area">
<input
@change="handleFileUpload"
type="file"
accept=".fit,.zwo"
id="workout-file"
class="file-input-hidden"
/>
<label for="workout-file" class="file-upload-label">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="17 8 12 3 7 8" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="3" x2="12" y2="15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="file-upload-text">
<strong>{{ uploadedFileName || 'Click to upload file' }}</strong>
<span v-if="!uploadedFileName">or drag and drop</span>
</div>
<span class="file-upload-hint">Supports FIT and ZWO formats</span>
</label>
</div>
<div v-if="fileParseError" class="form-error-text">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
{{ fileParseError }}
</div>
<div v-if="parsedFileData" class="file-parse-success">
<div class="parse-success-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
<strong>File parsed successfully!</strong>
</div>
<div class="parse-success-details">
<div v-if="parsedFileData.title" class="parse-detail">
<span class="parse-label">Title:</span>
<span class="parse-value">{{ parsedFileData.title }}</span>
</div>
<div v-if="parsedFileData.duration" class="parse-detail">
<span class="parse-label">Duration:</span>
<span class="parse-value">{{ formatDuration(parsedFileData.duration) }}</span>
</div>
<div v-if="parsedFileData.segments" class="parse-detail">
<span class="parse-label">Segments:</span>
<span class="parse-value">{{ parsedFileData.segments.length }}</span>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions-modern">
<button type="button" @click="showAddWorkout = false" class="btn-modern btn-modern-secondary">
Cancel
</button>
<button type="button" @click="resetForm" class="btn-modern btn-modern-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
Reset
</button>
<button type="submit" :disabled="loading" class="btn-modern btn-modern-primary">
<svg v-if="!loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
{{ loading ? 'Adding...' : 'Add Workout' }}
</button>
</div>
</form>
<div v-if="error" class="error-message-modern">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
{{ error }}
</div>
</div>
</div>
<!-- Calendar Grid -->
<div class="card-modern calendar-card">
<div class="calendar-grid">
<div class="calendar-header-row">
<div class="day-header">Sunday</div>
<div class="day-header">Monday</div>
<div class="day-header">Tuesday</div>
<div class="day-header">Wednesday</div>
<div class="day-header">Thursday</div>
<div class="day-header">Friday</div>
<div class="day-header">Saturday</div>
</div>
<div class="calendar-days-grid">
<div
v-for="day in calendarDays"
:key="day.date"
class="calendar-day-modern"
:class="{
'other-month': !day.isCurrentMonth,
'today': day.isToday,
'has-workouts': day.workouts.length > 0
}"
>
<div class="day-number-modern">
{{ day.day }}
<span v-if="day.isToday" class="today-badge">Today</span>
</div>
<div class="day-workouts-modern">
<div
v-for="workout in day.workouts"
:key="workout.id"
class="workout-badge-modern"
:style="{ borderLeftColor: getWorkoutColor(workout.type) }"
@click="selectWorkout(workout)"
>
<div class="workout-badge-header">
<span class="workout-badge-icon">{{ getWorkoutIcon(workout.type) }}</span>
<span class="workout-badge-title">{{ workout.title }}</span>
</div>
<div class="workout-badge-footer">
<span class="workout-badge-duration">{{ workout.duration }}min</span>
<span :class="['status-pill', `status-pill-${workout.status}`]">
{{ workout.status }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Workout Details Component -->
<WorkoutDetail
v-if="selectedWorkout"
:workout="selectedWorkout"
@close="selectedWorkout = null"
@updated="loadWorkouts"
@deleted="loadWorkouts"
/>
<!-- Upcoming Workouts List -->
<div class="card-modern">
<div class="card-modern-header">
<div>
<h3 class="card-modern-title">Upcoming Workouts</h3>
<p class="card-modern-subtitle">Your next 5 scheduled workouts</p>
</div>
</div>
<div v-if="upcomingWorkouts.length > 0" class="upcoming-workouts-table">
<table class="table-modern">
<thead>
<tr>
<th>Date</th>
<th>Workout</th>
<th>Type</th>
<th>Duration</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="workout in upcomingWorkouts.slice(0, 5)"
:key="workout.id"
class="workout-row"
@click="selectWorkout(workout)"
>
<td class="workout-date-cell">
<div class="date-display">
<span class="date-day">{{ formatDateDay(workout.scheduled_date) }}</span>
<span class="date-month">{{ formatDateMonth(workout.scheduled_date) }}</span>
</div>
</td>
<td>
<div class="workout-title-cell">
<span class="workout-icon">{{ getWorkoutIcon(workout.type) }}</span>
<strong>{{ workout.title }}</strong>
</div>
</td>
<td>
<span v-if="workout.type" class="workout-type-badge">{{ workout.type }}</span>
<span v-else class="text-secondary">-</span>
</td>
<td>
<span class="duration-display">{{ workout.duration }} min</span>
</td>
<td>
<span :class="['status-pill', `status-pill-${workout.status}`]">
{{ workout.status }}
</span>
</td>
<td>
<button class="btn-action" @click.stop="selectWorkout(workout)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state-modern">
<div class="empty-state-icon">📅</div>
<h3 class="empty-state-title">No Upcoming Workouts</h3>
<p class="empty-state-description">
You don't have any workouts scheduled yet. Click the "Add Workout" button to plan your first training session!
</p>
<button @click="showAddWorkout = true" class="btn-modern btn-modern-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Your First Workout
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import api from '@/services/api'
import ModernNavbar from '@/components/ModernNavbar.vue'
import WorkoutDetail from './WorkoutDetail.vue'
import { parseFITFile, parseZWOFile } from '@/utils/workoutFileParser'
const currentDate = ref(new Date())
const workouts = ref([])
const selectedWorkout = ref(null)
const showAddWorkout = ref(false)
const loading = ref(false)
const error = ref('')
const fileParseError = ref('')
const workoutTypes = ref([])
const uploadedFileName = ref('')
const parsedFileData = ref(null)
const newWorkout = ref({
title: '',
description: '',
type: '',
scheduled_date: new Date().toISOString().split('T')[0],
duration: 60,
workout_data: null,
file_type: null,
})
const monthYear = computed(() => {
return currentDate.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
})
const calendarDays = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startDate = new Date(firstDay)
startDate.setDate(startDate.getDate() - firstDay.getDay())
const days = []
let currentDay = new Date(startDate)
while (currentDay <= lastDay || currentDay.getDay() !== 0) {
const dateStr = currentDay.toISOString().split('T')[0]
const dayWorkouts = workouts.value.filter(w =>
w.scheduled_date.split('T')[0] === dateStr
)
days.push({
date: dateStr,
day: currentDay.getDate(),
isCurrentMonth: currentDay.getMonth() === month,
isToday: isToday(currentDay),
workouts: dayWorkouts,
})
currentDay.setDate(currentDay.getDate() + 1)
}
return days
})
const upcomingWorkouts = computed(() => {
return workouts.value
.filter(w => new Date(w.scheduled_date) >= new Date())
.sort((a, b) => new Date(a.scheduled_date) - new Date(b.scheduled_date))
})
onMounted(async () => {
// Debug logging
console.log('=== Frontend Debug Info ===')
console.log('Stored access_token:', localStorage.getItem('access_token'))
console.log('Stored refresh_token:', localStorage.getItem('refresh_token'))
console.log('API Base URL:', process.env.VUE_APP_API_URL)
console.log('NODE_ENV:', process.env.NODE_ENV)
console.log('========================')
await loadWorkoutTypes()
await loadWorkouts()
})
async function loadWorkouts() {
try {
const { data } = await api.get('/api/protected/workouts')
workouts.value = data || []
} catch (err) {
console.error('Failed to load workouts:', err)
error.value = 'Failed to load workouts'
}
}
async function loadWorkoutTypes() {
try {
const { data } = await api.get('/api/protected/workout-types')
console.log('Loaded workout types:', data)
workoutTypes.value = data || []
} catch (err) {
console.error('Failed to load workout types:', err)
// Set default types if API fails
workoutTypes.value = [
{ id: 1, name: 'Cycling', icon: '🚴', color: '#667eea' },
{ id: 2, name: 'Running', icon: '🏃', color: '#f59e0b' },
{ id: 3, name: 'Swimming', icon: '🏊', color: '#06b6d4' },
{ id: 4, name: 'Strength', icon: '💪', color: '#8b5cf6' },
]
}
}
async function handleFileUpload(event) {
const file = event.target.files?.[0]
if (!file) return
fileParseError.value = ''
parsedFileData.value = null
uploadedFileName.value = file.name
try {
const fileExtension = file.name.split('.').pop()?.toLowerCase()
let parsed
if (fileExtension === 'fit') {
const fileBuffer = await file.arrayBuffer()
parsed = await parseFITFile(fileBuffer)
} else if (fileExtension === 'zwo') {
parsed = await parseZWOFile(file)
} else {
throw new Error('Unsupported file format. Please use FIT or ZWO files.')
}
if (parsed) {
parsedFileData.value = parsed
newWorkout.value.workout_data = parsed
newWorkout.value.file_type = fileExtension
// Auto-populate fields from file
if (parsed.title && !newWorkout.value.title) {
newWorkout.value.title = parsed.title
}
// Update duration from parsed file (convert seconds to minutes)
if (parsed.duration) {
newWorkout.value.duration = Math.round(parsed.duration / 60)
}
if (parsed.description && !newWorkout.value.description) {
newWorkout.value.description = parsed.description
}
}
} catch (err) {
console.error('File parsing error:', err)
fileParseError.value = `Error parsing file: ${err.message}`
uploadedFileName.value = ''
newWorkout.value.workout_data = null
newWorkout.value.file_type = null
}
}
async function handleAddWorkout() {
if (!newWorkout.value.title.trim()) {
error.value = 'Please enter a workout title'
return
}
if (!newWorkout.value.scheduled_date) {
error.value = 'Please select a date'
return
}
loading.value = true
error.value = ''
try {
const payload = {
title: newWorkout.value.title,
description: newWorkout.value.description,
type: newWorkout.value.type || null,
scheduled_date: newWorkout.value.scheduled_date,
duration: newWorkout.value.duration,
workout_data: newWorkout.value.workout_data,
file_type: newWorkout.value.file_type,
}
console.log('=== Sending Payload ===')
console.log('Payload:', payload)
console.log('API URL:', api.defaults.baseURL)
console.log('Full URL:', api.defaults.baseURL + '/api/protected/workouts')
console.log('======================')
const { data } = await api.post('/api/protected/workouts', payload)
workouts.value.push(data)
showAddWorkout.value = false
resetForm()
} catch (err) {
console.error('=== Full Error ===')
console.error('Error object:', err)
console.error('Response:', err.response)
console.error('Request:', err.request)
console.error('Message:', err.message)
console.error('=================')
error.value = err.response?.data?.error || 'Failed to add workout'
} finally {
loading.value = false
}
}
function resetForm() {
newWorkout.value = {
title: '',
description: '',
type: '',
scheduled_date: new Date().toISOString().split('T')[0],
duration: 60,
workout_data: null,
file_type: null,
}
uploadedFileName.value = ''
parsedFileData.value = null
fileParseError.value = ''
error.value = ''
// Reset file input
const fileInput = document.getElementById('workout-file')
if (fileInput) {
fileInput.value = ''
}
}
function previousMonth() {
currentDate.value = new Date(currentDate.value.getFullYear(), currentDate.value.getMonth() - 1)
}
function nextMonth() {
currentDate.value = new Date(currentDate.value.getFullYear(), currentDate.value.getMonth() + 1)
}
function selectWorkout(workout) {
selectedWorkout.value = workout
}
function isToday(date) {
const today = new Date()
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
function getWorkoutColor(type) {
const typeObj = workoutTypes.value.find(t => t.name === type)
return typeObj?.color || '#667eea'
}
function getWorkoutIcon(type) {
const typeObj = workoutTypes.value.find(t => t.name === type)
return typeObj?.icon || '🏋'
}
function formatDateDay(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { day: 'numeric' })
}
function formatDateMonth(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short' })
}
</script>
<style scoped>
/* Calendar Page - Modern TrainingPeaks/TrainerRoad Inspired Design */
.calendar-page {
min-height: 100vh;
background: var(--color-background);
}
.calendar-content {
max-width: 1600px;
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
}
/* Month Navigation */
.month-navigation {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-xl);
margin: 0 auto;
padding: var(--spacing-lg);
}
.current-month-display {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
min-width: 280px;
text-align: center;
margin: 0;
}
/* Modal - Modern Design */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal-backdrop);
padding: var(--spacing-lg);
overflow-y: auto;
}
.modal-content-modern {
background: var(--color-surface);
border-radius: 16px;
padding: var(--spacing-2xl);
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
z-index: var(--z-modal);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header-modern {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-lg);
border-bottom: 2px solid var(--color-divider);
}
.modal-title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
}
.btn-close-modern {
background: var(--color-surface-hover);
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all var(--transition-base);
}
.btn-close-modern:hover {
background: var(--color-danger);
color: white;
transform: rotate(90deg);
}
/* Modern Form Layout */
.form-row-modern {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
}
.form-actions-modern {
display: flex;
gap: var(--spacing-md);
justify-content: flex-end;
padding-top: var(--spacing-xl);
border-top: 2px solid var(--color-divider);
margin-top: var(--spacing-lg);
}
/* Ensure textarea inherits form-input-modern styles */
textarea.form-input-modern {
resize: vertical;
min-height: 100px;
font-family: var(--font-family);
}
/* File Upload - Modern Design */
.file-input-hidden {
display: none;
}
.file-upload-area {
margin-top: var(--spacing-sm);
}
.file-upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-2xl);
border: 3px dashed var(--color-primary);
border-radius: 12px;
background: rgba(0, 102, 204, 0.03);
cursor: pointer;
transition: all var(--transition-base);
}
.file-upload-label:hover {
background: rgba(0, 102, 204, 0.08);
border-color: var(--color-primary-dark);
transform: translateY(-2px);
}
.file-upload-text {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.file-upload-text strong {
color: var(--color-primary);
font-weight: var(--font-weight-bold);
}
.file-upload-text span {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.file-upload-hint {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.file-parse-success {
margin-top: var(--spacing-md);
padding: var(--spacing-lg);
background: rgba(0, 200, 83, 0.08);
border: 2px solid rgba(0, 200, 83, 0.3);
border-radius: 10px;
}
.parse-success-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--color-secondary);
font-size: var(--font-size-base);
margin-bottom: var(--spacing-md);
}
.parse-success-details {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.parse-detail {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
}
.parse-label {
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.parse-value {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.error-message-modern {
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
background: rgba(230, 57, 70, 0.1);
color: var(--color-danger);
border-radius: 8px;
border-left: 4px solid var(--color-danger);
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-weight: var(--font-weight-medium);
}
/* Calendar Grid - Modern Design */
.calendar-card {
padding: var(--spacing-xl);
overflow-x: auto;
}
.calendar-grid {
min-width: 100%;
}
.calendar-header-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 2px;
background: var(--color-divider);
border-radius: 8px 8px 0 0;
overflow: hidden;
}
.day-header {
background: var(--color-primary);
color: white;
padding: var(--spacing-md) var(--spacing-sm);
text-align: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
background: var(--color-divider);
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.calendar-day-modern {
background: var(--color-surface);
padding: var(--spacing-md);
min-height: 140px;
display: flex;
flex-direction: column;
transition: all var(--transition-base);
position: relative;
}
.calendar-day-modern:hover {
background: var(--color-surface-hover);
transform: scale(1.02);
z-index: 1;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.calendar-day-modern.other-month {
background: var(--color-surface-secondary);
opacity: 0.5;
}
.calendar-day-modern.today {
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08) 0%, rgba(0, 102, 204, 0.02) 100%);
border: 2px solid var(--color-primary);
}
.calendar-day-modern.has-workouts {
border-left: 4px solid var(--color-secondary);
}
.day-number-modern {
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-sm);
display: flex;
align-items: center;
justify-content: space-between;
}
.today-badge {
background: var(--color-primary);
color: white;
font-size: var(--font-size-xs);
padding: 4px 8px;
border-radius: 12px;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.day-workouts-modern {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
flex: 1;
overflow-y: auto;
}
.workout-badge-modern {
background: var(--color-surface-hover);
border-left: 4px solid var(--color-primary);
border-radius: 6px;
padding: var(--spacing-sm);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.workout-badge-modern:hover {
background: rgba(0, 102, 204, 0.08);
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.workout-badge-header {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.workout-badge-icon {
font-size: var(--font-size-base);
}
.workout-badge-title {
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-xs);
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.workout-badge-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-xs);
}
.workout-badge-duration {
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
/* Upcoming Workouts Table - Modern Design */
.upcoming-workouts-table {
margin-top: var(--spacing-md);
}
.workout-row {
cursor: pointer;
transition: all var(--transition-base);
}
.workout-row:hover {
background: rgba(0, 102, 204, 0.05) !important;
}
.workout-date-cell {
padding: var(--spacing-md) !important;
}
.date-display {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-sm);
background: var(--color-primary);
border-radius: 8px;
min-width: 50px;
color: white;
}
.date-day {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
line-height: 1;
}
.date-month {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.workout-title-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--color-text-primary);
}
.workout-icon {
font-size: var(--font-size-lg);
}
.workout-type-badge {
background: rgba(0, 102, 204, 0.1);
color: var(--color-primary);
padding: 6px 12px;
border-radius: 16px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.duration-display {
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.btn-action {
background: transparent;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: var(--spacing-xs);
border-radius: 6px;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
}
.btn-action:hover {
background: rgba(0, 102, 204, 0.1);
}
.text-secondary {
color: var(--color-text-secondary);
}
/* Responsive Design */
@media (max-width: 1024px) {
.day-header {
font-size: var(--font-size-xs);
padding: var(--spacing-sm);
}
.calendar-day-modern {
min-height: 120px;
padding: var(--spacing-sm);
}
.day-number-modern {
font-size: var(--font-size-lg);
}
}
@media (max-width: 768px) {
.calendar-content {
padding: var(--spacing-lg) var(--spacing-md);
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.month-navigation {
flex-wrap: wrap;
gap: var(--spacing-md);
}
.current-month-display {
font-size: var(--font-size-2xl);
min-width: auto;
width: 100%;
}
.form-row-modern {
grid-template-columns: 1fr;
}
.modal-content-modern {
max-width: 100%;
padding: var(--spacing-xl);
}
.form-actions-modern {
flex-direction: column-reverse;
}
.form-actions-modern .btn-modern {
width: 100%;
}
.day-header {
font-size: 10px;
padding: var(--spacing-xs);
}
.calendar-day-modern {
min-height: 100px;
padding: var(--spacing-xs);
}
.day-number-modern {
font-size: var(--font-size-base);
}
.workout-badge-modern {
padding: 6px;
}
.today-badge {
display: none;
}
/* Table becomes stacked cards on mobile */
.table-modern {
display: block;
}
.table-modern thead {
display: none;
}
.table-modern tbody,
.table-modern tr,
.table-modern td {
display: block;
}
.table-modern tr {
margin-bottom: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.table-modern td {
padding: var(--spacing-sm) var(--spacing-md) !important;
border-bottom: 1px solid var(--color-border) !important;
text-align: left !important;
}
.table-modern td:last-child {
border-bottom: none !important;
}
.date-display {
width: fit-content;
}
.workout-title-cell {
font-size: var(--font-size-base);
}
}
@media (max-width: 480px) {
.calendar-card {
padding: var(--spacing-md);
}
.btn-modern-large {
padding: 12px 20px;
font-size: var(--font-size-base);
}
.modal-content-modern {
padding: var(--spacing-lg);
}
}
</style>