Latest state on privte laptop

This commit is contained in:
Moritz Graf 2026-01-02 17:40:18 +01:00
parent 715da2a816
commit 408cfa87d1
10 changed files with 701 additions and 267 deletions

View File

@ -2,6 +2,14 @@
FitMop is a full-stack fitness analytics and workout planning platform. It follows a decoupled Client-Server architecture with a FastAPI backend and a Vue.js frontend, orchestrated by a single startup script.
## Core Technology Stack & Tooling
> [!IMPORTANT]
> **Adherence to these tools is mandatory.**
> - **Backend Package Manager**: [`uv`](https://github.com/astral-sh/uv) (do NOT use pip directly).
> - **AI SDK**: [`google-genai`](https://pypi.org/project/google-genai/) (Standard Python SDK for Gemini).
> - **Frontend Tooling**: `Vite` + `npm`.
## System Overview
```mermaid
@ -124,4 +132,4 @@ sequenceDiagram
## Security & Reliability
- **CORS**: Restricted to localhost:5173.
- **Error Handling**: Global FastAPI handler ensures API never crashes silently.
- **Pre-flight**: `fitmop.sh` checks for mandatory environment variables before launch.
- **Pre-flight**: `Makefile` checks for mandatory environment variables before launch.

View File

@ -3,7 +3,7 @@
This document provides a set of global instructions and principles for the Gemini CLI to follow during our interactions.
**Reference:**
- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: ALWAYS refer to this document for the technical layout and data flows of the system.
- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: CRITICAL: You MUST read this file at the start of every session to understand the enforced tooling (`uv`, `google-genai`) and architectural patterns.
## Environment Management
- **Startup Rule:** ALWAYS start the application using `make run`. NEVER try to start individual services manually or use the old `fitmop.sh`.

View File

@ -1,5 +1,5 @@
import os
from typing import Any, Dict
from typing import Any, Dict, List
from dotenv import load_dotenv, set_key
@ -34,7 +34,7 @@ class EnvManager:
# Reload after setting
self.load_service_env(service, override=True)
def get_status(self, service: str, required_keys: list[str]) -> Dict[str, Any]:
def get_status(self, service: str, required_keys: List[str]) -> Dict[str, Any]:
"""Check if required keys are set for a service."""
# Reload just in case
self.load_service_env(service)

View File

@ -89,8 +89,19 @@ class RecommendationEngine:
Validation Rules:
- SportTypes: RUNNING=1, CYCLING=2
- StepTypes: WARMUP=1, COOLDOWN=2, INTERVAL=3, RECOVERY=4, REST=5, REPEAT=6
- EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7
- EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7, ITERATIONS=7
- TargetType: NO_TARGET=1, HEART_RATE=2, PACE=4 (Speed)
CRITICAL SCHEMA RULE:
Steps MUST use nested objects for types. Do NOT use flat IDs.
Example Step:
{
"type": "ExecutableStepDTO",
"stepOrder": 1,
"stepType": { "stepTypeId": 1, "stepTypeKey": "warmup" },
"endCondition": { "conditionTypeId": 2, "conditionTypeKey": "time" },
"endConditionValue": 600
}
"""
user_prompt = f"User Request: {prompt}"

View File

@ -0,0 +1,102 @@
import json
import os
import sys
from typing import Dict, Any
# Ensure we can import from src
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from common.env_manager import EnvManager
from garmin.workout_manager import WorkoutManager
def load_sample_workout() -> Dict[str, Any]:
"""Create a minimal valid workout for testing."""
return {
"workoutName": "Test Workout",
"description": "Base for AI test",
"sportType": {
"sportTypeId": 1,
"sportTypeKey": "running"
},
"workoutSegments": [
{
"segmentOrder": 1,
"sportType": {
"sportTypeId": 1,
"sportTypeKey": "running"
},
"workoutSteps": [
{
"type": "ExecutableStepDTO",
"stepOrder": 1,
"stepTypeId": 1,
"childStepId": None,
"endConditionId": 2,
"endConditionValue": 300,
"targetTypeId": 1,
"targetValueOne": 10.0,
"targetValueTwo": 12.0
}
]
}
]
}
def test_ai_modification():
print("\n--- Testing AI Workout Modifier ---")
# 1. Setup Environment
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
env = EnvManager(root_dir)
env.load_service_env("gemini")
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
print("SKIP: GEMINI_API_KEY not found in environment.")
return
# 2. Prepare Data
manager = WorkoutManager()
original_workout = load_sample_workout()
# 3. specific instruction
prompt = "Add a 10 minute cooldown at the end."
print(f"Prompt: {prompt}")
# 4. Execute AI Modification
try:
modified_workout = manager.generate_workout_json(prompt, existing_workout=original_workout)
# 5. Verify Structure (Schema Validation)
errors = manager.validate_workout_json(modified_workout)
if errors:
print(f"FAIL: Schema Validation Errors: {json.dumps(errors, indent=2)}")
print(f"Invalid Workout JSON: {json.dumps(modified_workout, indent=2)}")
return
# 6. Verify Content Logic
segments = modified_workout.get("workoutSegments", [])
if not segments:
print("FAIL: No segments found in modified workout.")
return
steps = segments[0].get("workoutSteps", [])
last_step = steps[-1] if steps else None
# Check if last step looks like a cooldown
# stepTypeId 4 = Cooldown (usually, or we check description/type)
is_cooldown = last_step and (last_step.get("stepTypeId") == 4 or "cool" in str(last_step).lower())
if is_cooldown:
print("PASS: Cooldown added successfully.")
print(f"Modified Steps Count: {len(steps)}")
else:
print("WARN: Could not strictly verify cooldown type, but schema is valid.")
print(f"Last Step: {json.dumps(last_step, indent=2)}")
except Exception as e:
print(f"FAIL: Exception during AI processing: {e}")
if __name__ == "__main__":
test_ai_modification()

View File

@ -0,0 +1,154 @@
<script setup>
import { ref } from 'vue'
import { Sparkles, X, Send, Loader2 } from 'lucide-vue-next'
const props = defineProps({
isOpen: Boolean,
workout: Object,
loading: Boolean
})
const emit = defineEmits(['close', 'ask'])
const prompt = ref('')
const handleAsk = () => {
if (!prompt.value.trim() || props.loading) return
emit('ask', prompt.value)
prompt.value = ''
}
</script>
<template>
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
<div class="modal-content ai-modal">
<div class="modal-header">
<div class="flex items-center gap-2">
<div class="p-2 bg-purple-600/20 rounded-lg text-purple-400">
<Sparkles :size="20" />
</div>
<h3 class="font-bold text-lg">AI Workout Enhancer</h3>
</div>
<button class="icon-btn" @click="emit('close')"><X :size="20" /></button>
</div>
<div class="modal-body">
<div class="ai-info-box">
<p class="text-sm text-gray-400 mb-4">
Tell the AI how you want to modify <strong>{{ workout?.workoutName }}</strong>.
For example: "Add 3 intervals of 1 minute fast", "Make it a recovery run", or "Add a 15 min warmup".
</p>
</div>
<div class="chat-input-container">
<textarea
v-model="prompt"
placeholder="Type your instruction here..."
class="ai-textarea"
:disabled="loading"
@keyup.enter.exact.prevent="handleAsk"
></textarea>
<div class="flex justify-end mt-3">
<button
class="primary-btn ai-submit-btn"
:disabled="!prompt.trim() || loading"
@click="handleAsk"
>
<Loader2 v-if="loading" class="w-4 h-4 animate-spin" />
<Send v-else :size="16" />
{{ loading ? 'Processing...' : 'Modify Workout' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content.ai-modal {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 16px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.modal-header {
padding: 1.25rem;
border-bottom: 1px solid #30363d;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 1.5rem;
}
.ai-info-box {
background: rgba(163, 113, 247, 0.05);
border: 1px solid rgba(163, 113, 247, 0.2);
padding: 1rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}
.ai-textarea {
width: 100%;
min-height: 120px;
background: #010409;
border: 1px solid #30363d;
border-radius: 12px;
color: #c9d1d9;
padding: 1rem;
font-family: inherit;
font-size: 1rem;
resize: none;
transition: border-color 0.2s;
}
.ai-textarea:focus {
outline: none;
border-color: #a371f7;
}
.ai-submit-btn {
background: linear-gradient(135deg, #a371f7 0%, #1f6feb 100%);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 10px;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.ai-submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(163, 113, 247, 0.3);
}
.ai-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,149 @@
<script setup>
import { ref, computed } from 'vue'
import { Eye, Edit2, Columns, Smartphone } from 'lucide-vue-next'
import WorkoutVisualEditor from './WorkoutVisualEditor.vue'
import WorkoutJsonEditor from './WorkoutJsonEditor.vue'
const props = defineProps({
original: {
type: Object,
required: true
},
modelValue: {
type: Object,
required: true
},
editorTab: {
type: String,
default: 'visual'
}
})
const emit = defineEmits(['update:modelValue'])
// Tab state for mobile or constrained views
const activeTab = ref('modified') // 'original' | 'modified' | 'split'
// Helper to determine if we can show split view (simple width check or just controlled by user preference)
const canSplit = ref(true)
const setTab = (tab) => {
activeTab.value = tab
}
const updateTabBasedOnWidth = () => {
if (window.innerWidth >= 768) {
if (activeTab.value !== 'split') activeTab.value = 'split'
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
updateTabBasedOnWidth()
window.addEventListener('resize', updateTabBasedOnWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateTabBasedOnWidth)
})
</script>
<template>
<div class="compare-view h-full flex flex-col">
<!-- View Controls (only visible on small screens or if needed) -->
<div class="mobile-tabs md:hidden flex bg-gray-900 border-b border-gray-800 p-1">
<button
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
:class="activeTab === 'original' ? 'bg-gray-800 text-white shadow' : 'text-gray-400 hover:text-white'"
@click="setTab('original')"
>
<Eye class="w-4 h-4" /> Original
</button>
<button
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
:class="activeTab === 'modified' ? 'bg-blue-900/40 text-blue-100 shadow' : 'text-gray-400 hover:text-white'"
@click="setTab('modified')"
>
<Edit2 class="w-4 h-4" /> Modified
</button>
</div>
<!-- MAIN CONTENT AREA -->
<div class="compare-container flex-1 overflow-hidden relative">
<!-- LEFT PANEL: ORIGINAL (READONLY) -->
<div
class="panel original-panel border-r border-gray-800 bg-gray-900/50 flex-1 min-w-0"
:class="{ 'hidden': activeTab === 'modified' }"
>
<div class="panel-header text-xs font-bold text-gray-500 uppercase tracking-wider p-3 border-b border-gray-800 flex justify-between">
<span>Original Version</span>
<span class="bg-gray-800 px-2 py-0.5 rounded text-gray-400">Read-only</span>
</div>
<div class="panel-content overflow-y-auto h-full p-4 relative">
<!-- Blocking Overlay for Interaction -->
<div class="absolute inset-0 z-10 cursor-not-allowed"></div>
<WorkoutVisualEditor
v-if="editorTab === 'visual'"
:modelValue="original"
:steps="original.workoutSegments[0].workoutSteps"
readonly
/>
<WorkoutJsonEditor
v-else
:modelValue="original"
readonly
/>
</div>
</div>
<!-- RIGHT PANEL: MODIFIED (EDITABLE) -->
<div
class="panel modified-panel flex-1 min-w-0"
:class="{ 'hidden': activeTab === 'original' }"
>
<div class="panel-header text-xs font-bold text-blue-400 uppercase tracking-wider p-3 border-b border-gray-800 bg-blue-900/10 flex justify-between">
<span>Working Copy</span>
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
</div>
<div class="panel-content overflow-y-auto h-full p-4">
<WorkoutVisualEditor
v-if="editorTab === 'visual'"
:modelValue="modelValue"
:steps="modelValue.workoutSegments[0].workoutSteps"
@update:modelValue="val => emit('update:modelValue', val)"
/>
<WorkoutJsonEditor
v-else
:modelValue="modelValue"
@update:modelValue="val => emit('update:modelValue', val)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.compare-container {
display: flex !important; /* Force flex to override any potential blocking */
flex-direction: row;
flex-wrap: nowrap;
}
.panel {
display: flex;
flex-direction: column;
}
/* Ensure Desktop defaults to split */
/* Ensure Desktop defaults to split */
@media (min-width: 768px) {
.md\:hidden {
display: none !important;
}
}
</style>

View File

@ -3,9 +3,22 @@ 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 }
modelValue: {
type: Object,
default: () => ({})
},
steps: {
type: Array,
required: true
},
readonly: {
type: Boolean,
default: false
},
isNested: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'update:steps'])
@ -101,9 +114,11 @@ const getStepColor = (typeId) => {
type="text"
placeholder="Workout Name"
class="bare-input"
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
:readonly="readonly"
@input="
emit('update:modelValue', {
!readonly && emit('update:modelValue', {
...modelValue,
workoutName: $event.target.value
})
@ -114,8 +129,11 @@ const getStepColor = (typeId) => {
<label>Type</label>
<select
:value="modelValue.sportType?.sportTypeId"
:disabled="readonly"
class="bare-select"
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
@change="
emit('update:modelValue', {
!readonly && emit('update:modelValue', {
...modelValue,
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
})
@ -136,6 +154,7 @@ const getStepColor = (typeId) => {
handle=".drag-handle"
group="steps"
class="steps-container"
:disabled="readonly"
@change="emitUpdate"
>
<template #item="{ element, index }">
@ -143,7 +162,7 @@ const getStepColor = (typeId) => {
<!-- REPEAT BLOCK -->
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
<div class="repeat-header">
<div class="drag-handle"><GripVertical :size="16" /></div>
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" /></div>
<div style="flex: 1; display: flex; align-items: center; gap: 0.5rem">
<Repeat :size="16" />
<span style="font-weight: 600">Repeat</span>
@ -152,11 +171,12 @@ const getStepColor = (typeId) => {
type="number"
class="bare-input"
min="1"
:readonly="readonly"
@change="emitUpdate"
/>
<span>times</span>
</div>
<button class="icon-btn delete" @click="removeStep(index)">
<button v-if="!readonly" class="icon-btn delete" @click="removeStep(index)">
<Trash2 :size="16" />
</button>
</div>
@ -165,6 +185,7 @@ const getStepColor = (typeId) => {
<WorkoutVisualEditor
v-model:steps="element.workoutSteps"
:is-nested="true"
:readonly="readonly"
@update:steps="onNestedUpdate($event, index)"
/>
</div>
@ -182,26 +203,27 @@ const getStepColor = (typeId) => {
<div class="step-content">
<div class="step-row-top">
<div class="drag-handle"><GripVertical :size="16" color="var(--text-muted)" /></div>
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" color="var(--text-muted)" /></div>
<!-- Step Type Select -->
<!-- Bind direct to stepTypeId if available, else nested -->
<!-- We need a computed setter or just force flat now -->
<select
v-model="element.stepTypeId"
class="bare-select type-select"
@change="emitUpdate"
:value="element.stepTypeId"
class="type-select"
:disabled="readonly"
@change="element.stepTypeId = Number($event.target.value); emitUpdate()"
>
<option :value="0">Warmup</option>
<option :value="1">Interval</option>
<option :value="2">Recover</option>
<option :value="5">Rest</option>
<option :value="3">Rest</option>
<option :value="4">Cooldown</option>
<option :value="3">Other</option>
<option :value="5">Other</option>
</select>
<div style="flex: 1"></div>
<button class="icon-btn delete" @click="removeStep(index)">
<button v-if="!readonly" class="icon-btn delete" @click="removeStep(index)">
<Trash2 :size="14" />
</button>
</div>
@ -210,7 +232,11 @@ const getStepColor = (typeId) => {
<!-- Duration/Target -->
<div class="detail-group">
<label>Duration Type</label>
<select v-model="element.endConditionId" class="bare-select" @change="emitUpdate">
<select
:value="element.endConditionId"
:disabled="readonly"
@change="element.endConditionId = Number($event.target.value); emitUpdate()"
>
<option :value="3">Distance</option>
<option :value="2">Time</option>
<option :value="1">Lap Button</option>
@ -223,6 +249,7 @@ const getStepColor = (typeId) => {
v-model.number="element.endConditionValue"
type="number"
class="bare-input"
:readonly="readonly"
@change="emitUpdate"
/>
</div>
@ -232,6 +259,7 @@ const getStepColor = (typeId) => {
v-model.number="element.endConditionValue"
type="number"
class="bare-input"
:readonly="readonly"
@change="emitUpdate"
/>
</div>
@ -243,7 +271,7 @@ const getStepColor = (typeId) => {
</draggable>
<!-- Add Buttons -->
<div class="add-controls">
<div v-if="!readonly" class="add-controls">
<button class="add-btn" @click="addStep('interval')"><Plus :size="16" /> Add Step</button>
<button class="add-btn" @click="addStep('repeat')"><Repeat :size="16" /> Add Repeat</button>
</div>
@ -340,16 +368,18 @@ const getStepColor = (typeId) => {
/* Typography Inputs */
.bare-select {
background: transparent;
border: none;
background: rgba(255, 255, 255, 0.05); /* Slight background for visibility */
border: 1px solid transparent;
color: var(--text-color);
font-size: 1.1rem; /* Larger */
font-size: 0.9rem;
cursor: pointer;
padding: 0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.bare-select:focus {
outline: none;
color: var(--accent-color);
border-color: var(--accent-color);
background: var(--bg-color);
}
.type-select {

View File

@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;

View File

@ -18,9 +18,12 @@ import {
} from 'lucide-vue-next'
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
import WorkoutCompareView from '../components/WorkoutCompareView.vue'
import AiChatModal from '../components/AiChatModal.vue'
// State
const viewMode = ref('browser') // 'browser' | 'editor'
const editorMode = ref('create') // 'create' | 'edit'
const editorTab = ref('visual') // 'visual' | 'json'
const sourceMode = ref('remote') // 'remote' | 'local'
const workouts = ref([])
@ -29,10 +32,12 @@ const syncing = ref(false)
const syncResult = ref(null)
// Editor State
const originalWorkout = ref(null) // Immutable reference for diffing
const workingWorkout = ref(null)
const aiPrompt = ref('')
const aiLoading = ref(false)
const aiError = ref('')
const showAiModal = ref(false)
// --- BROWSER ACTIONS ---
@ -90,7 +95,9 @@ const createNewWorkout = () => {
// If local, we need to know it's a new local file
isLocal: sourceMode.value === 'local'
}
originalWorkout.value = null
viewMode.value = 'editor'
editorMode.value = 'create'
editorTab.value = 'visual'
syncResult.value = null
}
@ -106,7 +113,14 @@ const duplicateWorkout = (workout) => {
workingWorkout.value = copy
workingWorkout.value.isLocal = true // Default to local for safety, or prompt user? Let's say local.
// For duplicate, we treat it as a NEW workout derived from old, so no diff?
// User requested "Diff for all edit/clone". So we should behave like 'edit'.
// But wait, if it's a clone, the "Original" is the source?
// Let's copy it to original too so user sees what they started with.
originalWorkout.value = JSON.parse(JSON.stringify(copy))
viewMode.value = 'editor'
editorMode.value = 'edit'
editorTab.value = 'visual'
syncResult.value = null
}
@ -160,7 +174,11 @@ const editWorkout = async (workout) => {
}
}
// Set Original for Diff View
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
viewMode.value = 'editor'
editorMode.value = 'edit'
editorTab.value = 'visual'
syncResult.value = null
} catch (e) {
@ -193,6 +211,8 @@ const saveOrSync = async () => {
if (data.status === 'SUCCESS') {
syncResult.value = { success: true, msg: 'Saved locally!' }
workingWorkout.value.filename = data.filename
// Update Original Reference on Save
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
} else {
syncResult.value = { success: false, error: 'Save failed' }
}
@ -214,6 +234,8 @@ const saveOrSync = async () => {
const data = await res.json()
if (data.success) {
syncResult.value = { success: true, msg: 'Uploaded to Garmin!' }
// Update Original Reference on Save
originalWorkout.value = JSON.parse(JSON.stringify(workingWorkout.value))
} else {
let errMsg = 'Upload failed: ' + (data.error || 'Unknown error')
if (data.details) {
@ -233,9 +255,7 @@ const saveOrSync = async () => {
}
// --- AI ACTIONS ---
const askAI = async () => {
if (!aiPrompt.value.trim()) return
const askAI = async (prompt) => {
aiLoading.value = true
aiError.value = ''
try {
@ -243,14 +263,14 @@ const askAI = async () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: aiPrompt.value,
prompt: prompt,
current_workout: workingWorkout.value
})
})
const data = await res.json()
if (data.workout) {
workingWorkout.value = data.workout
aiPrompt.value = '' // Clear on success
showAiModal.value = false // Close on success
} else if (data.error) {
aiError.value = data.error
}
@ -393,84 +413,60 @@ const getSportName = (workout) => {
</div>
<!-- EDITOR MODE -->
<div v-else class="flex flex-col h-full gap-4">
<!-- HEADER ROW -->
<div class="flex items-center gap-4 bg-gray-900/50 p-2 rounded-xl border border-gray-800">
<button
class="flex items-center gap-2 px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
@click="viewMode = 'browser'"
>
<ArrowLeft class="w-5 h-5" />
<span class="font-medium">Back</span>
</button>
<input
v-model="workingWorkout.workoutName"
class="bg-transparent text-xl font-bold focus:outline-none border-b border-transparent focus:border-blue-500 px-2 py-1 min-w-[200px]"
placeholder="Workout Name"
/>
<div class="flex-1"></div>
<!-- Editor Toggle -->
<div class="bg-gray-800 p-0.5 rounded-lg flex text-xs">
<button
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
:class="
editorTab === 'visual'
? 'bg-gray-700 text-white shadow'
: 'text-gray-400 hover:text-white'
"
@click="editorTab = 'visual'"
>
<LayoutDashboard class="w-3 h-3" /> Visual
</button>
<button
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
:class="
editorTab === 'json'
? 'bg-gray-700 text-white shadow'
: 'text-gray-400 hover:text-white'
"
@click="editorTab = 'json'"
>
<Code class="w-3 h-3" /> JSON
<div v-else class="flex flex-col h-full gap-4 editor-view">
<!-- HARMONIZED TOOLBAR -->
<div class="editor-toolbar">
<div class="toolbar-left">
<button class="minimal-btn" @click="viewMode = 'browser'">
<ArrowLeft class="w-5 h-5" />
</button>
<div class="toolbar-divider"></div>
<input
v-model="workingWorkout.workoutName"
class="header-title-input"
placeholder="Workout Name"
/>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition-all"
:disabled="syncing"
@click="saveOrSync"
>
<Cloud v-if="!syncing" class="w-4 h-4" />
<Loader2 v-else class="w-4 h-4 animate-spin" />
{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}
</button>
</div>
<div class="toolbar-right">
<!-- View Mode Toggle (Segmented) -->
<div class="segmented-control">
<button
:class="{ active: editorTab === 'visual' }"
@click="editorTab = 'visual'"
>
<LayoutDashboard class="w-4 h-4" />
<span>Visual</span>
</button>
<button
:class="{ active: editorTab === 'json' }"
@click="editorTab = 'json'"
>
<Code class="w-4 h-4" />
<span>JSON</span>
</button>
</div>
<!-- AI BAR & ERRORS -->
<div class="flex items-center gap-2">
<div
:class="[
'flex-1 flex items-center gap-3 bg-gray-900 border border-gray-700 px-4 py-2 rounded-xl focus-within:border-purple-500 focus-within:ring-1 focus-within:ring-purple-500/50 transition-all shadow-sm',
aiLoading ? 'opacity-75' : ''
]"
>
<Sparkles class="w-5 h-5 text-purple-400" />
<input
v-model="aiPrompt"
class="ai-prompt-input bg-transparent border-none focus:outline-none text-sm w-full placeholder-gray-500"
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 px-3 py-1 bg-purple-600/20 text-purple-300 hover:bg-purple-600 hover:text-white text-xs font-bold rounded uppercase tracking-wider transition-colors"
:disabled="!aiPrompt.trim() || aiLoading"
@click="askAI"
<div class="toolbar-divider"></div>
<!-- AI Button -->
<button
class="toolbar-btn ai-enhance-btn"
@click="showAiModal = true"
>
{{ aiLoading ? 'Thinking...' : 'Generate' }}
<Sparkles class="w-4 h-4" />
<span>Enhance</span>
</button>
<!-- Sync/Save Button -->
<button
class="toolbar-btn primary-btn sync-btn"
:disabled="syncing"
@click="saveOrSync"
>
<Cloud v-if="!syncing" class="w-4 h-4" />
<Loader2 v-else class="w-4 h-4 animate-spin" />
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
</button>
</div>
</div>
@ -517,17 +513,40 @@ const getSportName = (workout) => {
</div>
<!-- EDITOR CONTENT -->
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative">
<WorkoutVisualEditor
v-if="editorTab === 'visual'"
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative p-4 overflow-y-auto">
<!-- COMPARE VIEW (For Edits) -->
<WorkoutCompareView
v-if="editorMode === 'edit'"
:original="originalWorkout"
v-model="workingWorkout"
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
:editorTab="editorTab"
/>
<!-- SINGLE VIEW (For New Workouts) -->
<div v-else class="h-full">
<WorkoutJsonEditor v-model="workingWorkout" />
<WorkoutVisualEditor
v-if="editorTab === 'visual'"
v-model="workingWorkout"
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
/>
<WorkoutJsonEditor
v-else
v-model="workingWorkout"
/>
</div>
</div>
</div>
<!-- AI MODAL -->
<AiChatModal
:is-open="showAiModal"
:workout="workingWorkout"
:loading="aiLoading"
@close="showAiModal = false"
@ask="askAI"
/>
</div>
</template>
@ -553,177 +572,134 @@ const getSportName = (workout) => {
padding-bottom: 2rem;
}
.workout-card {
.editor-view {
background: var(--bg-color);
border-radius: 16px;
overflow: hidden;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
z-index: 10;
}
.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 {
.toolbar-left, .toolbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(163, 113, 247, 0.1); /* Subtle purple tint */
padding: 0.5rem 1rem;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 0.25rem;
}
.minimal-btn {
background: transparent;
color: var(--text-muted);
border: none;
padding: 0.5rem;
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);
.minimal-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-color);
}
.header-title-input {
background: transparent;
border: none;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
width: 250px;
}
.header-title-input:focus {
outline: none;
background: rgba(255, 255, 255, 0.03);
}
/* Segmented Control */
.segmented-control {
display: flex;
background: #010409;
padding: 2px;
border-radius: 10px;
border: 1px solid var(--border-color);
}
.segmented-control button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.segmented-control button.active {
background: var(--border-color);
color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.segmented-control button:hover:not(.active) {
color: var(--text-color);
}
/* Toolbar Buttons */
.toolbar-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
}
.ai-enhance-btn {
background: rgba(163, 113, 247, 0.1);
color: #a371f7;
border: 1px solid rgba(163, 113, 247, 0.2);
}
.ai-enhance-btn:hover {
background: rgba(163, 113, 247, 0.2);
border-color: rgba(163, 113, 247, 0.4);
}
.sync-btn {
background: linear-gradient(135deg, #1f6feb 0%, #094cb5 100%);
border: none;
color: #fff;
}
.sync-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
}
.sync-res {