feat: Implement Analyze and Plan features, Smart Sync, Theming, and Optional Auth
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { RotateCw, Activity, Loader2, Calendar, CheckCircle, AlertTriangle } from 'lucide-vue-next'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const loading = ref(true)
|
||||
const syncStatus = ref('idle') // idle, syncing, success, warning
|
||||
const syncMessage = ref('')
|
||||
const timeHorizon = ref(12) // Default 12 weeks
|
||||
const chartData = ref({ labels: [], datasets: [] })
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Hours' } }
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/analyze/stats?weeks=${timeHorizon.value}`)
|
||||
const data = await res.json()
|
||||
chartData.value = data.weekly
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runSmartSync = async () => {
|
||||
syncStatus.value = 'syncing'
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/sync/smart', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
syncStatus.value = 'success'
|
||||
syncMessage.value = data.synced_count > 0 ? `Synced ${data.synced_count} new` : 'Up to date'
|
||||
await fetchData()
|
||||
} else {
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = "Auth check failed"
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = "Sync error"
|
||||
}
|
||||
}
|
||||
|
||||
watch(timeHorizon, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
runSmartSync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analyze-view">
|
||||
<div class="card header-card">
|
||||
<div>
|
||||
<h3><Activity :size="24" /> Analyze Performance</h3>
|
||||
<p>Your fitness journey over time.</p>
|
||||
</div>
|
||||
|
||||
<!-- Sync Status Indicator -->
|
||||
<div class="sync-status" :class="syncStatus">
|
||||
<Loader2 v-if="syncStatus === 'syncing'" class="spinner" :size="16" />
|
||||
<CheckCircle v-if="syncStatus === 'success'" :size="16" />
|
||||
<AlertTriangle v-if="syncStatus === 'warning'" :size="16" />
|
||||
|
||||
<span v-if="syncStatus === 'idle'">Ready</span>
|
||||
<span v-if="syncStatus === 'syncing'">Syncing...</span>
|
||||
<span v-if="syncStatus === 'success'">{{ syncMessage }}</span>
|
||||
<span v-if="syncStatus === 'warning'">Check Connection</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Weekly Volume</h3>
|
||||
<div class="time-toggles">
|
||||
<button :class="{active: timeHorizon === 1}" @click="timeHorizon = 1">7D</button>
|
||||
<button :class="{active: timeHorizon === 4}" @click="timeHorizon = 4">4W</button>
|
||||
<button :class="{active: timeHorizon === 12}" @click="timeHorizon = 12">12W</button>
|
||||
<button :class="{active: timeHorizon === 52}" @click="timeHorizon = 52">1Y</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<Loader2 class="spinner" :size="32" />
|
||||
<p>Crunching the numbers...</p>
|
||||
</div>
|
||||
<div v-else class="chart-wrapper">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for Withings -->
|
||||
<div class="card">
|
||||
<h3>Body Composition</h3>
|
||||
<p style="color: var(--text-muted); font-style: italic;">
|
||||
Connect Withings in Settings to visualize weight and body composition trends here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analyze-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sync-status.success {
|
||||
color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
background: rgba(46, 160, 67, 0.1);
|
||||
}
|
||||
|
||||
.sync-status.warning {
|
||||
color: #e3b341;
|
||||
border-color: #e3b341;
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.time-toggles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--bg-color);
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.time-toggles button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.time-toggles button.active {
|
||||
background: var(--card-bg); /* or accent if preferred, but usually subtler */
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Dumbbell, MessageSquare, Plus, Save, Upload, Loader2, Calendar } from 'lucide-vue-next'
|
||||
|
||||
const remoteWorkouts = ref([])
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const currentWorkout = ref(null)
|
||||
|
||||
const fetchWorkouts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts')
|
||||
if (res.ok) {
|
||||
remoteWorkouts.value = await res.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workouts", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateWorkout = async () => {
|
||||
if (!chatInput.value.trim()) return
|
||||
|
||||
chatLoading.value = true
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: chatInput.value })
|
||||
})
|
||||
const data = await res.json()
|
||||
currentWorkout.value = data.workout
|
||||
creating.value = true
|
||||
chatInput.value = ''
|
||||
} catch (error) {
|
||||
console.error("Failed to generate workout", error)
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const uploadWorkout = async () => {
|
||||
if (!currentWorkout.value) return
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentWorkout.value)
|
||||
})
|
||||
if (res.ok) {
|
||||
alert("Workout uploaded to Garmin Connect successfully!")
|
||||
creating.value = false
|
||||
currentWorkout.value = null
|
||||
fetchWorkouts()
|
||||
} else {
|
||||
alert("Upload failed. Check backend logs.")
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Upload failed: " + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkouts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-view">
|
||||
|
||||
<!-- Hero / Creation Mode -->
|
||||
<div class="card creation-card">
|
||||
<div v-if="!creating" class="chat-interface">
|
||||
<h3><MessageSquare :size="24" /> AI Workout Crafter</h3>
|
||||
<p>Describe your goal (e.g., "Leg day with squats and lunges", "30 min interval run")</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="chatInput"
|
||||
@keyup.enter="generateWorkout"
|
||||
type="text"
|
||||
placeholder="Type your workout request..."
|
||||
:disabled="chatLoading"
|
||||
/>
|
||||
<button @click="generateWorkout" :disabled="chatLoading || !chatInput">
|
||||
<Loader2 v-if="chatLoading" class="spinner" :size="20" />
|
||||
<span v-else>Generate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="workout-editor">
|
||||
<div class="editor-header">
|
||||
<input v-model="currentWorkout.workoutName" class="title-input"/>
|
||||
<div class="actions">
|
||||
<button class="secondary" @click="creating = false">Cancel</button>
|
||||
<button @click="uploadWorkout"><Upload :size="16" /> Sync to Garmin</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="segments">
|
||||
<div v-for="(segment, sIdx) in currentWorkout.workoutSegments" :key="sIdx" class="segment">
|
||||
<h4>Segment {{ sIdx + 1 }}</h4>
|
||||
<div v-for="(step, stepIdx) in segment.workoutSteps" :key="stepIdx" class="step-item">
|
||||
<span class="step-type">{{ step.stepType.stepTypeKey }}</span>
|
||||
<span class="step-detail">
|
||||
{{ step.endConditionValue }}
|
||||
{{ step.endCondition.conditionTypeKey }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Workouts -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3><Calendar :size="20" /> Available Workouts (Garmin)</h3>
|
||||
<button class="icon-btn" @click="fetchWorkouts"><Loader2 v-if="loading" class="spinner" :size="16" /><span v-else>Refresh</span></button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && remoteWorkouts.length === 0" style="text-align: center; padding: 2rem;">Loading workouts...</div>
|
||||
|
||||
<div class="workout-grid">
|
||||
<div v-for="workout in remoteWorkouts" :key="workout.workoutId" class="workout-item">
|
||||
<div class="workout-icon">
|
||||
<Dumbbell v-if="workout.sportType.sportTypeKey === 'strength_training'" />
|
||||
<span v-else>🏃</span>
|
||||
</div>
|
||||
<div class="workout-info">
|
||||
<strong>{{ workout.workoutName }}</strong>
|
||||
<div class="meta">{{ workout.sportType.sportTypeKey }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.creation-card {
|
||||
border-color: var(--accent-color);
|
||||
background: linear-gradient(to bottom right, var(--card-bg), rgba(31, 111, 235, 0.05));
|
||||
}
|
||||
|
||||
.chat-interface {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workout-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-color);
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.step-type {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.workout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.workout-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.workout-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user