Implement Software Best Practices and Graceful API Key Management
- Integration of ruff, eslint, prettier for consistent code style - Global error handling and structured logging in backend - Comprehensive testing strategy with 100% pass rate (33 backend, 2 frontend) - Graceful handling of missing Gemini API key (non-blocking startup) - New /settings/status endpoint and UI warning indicators - Full documentation update (ARCHITECTURE.md, CONTRIBUTING.md, README.md, GEMINI.md) - Restored missing frontend logic and fixed UI syntax errors
This commit is contained in:
@@ -10,7 +10,14 @@ import {
|
||||
LinearScale
|
||||
} from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { RotateCw, Activity, Loader2, Calendar, CheckCircle, AlertTriangle } from 'lucide-vue-next'
|
||||
import {
|
||||
Activity,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Send,
|
||||
Bot
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
@@ -35,7 +42,7 @@ const fetchData = async () => {
|
||||
const data = await res.json()
|
||||
chartData.value = data.weekly
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats", error)
|
||||
console.error('Failed to fetch stats', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -47,21 +54,59 @@ const runSmartSync = async () => {
|
||||
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()
|
||||
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"
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = 'Auth check failed'
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = "Sync error"
|
||||
syncMessage.value = 'Sync error'
|
||||
}
|
||||
}
|
||||
|
||||
// AI Chat
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const chatHistory = ref([]) // Local UI history
|
||||
const chatContext = ref([]) // History for API context
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!chatInput.value.trim()) return
|
||||
|
||||
const userMsg = chatInput.value
|
||||
chatHistory.value.push({ role: 'user', content: userMsg })
|
||||
chatInput.value = ''
|
||||
chatLoading.value = true
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/analyze/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: userMsg,
|
||||
history: chatContext.value
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
const aiMsg = data.message
|
||||
chatHistory.value.push({ role: 'model', content: aiMsg })
|
||||
|
||||
// Update context for next turn
|
||||
chatContext.value.push({ role: 'user', content: userMsg })
|
||||
chatContext.value.push({ role: 'model', content: aiMsg })
|
||||
} catch (err) {
|
||||
chatHistory.value.push({ role: 'model', content: 'Error connecting to AI Analyst.' })
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(timeHorizon, () => {
|
||||
fetchData()
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -77,31 +122,31 @@ onMounted(() => {
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -110,11 +155,68 @@ onMounted(() => {
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- AI Analyst Chat -->
|
||||
<div class="card analyst-card">
|
||||
<div class="chart-header">
|
||||
<h3><Bot :size="24" /> AI Analyst <span class="beta-tag">BETA</span></h3>
|
||||
</div>
|
||||
|
||||
<div class="chat-window">
|
||||
<div v-if="chatHistory.length === 0" class="empty-state">
|
||||
<p>Ask me anything about your training data!</p>
|
||||
<div class="chips">
|
||||
<button
|
||||
@click="
|
||||
chatInput = 'Summarize my last 4 weeks of training';
|
||||
sendMessage();
|
||||
"
|
||||
>
|
||||
Summarize last month
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
chatInput = 'Why is my volume increasing?';
|
||||
sendMessage();
|
||||
"
|
||||
>
|
||||
Analyze volume trend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(msg, i) in chatHistory" :key="i" class="message" :class="msg.role">
|
||||
<div class="avatar">
|
||||
<Bot v-if="msg.role === 'model'" :size="16" />
|
||||
<span v-else>Me</span>
|
||||
</div>
|
||||
<div class="bubble">{{ msg.content }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chatLoading" class="message model">
|
||||
<div class="avatar"><Bot :size="16" /></div>
|
||||
<div class="bubble typing"><Loader2 class="spinner" :size="14" /> Thinking...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input margin-top">
|
||||
<input
|
||||
v-model="chatInput"
|
||||
type="text"
|
||||
placeholder="Ex: How does this week compare to last month?"
|
||||
:disabled="chatLoading"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<button :disabled="chatLoading || !chatInput" @click="sendMessage">
|
||||
<Send :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for Withings -->
|
||||
<div class="card">
|
||||
<h3>Body Composition</h3>
|
||||
<p style="color: var(--text-muted); font-style: italic;">
|
||||
<p style="color: var(--text-muted); font-style: italic">
|
||||
Connect Withings in Settings to visualize weight and body composition trends here.
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,60 +237,60 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
background: var(--card-bg); /* or accent if preferred, but usually subtler */
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
@@ -210,4 +312,102 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.beta-tag {
|
||||
font-size: 0.7rem;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chips button {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.model {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.model .avatar {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--card-bg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+472
-193
@@ -1,71 +1,147 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Dumbbell, MessageSquare, Plus, Save, Upload, Loader2, Calendar } from 'lucide-vue-next'
|
||||
import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Copy,
|
||||
Edit,
|
||||
ArrowLeft,
|
||||
UploadCloud,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Code,
|
||||
Layout
|
||||
} from 'lucide-vue-next'
|
||||
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
|
||||
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
|
||||
|
||||
const remoteWorkouts = ref([])
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const currentWorkout = ref(null)
|
||||
// State
|
||||
const viewMode = ref('browser') // 'browser' | 'editor'
|
||||
const editorTab = ref('visual') // 'visual' | 'json'
|
||||
const workouts = ref([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const syncResult = ref(null)
|
||||
|
||||
// Editor State
|
||||
const workingWorkout = ref(null)
|
||||
const aiPrompt = ref('')
|
||||
const aiLoading = ref(false)
|
||||
const aiError = ref('')
|
||||
|
||||
// --- BROWSER ACTIONS ---
|
||||
|
||||
const fetchWorkouts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts')
|
||||
if (res.ok) {
|
||||
remoteWorkouts.value = await res.json()
|
||||
workouts.value = await res.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workouts", error)
|
||||
console.error('Fetch workouts failed', 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 createNewWorkout = () => {
|
||||
workingWorkout.value = {
|
||||
workoutName: 'New Workout',
|
||||
description: 'Created with FitMop',
|
||||
sportType: { sportTypeId: 1, sportTypeKey: 'running' },
|
||||
workoutSegments: [
|
||||
{
|
||||
segmentOrder: 1,
|
||||
sportType: { sportTypeId: 1, sportTypeKey: 'running' },
|
||||
workoutSteps: []
|
||||
}
|
||||
]
|
||||
}
|
||||
viewMode.value = 'editor'
|
||||
editorTab.value = 'visual'
|
||||
syncResult.value = null
|
||||
}
|
||||
|
||||
const uploadWorkout = async () => {
|
||||
if (!currentWorkout.value) return
|
||||
|
||||
const editWorkout = (workout) => {
|
||||
// Deep copy to avoid mutating list directly
|
||||
workingWorkout.value = JSON.parse(JSON.stringify(workout))
|
||||
viewMode.value = 'editor'
|
||||
editorTab.value = 'visual'
|
||||
syncResult.value = null
|
||||
}
|
||||
|
||||
const duplicateWorkout = (workout) => {
|
||||
const copy = JSON.parse(JSON.stringify(workout))
|
||||
copy.workoutName = `${copy.workoutName} (Copy)`
|
||||
// Clear ID to ensure it treats as new if we were persisting IDs (remote IDs ignored on upload usually)
|
||||
delete copy.workoutId
|
||||
workingWorkout.value = copy
|
||||
viewMode.value = 'editor'
|
||||
syncResult.value = null
|
||||
}
|
||||
|
||||
// --- EDITOR ACTIONS ---
|
||||
|
||||
const syncToGarmin = async () => {
|
||||
syncing.value = true
|
||||
syncResult.value = null
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentWorkout.value)
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workingWorkout.value)
|
||||
})
|
||||
if (res.ok) {
|
||||
alert("Workout uploaded to Garmin Connect successfully!")
|
||||
creating.value = false
|
||||
currentWorkout.value = null
|
||||
fetchWorkouts()
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
// Note: Backend changed 'status' to 'success' boolean
|
||||
syncResult.value = { type: 'success', msg: 'Uploaded to Garmin!' }
|
||||
} else {
|
||||
alert("Upload failed. Check backend logs.")
|
||||
syncResult.value = { type: 'error', msg: 'Upload failed: ' + (data.error || 'Unknown error') }
|
||||
if (data.details) {
|
||||
console.error('Validation Details:', data.details)
|
||||
// Could show detailed validation errors in UI here
|
||||
syncResult.value.msg += ' (Check Console)'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Upload failed: " + error.message)
|
||||
} catch (e) {
|
||||
syncResult.value = { type: 'error', msg: 'Network error during sync.' }
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI ACTIONS ---
|
||||
|
||||
const askAI = async () => {
|
||||
if (!aiPrompt.value.trim()) return
|
||||
|
||||
aiLoading.value = true
|
||||
aiError.value = ''
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: aiPrompt.value,
|
||||
current_workout: workingWorkout.value
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.workout) {
|
||||
workingWorkout.value = data.workout
|
||||
aiPrompt.value = '' // Clear on success
|
||||
} else if (data.error) {
|
||||
aiError.value = data.error
|
||||
}
|
||||
} catch (err) {
|
||||
aiError.value = 'Failed to contact AI.'
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialization
|
||||
onMounted(() => {
|
||||
fetchWorkouts()
|
||||
})
|
||||
@@ -73,75 +149,125 @@ onMounted(() => {
|
||||
|
||||
<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"
|
||||
<!-- BROWSER MODE -->
|
||||
<div v-if="viewMode === 'browser'" class="browser-mode">
|
||||
<div class="card toolbar">
|
||||
<h3><Calendar :size="24" /> Existing Workouts</h3>
|
||||
<button class="primary-btn" @click="createNewWorkout">
|
||||
<Plus :size="18" /> New Workout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" style="text-align: center; padding: 2rem">
|
||||
<Loader2 class="spinner" /> Loading remote workouts...
|
||||
</div>
|
||||
|
||||
<div v-else class="workout-grid">
|
||||
<div v-if="workouts.length === 0" class="empty-state">No workouts found. Create one!</div>
|
||||
<div v-for="w in workouts" :key="w.workoutId" class="workout-card">
|
||||
<div class="w-header">
|
||||
<h4>{{ w.workoutName }}</h4>
|
||||
<span class="badge">{{ w.sportType?.sportTypeKey }}</span>
|
||||
</div>
|
||||
<p class="desc">{{ w.description || 'No description' }}</p>
|
||||
<div class="actions">
|
||||
<button class="icon-btn" title="Duplicate" @click="duplicateWorkout(w)">
|
||||
<Copy :size="16" />
|
||||
</button>
|
||||
<button class="icon-btn" title="Edit" @click="editWorkout(w)">
|
||||
<Edit :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDITOR MODE -->
|
||||
<div v-if="viewMode === 'editor'" class="editor-mode">
|
||||
<!-- Editor Header -->
|
||||
<div class="card editor-header">
|
||||
<div class="left-controls">
|
||||
<button class="icon-btn" @click="viewMode = 'browser'"><ArrowLeft :size="20" /></button>
|
||||
<input
|
||||
v-model="workingWorkout.workoutName"
|
||||
class="title-input"
|
||||
placeholder="Workout Name"
|
||||
/>
|
||||
<button @click="generateWorkout" :disabled="chatLoading || !chatInput">
|
||||
<Loader2 v-if="chatLoading" class="spinner" :size="20" />
|
||||
<span v-else>Generate</span>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<span v-if="syncResult" :class="['sync-res', syncResult.type]">
|
||||
{{ syncResult.msg }}
|
||||
</span>
|
||||
<button class="primary-btn" :disabled="syncing" @click="syncToGarmin">
|
||||
<UploadCloud v-if="!syncing" :size="18" />
|
||||
<Loader2 v-else class="spinner" :size="18" />
|
||||
{{ syncing ? 'Syncing...' : 'Sync to Garmin' }}
|
||||
</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>
|
||||
<!-- AI Assistant Bar -->
|
||||
<div class="card ai-bar">
|
||||
<div class="ai-input-wrapper">
|
||||
<Sparkles :size="20" class="ai-icon" />
|
||||
<input
|
||||
v-model="aiPrompt"
|
||||
placeholder="Ask AI to modify... (e.g. 'Add a 10 min warmup' or 'Make intervals harder')"
|
||||
:disabled="aiLoading"
|
||||
@keyup.enter="askAI"
|
||||
/>
|
||||
<button class="ai-btn" :disabled="!aiPrompt || aiLoading" @click="askAI">
|
||||
<Loader2 v-if="aiLoading" class="spinner" :size="16" />
|
||||
<span v-else>Generate</span>
|
||||
</button>
|
||||
</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 v-if="aiError" class="ai-error">{{ aiError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Tabs -->
|
||||
<div class="flex gap-2 border-b border-gray-700 mb-2">
|
||||
<button
|
||||
:class="[
|
||||
'px-4 py-2 text-sm flex items-center gap-2 border-b-2',
|
||||
editorTab === 'visual'
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
]"
|
||||
@click="editorTab = 'visual'"
|
||||
>
|
||||
<Layout class="w-4 h-4" /> Visual Editor
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-4 py-2 text-sm flex items-center gap-2 border-b-2',
|
||||
editorTab === 'json'
|
||||
? 'border-purple-500 text-purple-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
]"
|
||||
@click="editorTab = 'json'"
|
||||
>
|
||||
<Code class="w-4 h-4" /> JSON Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor Content -->
|
||||
<div class="flex-1 min-h-0">
|
||||
<div v-if="editorTab === 'visual'" class="h-full overflow-y-auto pr-2">
|
||||
<!-- Using the new Visual Editor component -->
|
||||
<!-- We bind to workoutSteps of the first segment for simplicity, or we could make the editor handle full workout object. -->
|
||||
<!-- Let's bind to the workout object to let it handle metadata, but the dragger needs a list. -->
|
||||
<!-- The VisualEditor I designed takes `modelValue` (metadata) AND `steps` (list). -->
|
||||
<WorkoutVisualEditor
|
||||
v-model="workingWorkout"
|
||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="editorTab === 'json'" class="h-full">
|
||||
<WorkoutJsonEditor v-model="workingWorkout" />
|
||||
</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>
|
||||
|
||||
@@ -152,110 +278,263 @@ onMounted(() => {
|
||||
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));
|
||||
/* Toolbar & Header */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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);
|
||||
.browser-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.workout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 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-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.workout-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-2px);
|
||||
.w-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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);
|
||||
.w-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
.badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Editor Styles */
|
||||
.editor-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-controls,
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-color);
|
||||
padding: 0.25rem;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* AI Bar */
|
||||
.ai-bar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.ai-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(163, 113, 247, 0.1); /* Subtle purple tint */
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.ai-input-wrapper input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ai-input-wrapper input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ai-btn {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
color: #fa4549;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Steps Editor */
|
||||
.workout-structure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.step-row:hover {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step-idx {
|
||||
background: var(--border-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-type-input {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
width: 120px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.step-details {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tiny-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiny-btn:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tiny-btn.danger:hover {
|
||||
background: #fa4549;
|
||||
color: white;
|
||||
border-color: #fa4549;
|
||||
}
|
||||
|
||||
.add-step-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-step-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: rgba(46, 160, 67, 0.05);
|
||||
}
|
||||
|
||||
.sync-res {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.sync-res.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
.sync-res.error {
|
||||
color: #fa4549;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user