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:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import js from '@eslint/js'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'*.log'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
prettier,
|
||||
{
|
||||
files: ['**/*.vue', '**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
process: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'no-unused-vars': 'warn',
|
||||
'vue/no-mutating-props': 'error'
|
||||
}
|
||||
}
|
||||
]
|
||||
Generated
+3088
-3
File diff suppressed because it is too large
Load Diff
+19
-5
@@ -6,16 +6,30 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4"
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.7.4",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
}
|
||||
+355
-147
@@ -1,11 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { Activity, Dumbbell, TrendingUp, Cpu, Lock, Loader2, RefreshCw, Settings, X, CheckCircle2, Monitor, Terminal, LayoutDashboard, LineChart, Calendar } from 'lucide-vue-next'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
Activity,
|
||||
Dumbbell,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
CheckCircle2,
|
||||
Monitor,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
Settings
|
||||
} from 'lucide-vue-next'
|
||||
import AnalyzeView from './views/AnalyzeView.vue'
|
||||
import PlanView from './views/PlanView.vue'
|
||||
|
||||
const activities = ref([])
|
||||
const recommendation = ref("Loading recommendations...")
|
||||
const recommendation = ref('Loading recommendations...')
|
||||
const loading = ref(true)
|
||||
const syncing = ref(false)
|
||||
const authenticated = ref(false)
|
||||
@@ -19,6 +32,11 @@ const currentView = ref('dashboard')
|
||||
const settingsOpen = ref(false)
|
||||
const activeTab = ref('garmin')
|
||||
const currentTheme = ref(localStorage.getItem('theme') || 'modern')
|
||||
const profile = ref({
|
||||
fitness_goals: '',
|
||||
dietary_preferences: '',
|
||||
focus_days: []
|
||||
})
|
||||
const settingsStatus = ref({ garmin: {}, withings: {}, gemini: {} })
|
||||
const settingsForms = ref({
|
||||
garmin: { email: '', password: '', mfa_code: '' },
|
||||
@@ -26,6 +44,12 @@ const settingsForms = ref({
|
||||
gemini: { api_key: '' }
|
||||
})
|
||||
|
||||
const dashboardStats = ref({
|
||||
summary: { total_hours: 0, trend_pct: 0 },
|
||||
breakdown: [],
|
||||
strength_sessions: 0
|
||||
})
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/auth/status')
|
||||
@@ -43,12 +67,27 @@ const checkAuth = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
fetchSettings()
|
||||
})
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/settings')
|
||||
settingsStatus.value = await res.json()
|
||||
const res = await fetch('http://localhost:8000/settings/status')
|
||||
if (res.ok) {
|
||||
settingsStatus.value = await res.json()
|
||||
// Pre-fill forms if configured
|
||||
if (settingsStatus.value.garmin.configured) {
|
||||
// Note: Password/Key are not sent back for security
|
||||
settingsForms.value.garmin.email = settingsStatus.value.garmin.email || ''
|
||||
}
|
||||
if (settingsStatus.value.gemini.configured) {
|
||||
settingsForms.value.gemini.api_key = '••••••••'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error)
|
||||
console.error('Failed to fetch settings status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,46 +101,25 @@ const saveServiceSettings = async (service) => {
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchSettings()
|
||||
if (service === 'garmin') checkAuth()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loginGarmin = async () => {
|
||||
loading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settingsForms.value.garmin)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (data.status === 'SUCCESS') {
|
||||
authenticated.value = true
|
||||
mfaRequired.value = false
|
||||
triggerSync()
|
||||
} else if (data.status === 'MFA_REQUIRED') {
|
||||
mfaRequired.value = true
|
||||
}
|
||||
} else {
|
||||
authError.value = data.detail || 'Login failed'
|
||||
const err = await res.json()
|
||||
authError.value = err.detail || 'Save failed'
|
||||
}
|
||||
} catch (error) {
|
||||
authError.value = 'Could not connect to backend'
|
||||
authError.value = 'Failed to communicate with backend'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const triggerSync = async () => {
|
||||
if (syncing.value) return
|
||||
syncing.value = true
|
||||
try {
|
||||
await fetch('http://localhost:8000/sync', { method: 'POST' })
|
||||
fetchData()
|
||||
const res = await fetch('http://localhost:8000/sync', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error)
|
||||
} finally {
|
||||
@@ -109,11 +127,43 @@ const triggerSync = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loginGarmin = async () => {
|
||||
loading.value = true
|
||||
authError.value = ""
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settingsForms.value.garmin)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.status === 'MFA_REQUIRED') {
|
||||
mfaRequired.value = true
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
authenticated.value = true
|
||||
mfaRequired.value = false
|
||||
fetchData()
|
||||
} else {
|
||||
authError.value = data.message || 'Login failed'
|
||||
}
|
||||
} catch (error) {
|
||||
authError.value = 'Connection error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const actRes = await fetch('http://localhost:8000/activities')
|
||||
activities.value = await actRes.json()
|
||||
|
||||
|
||||
// Fetch dashboard stats
|
||||
const dashRes = await fetch('http://localhost:8000/analyze/dashboard')
|
||||
if (dashRes.ok) {
|
||||
dashboardStats.value = await dashRes.json()
|
||||
}
|
||||
|
||||
const recRes = await fetch('http://localhost:8000/recommendation')
|
||||
const recData = await recRes.json()
|
||||
recommendation.value = recData.recommendation
|
||||
@@ -124,109 +174,162 @@ const fetchData = async () => {
|
||||
|
||||
const setTheme = (theme) => {
|
||||
currentTheme.value = theme
|
||||
document.documentElement.setAttribute('data-theme', theme === 'hacker' ? 'hacker' : '')
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
fetchSettings()
|
||||
setTheme(currentTheme.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<h1>Fit<span style="color: var(--accent-color);">Mop</span></h1>
|
||||
<p>Your personal coach orchestrator</p>
|
||||
|
||||
<nav class="main-nav">
|
||||
<button :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
|
||||
<LayoutDashboard :size="18" /> Dashboard
|
||||
</button>
|
||||
<button :class="{active: currentView === 'analyze'}" @click="currentView = 'analyze'">
|
||||
<LineChart :size="18" /> Analyze
|
||||
</button>
|
||||
<button :class="{active: currentView === 'plan'}" @click="currentView = 'plan'">
|
||||
<Calendar :size="18" /> Plan
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="settings-btn" @click="settingsOpen = true"><Settings :size="24" /></button>
|
||||
<h1>FitMop</h1>
|
||||
<p>Your Ultimate Strength & Endurance Companion</p>
|
||||
<button class="settings-btn icon-btn" @click="settingsOpen = true">
|
||||
<AlertTriangle
|
||||
v-if="!settingsStatus.gemini.configured"
|
||||
:size="24"
|
||||
style="color: var(--error-color); margin-right: 0.5rem"
|
||||
/>
|
||||
<Settings :size="24" />
|
||||
</button>
|
||||
|
||||
<div class="main-nav">
|
||||
<button :class="{ active: currentView === 'dashboard' }" @click="currentView = 'dashboard'">
|
||||
<Activity :size="18" /> Dashboard
|
||||
</button>
|
||||
<button :class="{ active: currentView === 'analyze' }" @click="currentView = 'analyze'">
|
||||
<TrendingUp :size="18" /> Analysis
|
||||
</button>
|
||||
<button :class="{ active: currentView === 'plan' }" @click="currentView = 'plan'">
|
||||
<Dumbbell :size="18" /> Workout Plans
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content-area">
|
||||
|
||||
<!-- DASHBOARD VIEW -->
|
||||
<div v-if="currentView === 'dashboard'" class="dashboard">
|
||||
<!-- Sync Status Overlay (Visible when syncing or if forced) -->
|
||||
<div v-if="syncing" class="sync-overlay">
|
||||
<div class="card" style="display: flex; align-items: center; gap: 1rem; border-color: var(--accent-color);">
|
||||
<Loader2 class="spinner" :size="32" />
|
||||
<div>
|
||||
<h3 style="margin: 0;">Syncing Garmin Data...</h3>
|
||||
<p style="margin: 0; font-size: 0.9rem;">Gathering your latest workouts locally.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sync Status Overlay (Visible when syncing or if forced) -->
|
||||
<div v-if="syncing" class="sync-overlay">
|
||||
<div
|
||||
class="card"
|
||||
style="display: flex; align-items: center; gap: 1rem; border-color: var(--accent-color)"
|
||||
>
|
||||
<Loader2 class="spinner" :size="32" />
|
||||
<div>
|
||||
<h3 style="margin: 0">Syncing Garmin Data...</h3>
|
||||
<p style="margin: 0; font-size: 0.9rem">Gathering your latest workouts locally.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<h3><Activity :size="20" /> Weekly Activity</h3>
|
||||
<button v-if="authenticated" class="icon-btn" @click="triggerSync" :disabled="syncing">
|
||||
<RefreshCw :size="16" :class="{ 'spinner': syncing }" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-value">4.2h</div>
|
||||
<p>+12% from last week</p>
|
||||
<!-- Quick Stats -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start">
|
||||
<h3><Activity :size="20" /> Last 7 Days</h3>
|
||||
<button v-if="authenticated" class="icon-btn" :disabled="syncing" @click="triggerSync">
|
||||
<RefreshCw :size="16" :class="{ spinner: syncing }" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-value">{{ dashboardStats.summary.total_hours }}h</div>
|
||||
<p
|
||||
:style="{
|
||||
color:
|
||||
dashboardStats.summary.trend_pct >= 0 ? 'var(--success-color)' : 'var(--error-color)'
|
||||
}"
|
||||
>
|
||||
{{ dashboardStats.summary.trend_pct >= 0 ? '+' : ''
|
||||
}}{{ dashboardStats.summary.trend_pct }}% from previous
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem">
|
||||
<span v-for="item in dashboardStats.breakdown" :key="item.label" class="badge">
|
||||
{{ item.count }}x {{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><Dumbbell :size="20" /> Strength Sessions</h3>
|
||||
<div class="stat-value">3</div>
|
||||
<div class="stat-value">{{ dashboardStats.strength_sessions }}</div>
|
||||
<p>Target: 4 sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card">
|
||||
<h3><TrendingUp :size="20" /> VO2 Max</h3>
|
||||
<div class="stat-value">52</div>
|
||||
<p>Status: Superior</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Recommendation -->
|
||||
<div class="card" style="grid-column: 1 / -1; border-color: var(--accent-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<h3 style="margin:0"><Cpu :size="20" /> Gemini Recommendation</h3>
|
||||
<CheckCircle2 v-if="settingsStatus.gemini.configured" color="var(--success-color)" :size="18" />
|
||||
<!-- AI Recommendation -->
|
||||
<div class="card" style="grid-column: 1 / -1; border-color: var(--accent-color)">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
"
|
||||
>
|
||||
<h3 style="margin: 0"><Cpu :size="20" /> Gemini Recommendation</h3>
|
||||
<CheckCircle2
|
||||
v-if="settingsStatus.gemini.configured"
|
||||
color="var(--success-color)"
|
||||
:size="18"
|
||||
/>
|
||||
<AlertTriangle
|
||||
v-else
|
||||
color="var(--error-color)"
|
||||
:size="18"
|
||||
title="Gemini API Key missing"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="loading">Thinking...</p>
|
||||
<p v-else style="font-size: 1.1rem; font-style: italic;">"{{ recommendation }}"</p>
|
||||
<div v-if="!settingsStatus.gemini.configured" class="doc-box" style="margin-top: 1rem; border-color: var(--error-color)">
|
||||
<strong>AI Recommendations Disabled</strong><br />
|
||||
Please set your Gemini API Key in <a href="#" @click.prevent="settingsOpen = true; activeTab = 'gemini'">Settings</a> to get personalized coaching.
|
||||
</div>
|
||||
<p v-else-if="loading">Thinking...</p>
|
||||
<p v-else style="font-size: 1.1rem; font-style: italic">"{{ recommendation }}"</p>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activities -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3>Recent Workouts</h3>
|
||||
<span v-if="!authenticated" style="font-size: 0.8rem; color: var(--text-muted);">
|
||||
<!-- Recent Activities -->
|
||||
<div class="card" style="grid-column: 1 / -1">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
"
|
||||
>
|
||||
<h3>Recent Workouts</h3>
|
||||
<span v-if="!authenticated" style="font-size: 0.8rem; color: var(--text-muted)">
|
||||
Offline Mode - <a href="#" @click.prevent="settingsOpen = true">Connect Garmin</a>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && activities.length === 0" style="text-align: center; padding: 2rem;">Loading history...</div>
|
||||
<div v-else-if="activities.length === 0" style="text-align: center; padding: 2rem;">
|
||||
No local data found. Hit refresh or connect account to sync.
|
||||
|
||||
<div v-if="loading && activities.length === 0" style="text-align: center; padding: 2rem">
|
||||
Loading history...
|
||||
</div>
|
||||
<div v-for="activity in activities.slice(0, 10).sort((a,b) => new Date(b.startTimeLocal) - new Date(a.startTimeLocal))" :key="activity.activityId" class="activity-item">
|
||||
<div>
|
||||
<div v-else-if="activities.length === 0" style="text-align: center; padding: 2rem">
|
||||
No local data found. Hit refresh or connect account to sync.
|
||||
</div>
|
||||
<div
|
||||
v-for="activity in activities
|
||||
.slice(0, 10)
|
||||
.sort((a, b) => new Date(b.startTimeLocal) - new Date(a.startTimeLocal))"
|
||||
:key="activity.activityId"
|
||||
class="activity-item"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ activity.activityName || 'Workout' }}</strong>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">
|
||||
{{ activity.activityType?.typeKey || 'Training' }} • {{ new Date(activity.startTimeLocal).toLocaleDateString() }}
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted)">
|
||||
{{ activity.activityType?.typeKey || 'Training' }} •
|
||||
{{ new Date(activity.startTimeLocal).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-weight: 600;">{{ Math.round(activity.duration / 60) }}m</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-weight: 600">{{ Math.round(activity.duration / 60) }}m</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANALYZE VIEW -->
|
||||
@@ -234,7 +337,6 @@ onMounted(() => {
|
||||
|
||||
<!-- PLAN VIEW -->
|
||||
<PlanView v-if="currentView === 'plan'" />
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@@ -244,55 +346,108 @@ onMounted(() => {
|
||||
<h3>Settings</h3>
|
||||
<button class="icon-btn" @click="settingsOpen = false"><X :size="20" /></button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-sidebar">
|
||||
<div class="sidebar-item" :class="{active: activeTab === 'garmin'}" @click="activeTab = 'garmin'">Garmin</div>
|
||||
<div class="sidebar-item" :class="{active: activeTab === 'withings'}" @click="activeTab = 'withings'">Withings</div>
|
||||
<div class="sidebar-item" :class="{active: activeTab === 'gemini'}" @click="activeTab = 'gemini'">Gemini AI</div>
|
||||
<div class="sidebar-item" :class="{active: activeTab === 'appearance'}" @click="activeTab = 'appearance'">Appearance</div>
|
||||
<div
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === 'garmin' }"
|
||||
@click="activeTab = 'garmin'"
|
||||
>
|
||||
Garmin
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === 'withings' }"
|
||||
@click="activeTab = 'withings'"
|
||||
>
|
||||
Withings
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === 'gemini' }"
|
||||
@click="activeTab = 'gemini'"
|
||||
>
|
||||
Gemini AI
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === 'profile' }"
|
||||
@click="activeTab = 'profile'"
|
||||
>
|
||||
Your Profile
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === 'appearance' }"
|
||||
@click="activeTab = 'appearance'"
|
||||
>
|
||||
Appearance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-main">
|
||||
<!-- Garmin Tab -->
|
||||
<div v-if="activeTab === 'garmin'">
|
||||
<div class="doc-box">
|
||||
<strong>Garmin Connect</strong><br>
|
||||
Credentials are stored in <code>.env_garmin</code>. Session tokens are saved to <code>.garth/</code> in the project root to keep you logged in.
|
||||
<strong>Garmin Connect</strong><br />
|
||||
Credentials are stored in <code>.env_garmin</code>. Session tokens are saved to
|
||||
<code>.garth/</code> in the project root to keep you logged in.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<input v-model="settingsForms.garmin.email" type="email" placeholder="Garmin Email" />
|
||||
<input v-model="settingsForms.garmin.password" type="password" placeholder="Garmin Password" />
|
||||
|
||||
<div v-if="mfaRequired" class="form-group" style="margin-top:0">
|
||||
<p style="font-size: 0.8rem; margin:0">Enter MFA Code from email:</p>
|
||||
<input
|
||||
v-model="settingsForms.garmin.password"
|
||||
type="password"
|
||||
placeholder="Garmin Password"
|
||||
/>
|
||||
|
||||
<div v-if="mfaRequired" class="form-group" style="margin-top: 0">
|
||||
<p style="font-size: 0.8rem; margin: 0">Enter MFA Code from email:</p>
|
||||
<input v-model="settingsForms.garmin.mfa_code" type="text" placeholder="MFA Code" />
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<button style="flex:1" @click="saveServiceSettings('garmin')" :disabled="loading">Save Credentials</button>
|
||||
<button style="flex:1" class="secondary" @click="loginGarmin" :disabled="loading">
|
||||
<div style="display: flex; gap: 1rem">
|
||||
<button style="flex: 1" :disabled="loading" @click="saveServiceSettings('garmin')">
|
||||
Save Credentials
|
||||
</button>
|
||||
<button style="flex: 1" class="secondary" :disabled="loading" @click="loginGarmin">
|
||||
{{ mfaRequired ? 'Verify MFA' : 'Test & Sync' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="authError" class="error">{{ authError }}</p>
|
||||
<p v-if="authenticated" class="success">✓ Garmin Connected as {{ settingsStatus.garmin.configured ? settingsForms.garmin.email : '' }}</p>
|
||||
<p v-if="authenticated" class="success">
|
||||
✓ Garmin Connected as
|
||||
{{ settingsStatus.garmin.configured ? settingsForms.garmin.email : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Withings Tab -->
|
||||
<div v-if="activeTab === 'withings'">
|
||||
<div class="doc-box">
|
||||
<strong>Withings Health</strong><br>
|
||||
1. Create a Withings Developer app at <a href="https://developer.withings.com" target="_blank">developer.withings.com</a>.<br>
|
||||
2. Copy your Client ID and Client Secret.<br>
|
||||
<strong>Withings Health</strong><br />
|
||||
1. Create a Withings Developer app at
|
||||
<a href="https://developer.withings.com" target="_blank">developer.withings.com</a
|
||||
>.<br />
|
||||
2. Copy your Client ID and Client Secret.<br />
|
||||
3. Data is stored in <code>.env_withings</code>.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input v-model="settingsForms.withings.client_id" type="text" placeholder="Client ID" />
|
||||
<input v-model="settingsForms.withings.client_secret" type="password" placeholder="Client Secret" />
|
||||
<button @click="saveServiceSettings('withings')" :disabled="loading">Save Withings Config</button>
|
||||
<input
|
||||
v-model="settingsForms.withings.client_id"
|
||||
type="text"
|
||||
placeholder="Client ID"
|
||||
/>
|
||||
<input
|
||||
v-model="settingsForms.withings.client_secret"
|
||||
type="password"
|
||||
placeholder="Client Secret"
|
||||
/>
|
||||
<button :disabled="loading" @click="saveServiceSettings('withings')">
|
||||
Save Withings Config
|
||||
</button>
|
||||
<p v-if="settingsStatus.withings.configured" class="success">✓ Withings Configured</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,30 +455,70 @@ onMounted(() => {
|
||||
<!-- Gemini Tab -->
|
||||
<div v-if="activeTab === 'gemini'">
|
||||
<div class="doc-box">
|
||||
<strong>Gemini AI Coaching</strong><br>
|
||||
1. Get an API key from <a href="https://aistudio.google.com" target="_blank">Google AI Studio</a>.<br>
|
||||
2. This enables personalized training recommendations.<br>
|
||||
<strong>Gemini AI Coaching</strong><br />
|
||||
1. Get an API key from
|
||||
<a href="https://aistudio.google.com" target="_blank">Google AI Studio</a>.<br />
|
||||
2. This enables personalized training recommendations.<br />
|
||||
3. Stored in <code>.env_gemini</code>.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input v-model="settingsForms.gemini.api_key" type="password" placeholder="Gemini API Key" />
|
||||
<button @click="saveServiceSettings('gemini')" :disabled="loading">Save API Key</button>
|
||||
<input
|
||||
v-model="settingsForms.gemini.api_key"
|
||||
type="password"
|
||||
placeholder="Gemini API Key"
|
||||
/>
|
||||
<button :disabled="loading" @click="saveServiceSettings('gemini')">
|
||||
Save API Key
|
||||
</button>
|
||||
<p v-if="settingsStatus.gemini.configured" class="success">✓ Gemini AI Configured</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Tab -->
|
||||
<div v-if="activeTab === 'profile'">
|
||||
<div class="doc-box">
|
||||
<strong>Your Athlete Profile</strong><br />
|
||||
The AI uses this information to personalize your analysis and workout plans.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Main Fitness Goal</label>
|
||||
<textarea
|
||||
v-model="profile.fitness_goals"
|
||||
rows="3"
|
||||
placeholder="e.g. Run a sub-4 hour marathon in October"
|
||||
></textarea>
|
||||
|
||||
<label>Dietary/Training Preferences</label>
|
||||
<textarea
|
||||
v-model="profile.dietary_preferences"
|
||||
rows="3"
|
||||
placeholder="e.g. Vegan, prefer morning workouts, hate swimming"
|
||||
></textarea>
|
||||
|
||||
<button :disabled="loading" @click="saveProfile">Save Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Tab -->
|
||||
<div v-if="activeTab === 'appearance'">
|
||||
<div class="doc-box">
|
||||
<strong>Theme Settings</strong><br>
|
||||
<strong>Theme Settings</strong><br />
|
||||
Choose the aesthetic that fits your mood.
|
||||
</div>
|
||||
<div class="theme-preview">
|
||||
<div class="theme-card" :class="{active: currentTheme === 'modern'}" @click="setTheme('modern')">
|
||||
<div
|
||||
class="theme-card"
|
||||
:class="{ active: currentTheme === 'modern' }"
|
||||
@click="setTheme('modern')"
|
||||
>
|
||||
<Monitor :size="32" />
|
||||
<p>Modern Blue</p>
|
||||
</div>
|
||||
<div class="theme-card" :class="{active: currentTheme === 'hacker'}" @click="setTheme('hacker')">
|
||||
<div
|
||||
class="theme-card"
|
||||
:class="{ active: currentTheme === 'hacker' }"
|
||||
@click="setTheme('hacker')"
|
||||
>
|
||||
<Terminal :size="32" />
|
||||
<p>Retro Hacker</p>
|
||||
</div>
|
||||
@@ -347,11 +542,11 @@ onMounted(() => {
|
||||
--border-color: #30363d;
|
||||
--error-color: #f85149;
|
||||
--success-color: #238636;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--card-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="hacker"] {
|
||||
[data-theme='hacker'] {
|
||||
--bg-color: #002b36;
|
||||
--card-bg: #073642;
|
||||
--text-color: #859900;
|
||||
@@ -361,7 +556,7 @@ onMounted(() => {
|
||||
--border-color: #586e75;
|
||||
--error-color: #dc322f;
|
||||
--success-color: #859900;
|
||||
--font-family: "Courier New", Courier, monospace;
|
||||
--font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -522,8 +717,12 @@ button:disabled {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -532,6 +731,15 @@ button:disabled {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -551,7 +759,7 @@ button:disabled {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HelloWorld from '../components/HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld.vue', () => {
|
||||
it('renders props.msg when passed', () => {
|
||||
const msg = 'new message'
|
||||
const wrapper = mount(HelloWorld, {
|
||||
props: { msg }
|
||||
})
|
||||
expect(wrapper.text()).toContain(msg)
|
||||
})
|
||||
|
||||
it('increments count when button is clicked', async () => {
|
||||
const wrapper = mount(HelloWorld)
|
||||
const button = wrapper.find('button')
|
||||
await button.trigger('click')
|
||||
expect(wrapper.text()).toContain('count is 1')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
msg: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
@@ -21,15 +21,12 @@ const count = ref(0)
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>, the
|
||||
official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
<a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-gray-400">Raw JSON Editor</span>
|
||||
<button
|
||||
class="px-3 py-1 bg-purple-600 hover:bg-purple-500 rounded text-sm flex items-center gap-2"
|
||||
@click="validate"
|
||||
>
|
||||
<CheckCircle2 class="w-4 h-4" /> Validate Schema
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="jsonString"
|
||||
class="flex-1 w-full bg-gray-900 font-mono text-sm p-4 text-green-400 border border-gray-700 rounded focus:outline-none focus:border-blue-500 resize-none"
|
||||
spellcheck="false"
|
||||
@input="updateModel"
|
||||
></textarea>
|
||||
|
||||
<!-- Validation Feedback -->
|
||||
<div
|
||||
v-if="validationResult"
|
||||
:class="[
|
||||
'mt-2 p-3 rounded text-sm',
|
||||
validationResult.valid ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-300'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-bold mb-1">
|
||||
<component :is="validationResult.valid ? CheckCircle2 : AlertTriangle" class="w-4 h-4" />
|
||||
{{ validationResult.valid ? 'Valid Garmin Workout Schema' : 'Validation Errors Found' }}
|
||||
</div>
|
||||
<ul v-if="!validationResult.valid" class="list-disc pl-5 space-y-1">
|
||||
<li v-for="(err, i) in validationResult.errors" :key="i">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { CheckCircle2, AlertTriangle } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const jsonString = ref(JSON.stringify(props.modelValue, null, 2))
|
||||
const validationResult = ref(null)
|
||||
|
||||
// Sync prop changes to local string (if changed externally)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (JSON.stringify(newVal) !== jsonString.value) {
|
||||
// Avoid loop
|
||||
jsonString.value = JSON.stringify(newVal, null, 2)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const updateModel = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString.value)
|
||||
emit('update:modelValue', parsed)
|
||||
validationResult.value = null // Clear validation on edit
|
||||
} catch (_) {
|
||||
// Check invalid JSON, don't emit yet
|
||||
}
|
||||
}
|
||||
|
||||
const validate = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(jsonString.value)
|
||||
const res = await fetch('http://localhost:8000/workouts/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
validationResult.value = await res.json()
|
||||
} catch (err) {
|
||||
validationResult.value = { valid: false, errors: ['Invalid JSON Syntax'] }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="workout-visual-editor space-y-4">
|
||||
<div v-if="!isNested" class="bg-gray-800 p-4 rounded-lg mb-4">
|
||||
<h3 class="text-lg font-bold mb-2">Workout Metadata</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400">Name</label>
|
||||
<input
|
||||
:value="modelValue.workoutName"
|
||||
class="w-full bg-gray-700 rounded px-2 py-1 text-white border border-gray-600 focus:border-blue-500"
|
||||
@input="emit('update:modelValue', { ...modelValue, workoutName: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400">Sport Type</label>
|
||||
<select
|
||||
:value="modelValue.sportType?.sportTypeId"
|
||||
class="w-full bg-gray-700 rounded px-2 py-1 text-white border border-gray-600"
|
||||
@change="emit('update:modelValue', { ...modelValue, sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) } })"
|
||||
>
|
||||
<option :value="1">Running</option>
|
||||
<option :value="2">Cycling</option>
|
||||
<option :value="3">Swimming</option>
|
||||
<option :value="6">Fitness Equipment</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draggable Area -->
|
||||
<draggable
|
||||
:list="steps"
|
||||
item-key="stepId"
|
||||
class="space-y-4"
|
||||
handle=".drag-handle"
|
||||
group="steps"
|
||||
@change="emitUpdate"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="step-card bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<!-- Header / Drag Handle -->
|
||||
<div class="bg-gray-700 p-2 flex items-center justify-between cursor-move drag-handle">
|
||||
<div class="flex items-center gap-2">
|
||||
<GripVertical class="w-4 h-4 text-gray-400" />
|
||||
<span class="font-bold text-sm">
|
||||
{{ formatStepType(element) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="text-red-400 hover:text-red-300" @click="removeStep(index)">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="p-4">
|
||||
<!-- If Repeat Group -->
|
||||
<div
|
||||
v-if="element.type === 'RepeatGroupDTO'"
|
||||
class="nested-group border-l-2 border-yellow-500 pl-4"
|
||||
>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<label class="text-sm">Iterations:</label>
|
||||
<input
|
||||
v-model.number="element.numberOfIterations"
|
||||
type="number"
|
||||
class="w-20 bg-gray-900 rounded px-2 py-1"
|
||||
min="1"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recursive Component for Repeat Steps -->
|
||||
<WorkoutVisualEditor
|
||||
v-model:steps="element.workoutSteps"
|
||||
:is-nested="true"
|
||||
@update:steps="onNestedUpdate($event, index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Single Step -->
|
||||
<div v-else class="grid grid-cols-2 gap-4">
|
||||
<!-- Duration/Target Controls (Simplified) -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400">Duration Type</label>
|
||||
<select
|
||||
v-model="element.endCondition.conditionTypeId"
|
||||
class="w-full bg-gray-900 rounded px-2 py-1 text-sm mt-1"
|
||||
@change="emitUpdate"
|
||||
>
|
||||
<option :value="1">Distance</option>
|
||||
<option :value="2">Time</option>
|
||||
<option :value="5">Cadence</option>
|
||||
<option :value="7">Lap Button</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="element.endCondition.conditionTypeId === 2">
|
||||
<label class="block text-xs text-gray-400">Duration (Secs)</label>
|
||||
<input
|
||||
v-model.number="element.endConditionValue"
|
||||
type="number"
|
||||
class="w-full bg-gray-900 rounded px-2 py-1 text-sm mt-1"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- Add Buttons -->
|
||||
<div class="flex gap-2 justify-center mt-4">
|
||||
<button
|
||||
class="bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded text-sm flex items-center gap-1"
|
||||
@click="addStep('interval')"
|
||||
>
|
||||
<Plus class="w-4 h-4" /> Add Step
|
||||
</button>
|
||||
<button
|
||||
class="bg-yellow-600 hover:bg-yellow-500 px-3 py-1 rounded text-sm flex items-center gap-1"
|
||||
@click="addStep('repeat')"
|
||||
>
|
||||
<Repeat class="w-4 h-4" /> Add Repeat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { GripVertical, Trash2, Plus, Repeat } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) }, // Only used at top level
|
||||
steps: { type: Array, default: () => [] }, // Used for recursion
|
||||
isNested: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:steps'])
|
||||
|
||||
// For vuedraggable to work seamlessly, we emit the whole list
|
||||
const onDraggableChange = (newSteps) => {
|
||||
emit('update:steps', newSteps)
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (props.isNested) {
|
||||
emit('update:steps', props.steps)
|
||||
} else {
|
||||
emit('update:modelValue', props.modelValue)
|
||||
}
|
||||
}
|
||||
|
||||
const onNestedUpdate = (newSteps, index) => {
|
||||
const updatedSteps = [...props.steps]
|
||||
updatedSteps[index] = { ...updatedSteps[index], workoutSteps: newSteps }
|
||||
emit('update:steps', updatedSteps)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const formatStepType = (step) => {
|
||||
if (step.type === 'RepeatGroupDTO') return 'Repeat Group'
|
||||
const typeId = step.stepType?.stepTypeId
|
||||
if (typeId === 1) return 'Warmup'
|
||||
if (typeId === 2) return 'Cooldown'
|
||||
if (typeId === 3) return 'Interval'
|
||||
if (typeId === 4) return 'Recovery'
|
||||
return 'Step'
|
||||
}
|
||||
|
||||
const removeStep = (index) => {
|
||||
const updatedSteps = [...props.steps]
|
||||
updatedSteps.splice(index, 1)
|
||||
emit('update:steps', updatedSteps)
|
||||
}
|
||||
|
||||
const addStep = (type) => {
|
||||
const updatedSteps = [...props.steps]
|
||||
if (type === 'repeat') {
|
||||
updatedSteps.push({
|
||||
type: 'RepeatGroupDTO',
|
||||
stepOrder: updatedSteps.length + 1,
|
||||
numberOfIterations: 2,
|
||||
workoutSteps: []
|
||||
})
|
||||
} else {
|
||||
updatedSteps.push({
|
||||
type: 'ExecutableStepDTO',
|
||||
stepOrder: updatedSteps.length + 1,
|
||||
stepType: { stepTypeId: 3, stepTypeKey: 'interval' }, // Default Interval
|
||||
endCondition: { conditionTypeId: 2, conditionTypeKey: 'time' },
|
||||
endConditionValue: 300 // 5 mins
|
||||
})
|
||||
}
|
||||
emit('update:steps', updatedSteps)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,9 @@ body {
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@@ -73,4 +75,4 @@ h3 {
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,5 +3,5 @@ import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [vue()]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
resolveSnapshotPath: (testPath, snapshotExtension) => testPath + snapshotExtension,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user