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:
Moritz Graf
2026-01-01 20:37:10 +01:00
parent 8e55078c14
commit f3260d7dff
45 changed files with 5723 additions and 728 deletions
+355 -147
View File
@@ -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;
+20
View File
@@ -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')
})
})
+4 -7
View File
@@ -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>
+4 -2
View File
@@ -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;
}
}
+265 -65
View File
@@ -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
View File
@@ -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>