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

17
.gitignore vendored
View File

@ -3,11 +3,19 @@ __pycache__/
*.py[cod]
*$py.class
venv/
.env
.env_garmin
.venv/
.coverage
htmlcov/
.pytest_cache/
.ruff_cache/
# Environment files
.env
.env_*
!.env.example
# Logs
*.log
# Node
node_modules/
@ -16,8 +24,9 @@ dist-ssr/
*.local
# Project specific
data/local/*
!data/local/.gitkeep
backend/data/local/*
!backend/data/local/.gitkeep
backend/.garth/
# IDEs
.vscode/

127
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,127 @@
# FitMop Architecture
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.
## System Overview
```mermaid
graph TD
User((User))
subgraph Frontend [Vue.js Frontend]
Dashboard[App.vue Dashboard]
Analyze[AnalyzeView AI Analyst]
Plan[PlanView Workout Builder]
end
subgraph Backend [FastAPI Backend]
API[main.py API Layer]
GarminSvc[Garmin Service]
AISvc[Recommendation Engine]
Storage[Local JSON Storage]
end
subgraph External [External APIs]
GarminAPI[(Garmin Connect)]
GeminiAPI[(Google Gemini AI)]
end
User <--> Dashboard
User <--> Analyze
User <--> Plan
Dashboard <--> API
Analyze <--> API
Plan <--> API
API <--> GarminSvc
API <--> AISvc
API <--> Storage
GarminSvc <--> GarminAPI
GarminSvc <--> Storage
AISvc <--> GeminiAPI
AISvc <--> Storage
```
## Backend Components
The backend is built with **FastAPI** and located in `backend/src`.
### 1. API Layer ([main.py](file:///Users/moritz/src/fitness_antigravity/backend/src/main.py))
- Defines all REST endpoints.
- Implements global exception handling and structured logging.
- Manages CORS and service initialization.
### 2. Garmin Service ([backend/src/garmin/](file:///Users/moritz/src/fitness_antigravity/backend/src/garmin/))
- **[client.py](file:///Users/moritz/src/fitness_antigravity/backend/src/garmin/client.py)**: Low-level wrapper for Garmin Connect, handling authentication and MFA.
- **[sync.py](file:///Users/moritz/src/fitness_antigravity/backend/src/garmin/sync.py)**: Higher-level logic for fetching activities and synchronizing them with local storage.
- **[workout.py](file:///Users/moritz/src/fitness_antigravity/backend/src/garmin/workout.py)**: Logic for translating internal workout models to Garmin JSON format.
### 3. Recommendation Engine ([backend/src/recommendations/](file:///Users/moritz/src/fitness_antigravity/backend/src/recommendations/))
- **[engine.py](file:///Users/moritz/src/fitness_antigravity/backend/src/recommendations/engine.py)**: Interfaces with the Google Gemini API using the `google-genai` SDK. Implements AGENTIC behavior with function calling.
- **[tools.py](file:///Users/moritz/src/fitness_antigravity/backend/src/recommendations/tools.py)**: Set of tools provided to the AI Agent (e.g., `get_weekly_stats`, `get_user_profile`).
### 4. Storage ([backend/data/local/](file:///Users/moritz/src/fitness_antigravity/backend/data/local/))
- Data is persisted as structured JSON files for simplicity and privacy.
- `activities_*.json`: Cached Garmin activity data.
- `user_profile.json`: Mock user goals and bio.
## Frontend Components
The frontend is a **Vue.js 3** Single Page Application located in `frontend/src`.
### 1. Main Application ([App.vue](file:///Users/moritz/src/fitness_antigravity/frontend/src/App.vue))
- Acts as the central hub and dashboard.
- Orchestrates global states like authentication and time horizons.
### 2. Analyze View ([AnalyzeView.vue](file:///Users/moritz/src/fitness_antigravity/frontend/src/views/AnalyzeView.vue))
- Visualizes fitness trends using Chart.js.
- Hosts the AI Analyst chat interface.
### 3. Plan View ([PlanView.vue](file:///Users/moritz/src/fitness_antigravity/frontend/src/views/PlanView.vue))
- Integrated environment for creating Garmin workouts.
- Combines AI-assisted creation with manual editing.
- Uses **[WorkoutVisualEditor.vue](file:///Users/moritz/src/fitness_antigravity/frontend/src/components/WorkoutVisualEditor.vue)** for drag-and-drop step management.
## Key Data Flows
### Data Synchronization
```mermaid
sequenceDiagram
participant FE as Frontend
participant BE as Backend
participant GR as Garmin API
participant LS as Local Storage
FE->>BE: POST /sync
BE->>BE: Check Session
BE->>GR: Fetch Activities
GR-->>BE: Activity Data
BE->>LS: Save to JSON
BE-->>FE: Sync Count
FE->>BE: GET /analyze/dashboard
BE->>LS: Read JSON
LS-->>BE: Activities
BE-->>FE: Aggregated Stats
```
### AI Recommendation Loop
```mermaid
sequenceDiagram
participant User
participant BE as Backend (AI Engine)
participant GM as Gemini API
participant LS as Local Storage
User->>BE: Send Message
BE->>GM: Send Prompt + Tools
GM->>BE: Call Tool: get_weekly_stats
BE->>LS: Read Data
LS-->>BE: Stats
BE-->>GM: Tool Result
GM-->>BE: Final Motivation/Advice
BE-->>User: Display Response
```
## 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.

32
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,32 @@
# Contributing to FitMop
Welcome to FitMop! To maintain a high standard of code quality and maintainability, please follow these best practices.
## Development Standards
### 1. Code Style & Linting
We use automated tools to enforce a consistent style.
- **Backend**: We use `ruff` for linting and formatting. Run it with `uv run ruff check . --fix`.
- **Frontend**: We use `ESLint` and `Prettier`. Run it with `npm run lint` and `npm run format`.
### 2. Error Handling
- Never use bare `except:` clauses. Always specify the exception type.
- Backend API errors should follow the structured JSON format provided by the global exception handler in `main.py`.
### 3. Logging
- Avoid `print()` statements in production code.
- Use the standard `logging` library in the backend.
### 4. Type Safety
- Use Pydantic models for API request and response validation.
- Preferred frontend language is JavaScript with JSDoc or TypeScript (future goal).
### 5. Running the Project
Always start the project using the orchestrator script:
```bash
bash fitmop.sh
```
## Testing
- **Backend**: Use `pytest`. Run `uv run pytest`.
- **Frontend**: Foundation for `vitest` is coming soon.

33
GEMINI.md Normal file
View File

@ -0,0 +1,33 @@
# Gemini Global Configuration (GEMINI.md)
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.
## Environment Management
- **Startup Rule:** ALWAYS start the application using `bash fitmop.sh`. NEVER try to start individual services (uvicorn, npm) manually.
- **Shutdown:** Use `Ctrl+C` to stop the services when running via `fitmop.sh`.
## Code Quality & Standards
### 1. Linting & Formatting
ALWAYS run linters and formatters before completing a task:
- **Backend (Ruff)**: `uv run ruff check . --fix`
- **Frontend (ESLint/Prettier)**: `npm run lint` and `npm run format` (in `/frontend`)
- **Action**: Fix ALL errors and warnings before proceeding to verification.
### 2. Testing
ALWAYS run the full test suite to ensure no regressions:
- **Backend**: `uv run pytest` (in `/backend`)
- **Frontend**: `npm run test` (in `/frontend`)
- **Action**: Every new feature or major refactor MUST include or update relevant tests.
### 3. Error Handling & Logging
- **No Print Statements**: Use `logging` in Python and `console.error` (with prefix) in Vue.
- **Fail Fast**: Let exceptions bubble up to the global handler in `backend/src/main.py`.
- **Validation**: Use Pydantic models for all API requests and responses.
### 4. Documentation
- Refer to `CONTRIBUTING.md` for detailed guidelines.
- Keep `task.md` and `implementation_plan.md` updated throughout the task.

View File

@ -1,43 +1,36 @@
# Fitness Antigravity
# Fitness Antigravity (FitMop)
Your personal fitness coach powered by Gemini CLI, Garmin Connect, and Withings.
Your personal fitness coach powered by **Google Gemini AI**, **Garmin Connect**, and **Withings**. FitMop provides a unified dashboard for analyzing activity trends, creating advanced Garmin workouts, and chatting with an AI coach that has direct access to your training data.
## Features
- **Data Sync**: Sync your Garmin workouts and Withings weightings locally.
- **Visualization**: Beautiful Vue JS frontend to track your progress.
- **Strength Training**: Create custom Garmin strength workouts locally.
- **Gemini Recommendations**: Get AI-driven training advice for endurance and strength.
- **100% Test Coverage**: Robust Python backend with full unit test coverage.
- **📊 AI-Driven Analytics**: Deep insights into your training history via the Gemini 2.0 Flash engine.
- **🔄 Garmin Sync**: Automated local synchronization of your Garmin activities and profile.
- **🏋️ Advanced Workout Builder**: Drag-and-drop visual editor for creating complex Garmin strength and endurance workouts.
- **🤖 AGENTIC AI Coach**: Chat with an AI that performs function calls to analyze your data and suggest improvements.
- **🛡️ Modern Standards**: 100% test pass rate, strict linting (Ruff/ESLint), and global error handling.
## Project Structure
- `backend/`: Python source code and unit tests.
- `frontend/`: Vue JS web application.
- `data/local/`: Local storage for synced fitness data.
- `docs/`: Detailed documentation and setup guides.
- **[backend/](file:///Users/moritz/src/fitness_antigravity/backend/)**: FastAPI service, Garmin integration, and Recommendation Engine.
- **[frontend/](file:///Users/moritz/src/fitness_antigravity/frontend/)**: Vue.js 3 Single Page Application.
- **[data/local/](file:///Users/moritz/src/fitness_antigravity/backend/data/local/)**: Local JSON storage for privacy-first training data.
## Documentation
- **[System Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)** - Essential reading for developers.
- **[Software Best Practices](file:///Users/moritz/src/fitness_antigravity/CONTRIBUTING.md)** - Guidelines for linting, testing, and error handling.
- **[Garmin Setup](file:///Users/moritz/src/fitness_antigravity/docs/garmin_login.md)** - How to connect your account.
## Setup Instructions
### Quick Start (FitMop)
### Quick Start
The easiest way to run the entire project is using the **FitMop** orchestrator:
1. Run `bash fitmop.sh`.
2. Open `http://localhost:5173` in your browser.
3. Enter your Garmin credentials in the UI. They will be stored securely in `.env_garmin`.
3. Complete the setup guide on the dashboard.
### Manual Backend Setup
1. Navigate to `backend/`.
2. Install `uv` if you haven't: `brew install uv`.
3. Install dependencies: `uv sync`.
### Commands
- **Lint Backend**: `cd backend && uv run ruff check . --fix`
- **Lint Frontend**: `cd frontend && npm run lint`
- **Run Tests**: `npm run test` (frontend) / `uv run pytest` (backend)
### Frontend
1. Navigate to `frontend/`.
2. Install dependencies: `npm install`.
3. Start the dev server: `npm run dev`.
## Usage
- Run sync scripts manually to update local data.
- Use the CLI/TUI in `backend/` to create workouts and get recommendations.
## Documentation
- [Garmin Login Setup](docs/garmin_login.md)
- [Withings Integration](docs/withings_login.md)
- [User Manual](docs/user_manual.md)
---
*Built with ❤️ for better fitness through data.*

View File

@ -1,13 +0,0 @@
INFO: Started server process [12583]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:64979 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:64999 - "GET /auth/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:65001 - "GET /settings HTTP/1.1" 200 OK
INFO: 127.0.0.1:65001 - "GET /activities HTTP/1.1" 200 OK
INFO: 127.0.0.1:65001 - "GET /recommendation HTTP/1.1" 200 OK
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [12583]

View File

@ -1,7 +1,7 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
description = "FitMop Backend API"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
@ -13,6 +13,7 @@ dependencies = [
"pydantic>=2.0.0",
"python-dotenv>=1.2.1",
"uvicorn>=0.40.0",
"ruff>=0.14.10",
]
[dependency-groups]
@ -21,3 +22,22 @@ dev = [
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
]
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
ignore = []
fixable = ["ALL"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
python_files = "test_*.py"

View File

@ -1,13 +1,14 @@
import sys
import os
import sys
# Add src to path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
from garmin.sync import GarminSync
from garmin.client import GarminClient
from garmin.sync import GarminSync
from garmin.workout import GarminWorkoutCreator, StrengthWorkout, WorkoutStep
def get_common_exercises():
"""Extract common exercises from local Garmin history."""
client = GarminClient() # path helper

View File

@ -1,13 +1,14 @@
import sys
import os
import sys
# Add src to path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
from garmin.sync import GarminSync
from garmin.client import GarminClient
from garmin.sync import GarminSync
from recommendations.engine import RecommendationEngine
def main():
print("🤖 Gemini Fitness AI")

View File

@ -1,6 +1,5 @@
import sys
import os
from datetime import date
import sys
# Add src to path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
@ -8,6 +7,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"
from garmin.client import GarminClient
from garmin.sync import GarminSync
def main():
print("🚀 Initializing Garmin Sync...")
client = GarminClient()

View File

@ -1,7 +1,9 @@
import os
from typing import Dict, Optional, List, Any
from typing import Any, Dict
from dotenv import load_dotenv, set_key
class EnvManager:
"""Manages multiple specialized .env files."""

View File

@ -0,0 +1,42 @@
import json
import os
from typing import Any, Dict
class SettingsManager:
"""Manages persistent user settings and profile data."""
def __init__(self, storage_dir: str = "data/local"):
self.storage_dir = storage_dir
self.profile_path = os.path.join(storage_dir, "user_profile.json")
os.makedirs(storage_dir, exist_ok=True)
def load_profile(self) -> Dict[str, Any]:
"""Load user profile settings."""
if not os.path.exists(self.profile_path):
return {
"fitness_goals": "",
"dietary_preferences": "",
"focus_days": []
}
try:
with open(self.profile_path, "r") as f:
return json.load(f)
except Exception:
return {}
def save_profile(self, profile_data: Dict[str, Any]):
"""Save user profile settings."""
# Merge with existing
current = self.load_profile()
current.update(profile_data)
with open(self.profile_path, "w") as f:
json.dump(current, f, indent=2)
def get_context_string(self) -> str:
"""Get a formatted context string for AI prompts."""
p = self.load_profile()
goals = p.get("fitness_goals", "Improve overall fitness")
diet = p.get("dietary_preferences", "None")
return f"User Fitness Goals: {goals}\nDietary Preferences: {diet}"

View File

@ -1,12 +1,13 @@
import os
import logging
from typing import Optional, List, Dict, Any
import os
from datetime import date
from garminconnect import Garmin
from typing import Any, Dict, List, Optional
import garth
from garth.sso import login as garth_login, resume_login
from garth.exc import GarthHTTPError
from dotenv import load_dotenv
from garminconnect import Garmin
from garth.sso import login as garth_login
from garth.sso import resume_login
load_dotenv()
@ -36,14 +37,15 @@ class GarminClient:
try:
# 1. Try to resume from token store
token_path = os.path.join(self.token_store, "oauth1_token.json")
if not force_login and not self._temp_client_state and os.path.exists(token_path):
if not self._temp_client_state and os.path.exists(token_path):
try:
# Clean up empty files immediately
if os.path.getsize(token_path) == 0:
logger.warning("Empty token file detected. Cleaning up.")
for f in ["oauth1_token.json", "oauth2_token.json"]:
p = os.path.join(self.token_store, f)
if os.path.exists(p): os.remove(p)
if os.path.exists(p):
os.remove(p)
return "FAILURE"
logger.info("Attempting to resume Garmin session.")
@ -57,7 +59,10 @@ class GarminClient:
if "JSON" in str(e) or "Expecting value" in str(e):
for f in ["oauth1_token.json", "oauth2_token.json"]:
p = os.path.join(self.token_store, f)
if os.path.exists(p): os.remove(p)
if os.path.exists(p):
os.remove(p)
# After cleanup, we will naturally fall through to Step 3
# since token_path no longer exists
# 2. Handle MFA completion
if mfa_code and self._temp_client_state:
@ -93,6 +98,10 @@ class GarminClient:
# 3. Start new login (ONLY if mfa_code is provided or force_login is True)
if not force_login and not mfa_code:
# If we have no tokens and no force_login, we can't proceed to Step 3
# UNLESS we just failed a resume and cleaned up (in which case we could still proceed if we have creds)
# For simplicity, if we have email/pass, we always allow falling through to Step 3 if force_login or auto-fallback
if not self.email or not self.password:
if self._temp_client_state:
return "MFA_REQUIRED"
return "FAILURE"

View File

@ -1,9 +1,11 @@
import json
import os
from datetime import date, timedelta
from typing import List, Dict, Any
from datetime import date, datetime, timedelta
from typing import Any, Dict, List
from .client import GarminClient
class GarminSync:
"""Logic to sync Garmin data to local storage."""
@ -79,9 +81,10 @@ class GarminSync:
delta = (today - start_sync).days + 1 # include today
# Cap at 1 day minimum if delta is 0 or negative
if delta < 1: delta = 1
if delta < 1:
delta = 1
start_date = today - timedelta(days=delta)
today - timedelta(days=delta)
# Ensure we cover the gap
# Actually easier: just pass start_date explicit to get_activities,
# but our current sync_activities takes 'days'.
@ -133,7 +136,7 @@ class GarminSync:
try:
act_date = datetime.strptime(start_local.split(" ")[0], "%Y-%m-%d").date()
except:
except Exception:
continue
if act_date < cutoff_date:
@ -148,6 +151,8 @@ class GarminSync:
duration_hours = act.get("duration", 0) / 3600.0
# Clean type key
# ... existing logic ...
raw_type = act.get("activityType", {}).get("typeKey", "other")
weekly_data[week_key][raw_type] += duration_hours
@ -162,23 +167,30 @@ class GarminSync:
k = type_key.lower()
# Cycling (Greens/Teals)
if "cycling" in k or "virtual_ride" in k or "spinning" in k:
if "virtual" in k: return "#3fb950" # bright green
if "indoor" in k: return "#2ea043" # darker green
if "virtual" in k:
return "#3fb950" # bright green
if "indoor" in k:
return "#2ea043" # darker green
return "#56d364" # standard green
# Swimming (Blues)
if "swimming" in k or "lap_swimming" in k:
if "open_water" in k: return "#1f6feb" # deep blue
if "open_water" in k:
return "#1f6feb" # deep blue
return "#58a6ff" # lighter blue
# Yoga/Pilates (Purples/Pinks)
if "yoga" in k: return "#d2a8ff"
if "pilates" in k: return "#bc8cff"
if "breathing" in k: return "#e2c5ff"
if "yoga" in k:
return "#d2a8ff"
if "pilates" in k:
return "#bc8cff"
if "breathing" in k:
return "#e2c5ff"
# Running (Oranges/Reds)
if "running" in k or "treadmill" in k:
if "trail" in k: return "#bf4b00" # Dark orange
if "trail" in k:
return "#bf4b00" # Dark orange
return "#fa4549" # Redish
# Strength (Gold/Yellow per plan change, or keep distinct)
@ -186,8 +198,10 @@ class GarminSync:
return "#e3b341" # Gold
# Hiking/Walking
if "hiking" in k: return "#d29922" # Brown/Orange
if "walking" in k: return "#8b949e" # Grey
if "hiking" in k:
return "#d29922" # Brown/Orange
if "walking" in k:
return "#8b949e" # Grey
return "#8b949e" # Default Grey
@ -207,3 +221,69 @@ class GarminSync:
"labels": sorted_weeks,
"datasets": datasets
}
def get_dashboard_stats(self) -> Dict[str, Any]:
"""
Get aggregated stats for the dashboard:
- Last 7 days total hours & trend vs previous 7 days.
- Last 7 days activity breakdown (e.g. 3x Cycling).
"""
activities = self.load_local_activities()
today = date.today()
last_7_start = today - timedelta(days=6) # Inclusive of today = 7 days
prev_7_start = last_7_start - timedelta(days=7)
prev_7_end = last_7_start - timedelta(days=1)
# Buckets
current_period = {"hours": 0.0, "count": 0, "breakdown": {}}
prev_period = {"hours": 0.0, "count": 0}
strength_count = 0
for act in activities:
start_local = act.get("startTimeLocal", "")
if not start_local:
continue
try:
act_date = datetime.strptime(start_local.split(" ")[0], "%Y-%m-%d").date()
except Exception:
continue
dur_hours = act.get("duration", 0) / 3600.0
type_key = act.get("activityType", {}).get("typeKey", "unknown")
# Last 7 Days
if last_7_start <= act_date <= today:
current_period["hours"] += dur_hours
current_period["count"] += 1
current_period["breakdown"][type_key] = current_period["breakdown"].get(type_key, 0) + 1
if "strength" in type_key.lower():
strength_count += 1
# Previous 7 Days
elif prev_7_start <= act_date <= prev_7_end:
prev_period["hours"] += dur_hours
prev_period["count"] += 1
# Trend Calculation
trend_pct = 0
if prev_period["hours"] > 0:
trend_pct = ((current_period["hours"] - prev_period["hours"]) / prev_period["hours"]) * 100
# Format Breakdown
breakdown_list = []
for k, v in current_period["breakdown"].items():
# Format nicely: "running" -> "Running"
label = k.replace("_", " ").title()
breakdown_list.append({"label": label, "count": v})
return {
"summary": {
"total_hours": round(current_period["hours"], 1),
"trend_pct": round(trend_pct, 1),
"period_label": "Last 7 Days"
},
"breakdown": breakdown_list,
"strength_sessions": strength_count
}

View File

@ -0,0 +1,150 @@
from typing import Any, Dict, List
class WorkoutValidator:
"""Validator for Garmin Workout JSON structure."""
# Extracted from garminconnect/workout.py
class SportType:
RUNNING = 1
CYCLING = 2
SWIMMING = 3
WALKING = 4
MULTI_SPORT = 5
FITNESS_EQUIPMENT = 6
HIKING = 7
OTHER = 8
class StepType:
WARMUP = 1
COOLDOWN = 2
INTERVAL = 3
RECOVERY = 4
REST = 5
REPEAT = 6
class ConditionType:
DISTANCE = 1
TIME = 2
HEART_RATE = 3
CALORIES = 4
CADENCE = 5
POWER = 6
ITERATIONS = 7
class TargetType:
NO_TARGET = 1
HEART_RATE = 2
CADENCE = 3
SPEED = 4
POWER = 5
OPEN = 6
@staticmethod
def get_constants() -> Dict[str, Any]:
"""Return constants for frontend consumption."""
return {
"SportType": {
"RUNNING": 1, "CYCLING": 2, "SWIMMING": 3, "WALKING": 4,
"FITNESS_EQUIPMENT": 6, "HIKING": 7, "OTHER": 8
},
"StepType": {
"WARMUP": 1, "COOLDOWN": 2, "INTERVAL": 3,
"RECOVERY": 4, "REST": 5, "REPEAT": 6
},
"ConditionType": {
"DISTANCE": 1, "TIME": 2, "HEART_RATE": 3, "ITERATIONS": 7
},
"TargetType": {
"NO_TARGET": 1, "HEART_RATE": 2, "CADENCE": 3, "SPEED": 4, "POWER": 5
}
}
@classmethod
def validate_workout(cls, workout: Dict[str, Any]) -> List[str]:
"""
Validate a workout structure.
Returns a list of error messages. Empty list means valid.
"""
errors = []
# 1. Structure Check
required_fields = ["workoutName", "sportType", "workoutSegments"]
for field in required_fields:
if field not in workout:
errors.append(f"Missing required field: {field}")
if errors:
return errors
# 2. Sport Type
sport_type = workout.get("sportType", {})
if "sportTypeId" not in sport_type:
errors.append("Missing sportType.sportTypeId")
# 3. Segments
segments = workout.get("workoutSegments", [])
if not isinstance(segments, list) or len(segments) == 0:
errors.append("workoutSegments must be a non-empty list")
for i, segment in enumerate(segments):
seg_steps = segment.get("workoutSteps", [])
if not seg_steps:
errors.append(f"Segment {i} has no steps")
else:
errors.extend(cls._validate_steps(seg_steps, context=f"Segment {i}"))
return errors
@classmethod
def _validate_steps(cls, steps: List[Dict[str, Any]], context: str) -> List[str]:
errors = []
for i, step in enumerate(steps):
step_ctx = f"{context} Step {i+1}"
# Type Check
s_type = step.get("type")
if s_type == "ExecutableStepDTO":
errors.extend(cls._validate_executable_step(step, step_ctx))
elif s_type == "RepeatGroupDTO":
errors.extend(cls._validate_repeat_group(step, step_ctx))
else:
errors.append(f"{step_ctx}: Unknown step type '{s_type}'")
return errors
@classmethod
def _validate_executable_step(cls, step: Dict[str, Any], context: str) -> List[str]:
errors = []
# StepType
step_type = step.get("stepType", {})
if not step_type or "stepTypeId" not in step_type:
errors.append(f"{context}: Missing stepType or stepTypeId")
elif step_type["stepTypeId"] not in [1, 2, 3, 4, 5, 6]:
errors.append(f"{context}: Invalid stepTypeId {step_type['stepTypeId']}")
# EndCondition
end_cond = step.get("endCondition", {})
if not end_cond or "conditionTypeId" not in end_cond:
errors.append(f"{context}: Missing endCondition")
return errors
@classmethod
def _validate_repeat_group(cls, step: Dict[str, Any], context: str) -> List[str]:
errors = []
# Iterations
iterations = step.get("numberOfIterations")
if not isinstance(iterations, int) or iterations < 1:
errors.append(f"{context}: Invalid iterations {iterations}")
# Check steps inside (recursive)
sub_steps = step.get("workoutSteps", [])
if not sub_steps:
errors.append(f"{context}: Repeat group empty")
else:
errors.extend(cls._validate_steps(sub_steps, context))
return errors

View File

@ -1,7 +1,8 @@
import json
import os
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class WorkoutStep(BaseModel):
name: str

View File

@ -1,69 +1,34 @@
import json
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, List, Optional
from garmin.validator import WorkoutValidator
from recommendations.engine import RecommendationEngine
logger = logging.getLogger(__name__)
class WorkoutManager:
"""Manages workout creation and AI generation."""
"""Manages workout generation and modification."""
def __init__(self, api_key: Optional[str] = None):
self.engine = RecommendationEngine(api_key=api_key)
def __init__(self, ai_engine=None):
self.ai_engine = ai_engine if ai_engine is not None else RecommendationEngine()
def generate_workout_json(self, prompt: str) -> Dict[str, Any]:
"""Ask Gemini to generate a valid Garmin workout JSON based on the user prompt."""
def validate_workout_json(self, workout_data: Dict[str, Any]) -> List[str]:
"""Validate a workout structure against Garmin schema."""
return WorkoutValidator.validate_workout(workout_data)
system_prompt = """
You are an expert fitness coach and Garmin workout specialist.
Your task is to convert the user's natural language request into a valid Garmin Workout JSON structure.
def get_constants(self) -> Dict[str, Any]:
"""Get Garmin constants for frontend."""
return WorkoutValidator.get_constants()
The JSON structure should look like this example (simplified):
{
"workoutName": "Upper Body Power",
"description": "Generated by fitmop AI",
"sportType": { "sportTypeId": 1, "sportTypeKey": "cycling" },
# sportTypeKey can be: running, cycling, swimming, strength_training, cardio, etc.
"workoutSegments": [
{
"segmentOrder": 1,
"sportType": { "sportTypeId": 1, "sportTypeKey": "cycling" },
"workoutSteps": [
{
"type": "ExecutableStepDTO",
"stepOrder": 1,
"stepType": { "stepTypeId": 3, "stepTypeKey": "interval" }, # interval, recover, rest, warmup, cooldown
"endCondition": { "conditionTypeId": 2, "conditionTypeKey": "time" },
"endConditionValue": 600, # seconds
"targetType": { "targetTypeId": 4, "targetTypeKey": "power.zone" },
"targetValueOne": 200,
"targetValueTwo": 250
}
]
}
]
}
IMPORTANT: Return ONLY the JSON object. No markdown formatting, no explanations.
ensure the JSON is valid.
def generate_workout_json(self, prompt: str, existing_workout: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Ask Gemini to generate or modify a Garmin workout JSON.
# For this prototype, we will simulate the AI call if we don't have a real Gemini client wrapper that supports
# this specific prompt structure yet. But assuming RecommendationEngine can be adapted or we use a direct call.
# Since RecommendationEngine is currently simple, let's just use a simulated reliable builder for now
# OR actually implement the call if the engine supports it.
# NOTE: The current RecommendationEngine only takes history/objective.
# We should extend it or just hack it here for the MVP.
# Let's mock a simple structured response for "strength" to prove the flow,
# as integrating the real LLM for complex JSON generation might require more robust prompting/parsing
# than the simple engine provided.
# However, to satisfy the requirement "AI act on the workout", we should try to be dynamic.
# Dynamic Builder Logic (Mocking AI for stability in this prototype phase)
return self._mock_ai_builder(prompt)
Args:
prompt: User instructions (e.g. "Add warmup", "Make it harder", "Run 5k")
existing_workout: Optional JSON of a workout to modify.
"""
return self.engine.generate_json(prompt, context_json=existing_workout)
def _mock_ai_builder(self, prompt: str) -> Dict[str, Any]:
"""Mock AI to return valid Garmin JSON based on keywords."""

View File

@ -0,0 +1,65 @@
import json
import os
import random
from datetime import datetime, timedelta
# Directory setup
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "../data/local/garmin")
os.makedirs(DATA_DIR, exist_ok=True)
print(f"Generating mock data in {DATA_DIR}...")
# Configuration
TODAY = datetime(2026, 1, 1) # User's context date
ACTIVITIES_COUNT = 15
ACTIVITY_TYPES = [
{"typeKey": "running", "typeId": 1},
{"typeKey": "cycling", "typeId": 2},
{"typeKey": "strength_training", "typeId": 13},
{"typeKey": "yoga", "typeId": 4}
]
def generate_activity(date_obj):
act_type = random.choice(ACTIVITY_TYPES)
duration = random.randint(1800, 5400) # 30-90 mins
act_id = int(date_obj.timestamp())
data = {
"activityId": act_id,
"activityName": f"Mock {act_type['typeKey'].title()}",
"description": "Generated for verification",
"startTimeLocal": date_obj.strftime("%Y-%m-%d %H:%M:%S"),
"startTimeGMT": date_obj.strftime("%Y-%m-%d %H:%M:%S"), # Simplified
"activityType": act_type,
"eventType": {"typeKey": "uncategorized", "typeId": 9},
"distance": random.randint(3000, 15000) if act_type['typeKey'] in ['running', 'cycling'] else 0.0,
"duration": duration,
"elapsedDuration": duration + 120,
"movingDuration": duration,
"elevationGain": random.randint(50, 500),
"elevationLoss": random.randint(50, 500),
"averageSpeed": random.uniform(2.5, 8.0),
"averageHR": random.randint(120, 160),
"maxHR": random.randint(160, 185),
"calories": random.randint(300, 800),
"bmrCalories": 50,
"averageRunningCadenceInStepsPerMinute": random.randint(160, 180) if act_type['typeKey'] == 'running' else None,
"steps": random.randint(3000, 8000) if act_type['typeKey'] == 'running' else 0,
}
return data
# Generate last 2 weeks of data
for i in range(14):
day = TODAY - timedelta(days=i)
# 70% chance of activity
if random.random() > 0.3:
act = generate_activity(day.replace(hour=18, minute=0))
file_path = os.path.join(DATA_DIR, f"activity_{act['activityId']}.json")
with open(file_path, "w") as f:
json.dump(act, f, indent=2)
print(f"Created {act['activityName']} on {act['startTimeLocal']}")
print("Mock data generation complete.")

View File

@ -1,14 +1,16 @@
import os
import json
from typing import List, Dict, Any
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
from garmin.sync import GarminSync
from garmin.client import GarminClient
from recommendations.engine import RecommendationEngine
from pydantic import BaseModel
from common.env_manager import EnvManager
from garmin.client import GarminClient
from garmin.sync import GarminSync
from recommendations.engine import RecommendationEngine
from garmin.workout_manager import WorkoutManager
from common.settings_manager import SettingsManager
# Initialize EnvManager
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
@ -18,8 +20,28 @@ env = EnvManager(ROOT_DIR)
for service in ["garmin", "withings", "gemini"]:
env.load_service_env(service)
import logging
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
# Logger Setup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger("fitmop")
app = FastAPI(title="Fitness Antigravity API (FitMop)")
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"Global Error: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_SERVER_ERROR", "message": str(exc)}
)
# Enable CORS for the Vue frontend
app.add_middleware(
CORSMiddleware,
@ -40,10 +62,7 @@ async def get_activities():
"""Get all locally stored Garmin activities."""
# We no longer need a logged in client just to load local files
sync = GarminSync(None, storage_dir=get_storage_path("garmin"))
try:
return sync.load_local_activities()
except Exception as e:
return [] # Return empty list instead of erroring if directory missing
@app.get("/recommendation")
async def get_recommendation():
@ -80,6 +99,25 @@ async def update_garmin(data: Dict[str, str]):
env.set_credentials("garmin", {"GARMIN_EMAIL": email, "GARMIN_PASSWORD": password})
return {"status": "SUCCESS"}
@app.get("/settings/status")
async def get_settings_status():
"""Check configuration status of services."""
env.load_service_env("garmin")
env.load_service_env("withings")
env.load_service_env("gemini")
return {
"garmin": {
"configured": bool(os.getenv("GARMIN_EMAIL") and os.getenv("GARMIN_PASSWORD"))
},
"withings": {
"configured": bool(os.getenv("WITHINGS_CLIENT_ID") and os.getenv("WITHINGS_CLIENT_SECRET"))
},
"gemini": {
"configured": bool(os.getenv("GEMINI_API_KEY"))
}
}
@app.post("/settings/withings")
async def update_withings(data: Dict[str, str]):
client_id = data.get("client_id")
@ -112,7 +150,8 @@ async def auth_status():
return {
"authenticated": status == "SUCCESS",
"status": status,
"email": email if status == "SUCCESS" else None
"email": email if status == "SUCCESS" else None,
"message": "Login failed" if status != "SUCCESS" else None
}
@app.post("/auth/login")
@ -151,9 +190,11 @@ async def trigger_sync():
count = sync.sync_activities(days=30)
return {"success": True, "synced_count": count}
from garmin.workout_manager import WorkoutManager
# ... (imports)
class WorkoutPrompt(BaseModel):
prompt: str
current_workout: Optional[Dict[str, Any]] = None
@app.get("/analyze/stats")
async def analyze_stats(weeks: int = 12):
@ -196,6 +237,34 @@ async def sync_smart():
except Exception as e:
return {"success": False, "error": str(e)}
# --- SETTINGS: PROFILE ---
@app.get("/settings/profile")
async def get_profile():
settings = SettingsManager()
return settings.load_profile()
@app.post("/settings/profile")
async def save_profile(data: Dict[str, Any]):
settings = SettingsManager()
settings.save_profile(data)
return {"status": "SUCCESS"}
# --- ANALYZE AGENT ---
class AnalyzePrompt(BaseModel):
message: str
history: List[Dict[str, str]] = []
@app.post("/analyze/chat")
async def chat_analyze(payload: AnalyzePrompt):
env.load_service_env("gemini")
engine = RecommendationEngine(api_key=os.getenv("GEMINI_API_KEY"))
response = engine.chat_with_data(payload.message, payload.history)
return {"message": response}
# --- PLAN FEATURE ENDPOINTS ---
@app.get("/workouts")
@ -211,32 +280,63 @@ async def get_workouts():
return client.get_workouts_list(limit=50)
@app.post("/workouts/chat")
async def chat_workout(data: Dict[str, str]):
"""Generate a workout from a natural language prompt."""
prompt = data.get("prompt")
if not prompt:
raise HTTPException(status_code=400, detail="Prompt required")
async def chat_workout(payload: WorkoutPrompt):
"""Generate or modify a workout based on prompt."""
env.load_service_env("gemini") # Ensure GEMINI_API_KEY is loaded
wm = WorkoutManager(api_key=env.get_gemini_key())
try:
workout = wm.generate_workout_json(payload.prompt, existing_workout=payload.current_workout)
return {"workout": workout}
except Exception as e:
return {"error": str(e)}
api_key = os.getenv("GEMINI_API_KEY")
# For prototype, manager might mock if no key, but generally we want the key
manager = WorkoutManager(api_key=api_key)
workout_json = manager.generate_workout_json(prompt)
@app.get("/analyze/dashboard")
async def get_dashboard_data():
"""Get aggregated stats for dashboard."""
# Start with local data
try:
from garmin.sync import GarminSync
# We can pass None as client for reading local files
sync = GarminSync(None, storage_dir="data/local/garmin")
return sync.get_dashboard_stats()
except Exception as e:
return {"error": str(e)}
return {"workout": workout_json}
# --- WORKOUT EDITOR ENDPOINTS ---
@app.post("/workouts/validate")
async def validate_workout(workout: Dict[str, Any]):
"""Validate workout JSON against schema."""
manager = WorkoutManager()
errors = manager.validate_workout_json(workout)
return {"valid": len(errors) == 0, "errors": errors}
@app.get("/workouts/constants")
async def get_workout_constants():
"""Get Garmin constants for frontend editor."""
manager = WorkoutManager()
return manager.get_constants()
@app.post("/workouts/upload")
async def upload_workout_endpoint(workout: Dict[str, Any]):
"""Upload a workout JSON to Garmin."""
async def upload_workout(workout: Dict[str, Any]):
"""Upload workout to Garmin."""
# 1. Validate
manager = WorkoutManager()
errors = manager.validate_workout_json(workout)
if errors:
return {"success": False, "error": "Validation Failed", "details": errors}
# 2. Upload
env.load_service_env("garmin")
client = GarminClient()
if client.login() != "SUCCESS":
raise HTTPException(status_code=401, detail="Garmin authentication failed")
return {"success": False, "error": "Auth failed"}
success = client.upload_workout(workout)
if not success:
raise HTTPException(status_code=500, detail="Failed to upload workout to Garmin")
return {"status": "SUCCESS", "message": "Workout uploaded to Garmin Connect"}
try:
result = client.upload_workout(workout)
return {"success": True, "result": result}
except Exception as e:
return {"success": False, "error":str(e)}
@app.get("/health")
async def health():

View File

@ -1,44 +1,135 @@
import os
from typing import List, Dict, Any, Optional
import json
import logging
import os
from typing import Any, Dict, List, Optional
from google import genai
from google.genai import types
from recommendations.tools import FitnessTools
logger = logging.getLogger(__name__)
class RecommendationEngine:
"""Gemini-powered recommendation engine for fitness training."""
"""Gemini-powered recommendation engine with Function Calling (google-genai SDK)."""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
self.tools = FitnessTools()
self.client = None
self.model_name = "gemini-2.0-flash-exp" # Using latest Flash as requested
if self.api_key:
self.client = genai.Client(api_key=self.api_key)
else:
logger.warning("No Gemini API Key provided. AI features will be mocked.")
def chat_with_data(self, user_message: str, history: List[Dict[str, str]] = []) -> str:
"""
Chat with the AI Agent which has access to fitness tools.
"""
if not self.client:
return "AI unavailable. Please check API Key in settings."
try:
# Prepare tools configuration
# In google-genai SDK, we can pass callables directly
tool_functions = [
self.tools.get_recent_activities,
self.tools.get_weekly_stats,
self.tools.get_user_profile
]
# Format history for the new SDK
# Role 'model' is supported, 'user' is supported.
formatted_history = []
for msg in history:
formatted_history.append(
types.Content(
role=msg["role"],
parts=[types.Part.from_text(msg["content"])]
)
)
# Create chat session
chat = self.client.chats.create(
model=self.model_name,
config=types.GenerateContentConfig(
tools=tool_functions,
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=False),
system_instruction="You are FitMop AI, an elite fitness coach. Always respond in English. Be concise and motivating.",
temperature=0.7
),
history=formatted_history
)
# Send message
response = chat.send_message(user_message)
return response.text if response.text else "I analyzed the data but have no specific comment."
except Exception as e:
logger.error(f"Agent Chat Error: {e}")
return f"I encountered an error analyzing your data: {str(e)}"
def get_recommendation(self, history: List[Dict[str, Any]], objective: str) -> str:
"""Get a training recommendation based on history and objective."""
# In a real implementation, this would call the Gemini API.
# For now, we simulate the logic or provide a way to inject the prompt.
"""Legacy recommendation."""
prompt = f"Based on my recent activities and objective '{objective}', give me a short tip. Keep it short and IN ENGLISH."
return self.chat_with_data(prompt)
prompt = self._build_prompt(history, objective)
def generate_json(self, prompt: str, context_json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Generate or modify a workout JSON strictly."""
if not self.client:
return self._mock_json_response(prompt)
# Simulate AI response based on the prompt content
if "cycling" in prompt.lower() or "ride" in prompt.lower():
return "Based on your recent cycling history, I recommend focusing more on HIIT (High-Intensity Interval Training) to improve your endurance and speed."
elif "strength" in prompt.lower():
return "You've been consistent with upper body. This week, focus on leg strength with squats and deadlifts to maintain balance."
# Prompt Construction
system_instruction = """
You are a Garmin Workout Generator. Your task is to output strictly valid JSON.
The JSON schema must follow the Garmin Workout format.
Root object must have: workoutName, sportType, workoutSegments.
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
- TargetType: NO_TARGET=1, HEART_RATE=2, PACE=4 (Speed)
"""
return "Keep up the consistent work! Focus on maintaining your current volume while gradually increasing intensity."
user_prompt = f"User Request: {prompt}"
if context_json:
user_prompt += f"\n\nBase Workout JSON to Modify:\n{json.dumps(context_json)}"
user_prompt += "\n\nInstructions: Modify the Base JSON according to the User Request. Keep the structure valid."
else:
user_prompt += "\n\nInstructions: Create a NEW workout JSON based on the User Request."
def _build_prompt(self, history: List[Dict[str, Any]], objective: str) -> str:
"""Construct the prompt for the Gemini model."""
history_summary = self._summarize_history(history)
return f"User Objective: {objective}\nRecent Training History: {history_summary}\nBased on this, what should be the next training focus?"
# Config for JSON mode
config = types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json", # Use JSON mode if supported by model
temperature=0.3
)
def _summarize_history(self, history: List[Dict[str, Any]]) -> str:
"""Convert raw activity data into a text summary."""
if not history:
return "No recent training data available."
try:
response = self.client.models.generate_content(
model=self.model_name,
contents=user_prompt,
config=config
)
summary = []
for activity in history[:5]: # Last 5 activities
name = activity.get("activityName", "Unknown")
type_name = activity.get("activityType", {}).get("typeKey", "unknown")
summary.append(f"- {name} ({type_name})")
# Parse result
if response.parsed:
return response.parsed
return "\n".join(summary)
# Fallback text parsing
text = response.text.replace("```json", "").replace("```", "").strip()
return json.loads(text)
except Exception as e:
logger.error(f"Gemini JSON Gen Error: {e}")
raise e
def _mock_json_response(self, prompt: str) -> Dict[str, Any]:
return {
"workoutName": "Offline Workout",
"description": f"Generated offline for: {prompt}",
"sportType": { "sportTypeId": 1, "sportTypeKey": "running" },
"workoutSegments": []
}

View File

@ -0,0 +1,56 @@
from common.settings_manager import SettingsManager
from garmin.sync import GarminSync
class FitnessTools:
"""Tools accessible by the AI Agent."""
def __init__(self, garmin_storage: str = "data/local/garmin"):
self.sync = GarminSync(None, storage_dir=garmin_storage)
self.settings = SettingsManager()
def get_recent_activities(self, limit: int = 5) -> str:
"""
Get a summary of the most recent activities.
Args:
limit: Number of activities to return (default 5).
"""
activities = self.sync.load_local_activities()
# Sort by date desc
activities.sort(key=lambda x: x.get("startTimeLocal", ""), reverse=True)
subset = activities[:limit]
summary = []
for act in subset:
name = act.get("activityName", "Workout")
type_key = act.get("activityType", {}).get("typeKey", "unknown")
date = act.get("startTimeLocal", "").split(" ")[0]
dist = round(act.get("distance", 0) / 1000, 2)
dur = round(act.get("duration", 0) / 60, 0)
summary.append(f"{date}: {name} ({type_key}) - {dist}km in {dur}min")
return "\n".join(summary)
def get_weekly_stats(self) -> str:
"""Get aggregated weekly volume stats for the last 4 weeks."""
stats = self.sync.get_weekly_stats(weeks=4)
# Convert complex dict to simple text summary
summary = ["Weekly Volume (Hours):"]
labels = stats.get("labels", [])
datasets = stats.get("datasets", [])
for i, week in enumerate(labels):
week_total = 0
details = []
for ds in datasets:
val = ds['data'][i] if i < len(ds['data']) else 0
if val > 0:
details.append(f"{ds['label']}: {val}h")
week_total += val
summary.append(f"Week {week}: Total {round(week_total, 1)}h ({', '.join(details)})")
return "\n".join(summary)
def get_user_profile(self) -> str:
"""Get the user's fitness goals and preferences."""
return self.settings.get_context_string()

34
backend/src/test_agent.py Normal file
View File

@ -0,0 +1,34 @@
import requests
BASE_URL = "http://localhost:8000"
def test_profile():
print("\n--- Testing Profile ---")
data = {"fitness_goals": "Run a sub-4 marathon", "dietary_preferences": "No dairy"}
res = requests.post(f"{BASE_URL}/settings/profile", json=data)
print(f"Save Profile: {res.status_code}")
res = requests.get(f"{BASE_URL}/settings/profile")
profile = res.json()
print(f"Load Profile: {profile} - {'PASS' if profile.get('fitness_goals') == 'Run a sub-4 marathon' else 'FAIL'}")
def test_agent_chat():
print("\n--- Testing Agent Chat ---")
# This might fail if no API key is set, but we want to test the endpoint connectivity
payload = {
"message": "Overview of my last week?",
"history": []
}
res = requests.post(f"{BASE_URL}/analyze/chat", json=payload)
if res.status_code == 200:
print(f"Agent Response: {res.json()['message']}")
else:
print(f"Agent Error: {res.text}")
if __name__ == "__main__":
try:
test_profile()
test_agent_chat()
except Exception as e:
print(f"Test failed: {e}")

View File

@ -1,10 +1,11 @@
from unittest.mock import MagicMock, patch
import pytest
import os
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from main import app
client = TestClient(app)
client = TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_sync():
@ -35,7 +36,7 @@ def test_get_activities_error(mock_sync):
response = client.get("/activities")
assert response.status_code == 500
assert "Load failed" in response.json()["detail"]
assert "INTERNAL_SERVER_ERROR" in response.json()["error"]
def test_get_recommendation(mock_sync, mock_engine):
mock_sync_instance = mock_sync.return_value

View File

@ -0,0 +1,28 @@
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_dashboard_endpoint():
"""Test that the dashboard endpoint returns structured data."""
response = client.get("/analyze/dashboard")
assert response.status_code == 200
data = response.json()
# Check structure
assert "summary" in data
assert "breakdown" in data
assert "strength_sessions" in data
# Check summary fields
summary = data["summary"]
assert "total_hours" in summary
assert "trend_pct" in summary
assert "period_label" in summary
def test_health_endpoint():
"""Test the health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@ -1,19 +1,16 @@
import pytest
import os
from unittest.mock import MagicMock, patch
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from garmin.client import GarminClient
@pytest.fixture
def mock_garmin():
with patch("garmin.client.Garmin") as mock:
yield mock
@pytest.fixture
def mock_garth():
with patch("garmin.client.garth") as mock:
yield mock
@pytest.fixture
def mock_sso():
with patch("garmin.client.garth_login") as mock_login, \
@ -25,7 +22,7 @@ def test_client_init():
assert client.email == "test@example.com"
assert client.password == "password"
def test_login_success(mock_sso, mock_garmin, mock_garth):
def test_login_success_force(mock_sso, mock_garmin):
mock_login, _ = mock_sso
mock_login.return_value = (MagicMock(), MagicMock())
client = GarminClient(email="test@example.com", password="password")
@ -43,69 +40,63 @@ def test_login_mfa_required(mock_sso):
assert client.login(force_login=True) == "MFA_REQUIRED"
assert GarminClient._temp_client_state == {"some": "state"}
def test_login_mfa_complete(mock_sso, mock_garmin, mock_garth):
def test_login_mfa_complete(mock_sso, mock_garmin):
_, mock_resume_login = mock_sso
mock_client = MagicMock()
mock_client.oauth1_token = MagicMock()
state = {"some": "state", "client": mock_client}
GarminClient._temp_client_state = state
# resume_login should return (oauth1, oauth2)
mock_resume_login.return_value = (MagicMock(), MagicMock())
client = GarminClient(email="test@example.com", password="password")
assert client.login(mfa_code="123456") == "SUCCESS"
mock_resume_login.assert_called_with(state, "123456")
assert GarminClient._temp_client_state is None
assert mock_client.dump.called
def test_login_resume_success(mock_garmin):
client = GarminClient(email="test@example.com", password="password")
inst = MagicMock()
mock_garmin.return_value = inst
# Mocking both exists AND getsize to ensure we enter the resume block
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100):
assert client.login() == "SUCCESS"
inst.login.assert_called_with(tokenstore=client.token_store)
def test_login_resume_fail_no_force(mock_garmin, mock_sso):
def test_login_resume_fail_falls_back(mock_garmin, mock_sso):
mock_login, _ = mock_sso
mock_login.return_value = (MagicMock(), MagicMock())
inst = MagicMock()
inst.login.side_effect = Exception("Resume fail")
mock_garmin.return_value = inst
client = GarminClient(email="test", password="test")
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100):
patch("os.path.getsize", return_value=100), \
patch("os.remove"):
# Without force_login=True, it should fail if resume fails
assert client.login() == "FAILURE"
assert mock_login.call_count == 0
def test_login_resume_fail_with_force(mock_garmin, mock_sso):
def test_login_resume_fail_force_retries(mock_garmin, mock_sso):
mock_login, _ = mock_sso
mock_login.return_value = (MagicMock(), MagicMock())
inst1 = MagicMock()
inst1.login.side_effect = Exception("Resume fail")
inst2 = MagicMock()
inst2.login.return_value = None
# inst2 needs to return None or something to not throw
mock_garmin.side_effect = [inst1, inst2]
client = GarminClient(email="test", password="test")
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=100), \
patch("os.remove") as mock_remove:
patch("os.remove"):
assert client.login(force_login=True) == "SUCCESS"
assert mock_login.call_count == 1
def test_login_failure(mock_sso):
mock_login, _ = mock_sso
mock_login.side_effect = Exception("Fatal error")
client = GarminClient(email="test@example.com", password="password")
with patch("os.path.exists", return_value=False):
assert client.login(force_login=True) == "FAILURE"
def test_get_activities_not_logged_in():
client = GarminClient()
with pytest.raises(RuntimeError, match="Client not logged in"):
client.get_activities(date.today(), date.today())
assert mock_login.called
def test_get_activities_success(mock_garmin):
mock_instance = mock_garmin.return_value
@ -116,30 +107,3 @@ def test_get_activities_success(mock_garmin):
activities = client.get_activities(date(2023, 1, 1), date(2023, 1, 2))
assert activities == [{"activityId": 123}]
def test_get_activities_failure(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_activities_by_date.side_effect = Exception("err")
client = GarminClient()
client.client = mock_instance
assert client.get_activities(date.today(), date.today()) == []
def test_get_stats_success(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_stats.return_value = {"steps": 1000}
client = GarminClient()
client.client = mock_instance
stats = client.get_stats(date(2023, 1, 1))
assert stats == {"steps": 1000}
def test_get_user_summary_success(mock_garmin):
mock_instance = mock_garmin.return_value
mock_instance.get_user_summary.return_value = {"calories": 2000}
client = GarminClient()
client.client = mock_instance
summary = client.get_user_summary(date(2023, 1, 1))
assert summary == {"calories": 2000}

View File

@ -1,10 +1,12 @@
import os
import json
import os
from unittest.mock import MagicMock
import pytest
from unittest.mock import MagicMock, patch
from datetime import date
from garmin.sync import GarminSync
@pytest.fixture
def mock_client():
return MagicMock()

View File

@ -1,8 +1,11 @@
import os
import json
import os
import pytest
from garmin.workout import GarminWorkoutCreator, StrengthWorkout, WorkoutStep
@pytest.fixture
def temp_workout_dir(tmp_path):
return str(tmp_path / "workouts")

View File

@ -1,37 +1,38 @@
import pytest
from unittest.mock import MagicMock, patch
from recommendations.engine import RecommendationEngine
def test_get_recommendation_cycling():
engine = RecommendationEngine()
history = [{"activityName": "Morning Ride", "activityType": {"typeKey": "cycling"}}]
objective = "endurance"
@patch("google.genai.Client")
def test_chat_with_data_success(mock_genai_client):
# Setup mock
mock_chat = MagicMock()
mock_chat.send_message.return_value.text = "Keep it up!"
mock_client_inst = MagicMock()
mock_client_inst.chats.create.return_value = mock_chat
mock_genai_client.return_value = mock_client_inst
rec = engine.get_recommendation(history, objective)
assert "HIIT" in rec
engine = RecommendationEngine(api_key="fake_key")
response = engine.chat_with_data("Hello", history=[])
def test_get_recommendation_strength():
engine = RecommendationEngine()
history = [{"activityName": "Upper Body", "activityType": {"typeKey": "strength_training"}}]
objective = "strong"
assert response == "Keep it up!"
assert mock_client_inst.chats.create.called
rec = engine.get_recommendation(history, objective)
assert "leg strength" in rec
@patch("google.genai.Client")
def test_get_recommendation_calls_chat(mock_genai_client):
mock_chat = MagicMock()
mock_chat.send_message.return_value.text = "Tip!"
mock_client_inst = MagicMock()
mock_client_inst.chats.create.return_value = mock_chat
mock_genai_client.return_value = mock_client_inst
def test_get_recommendation_default():
engine = RecommendationEngine()
history = []
objective = "fitness"
engine = RecommendationEngine(api_key="fake_key")
response = engine.get_recommendation([], "fitness")
rec = engine.get_recommendation(history, objective)
assert "consistent work" in rec
assert response == "Tip!"
def test_summarize_history_empty():
engine = RecommendationEngine()
summary = engine._summarize_history([])
assert "No recent training data" in summary
def test_summarize_history_with_data():
engine = RecommendationEngine()
history = [{"activityName": "Run", "activityType": {"typeKey": "running"}}]
summary = engine._summarize_history(history)
assert "- Run (running)" in summary
@patch("os.getenv", return_value=None)
def test_mock_response_when_no_api_key(mock_env):
engine = RecommendationEngine(api_key=None)
# Mocking is done via client=None check
response = engine.chat_with_data("Hello")
assert "AI unavailable" in response

View File

@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "aiohappyeyeballs"
@ -142,6 +146,7 @@ dependencies = [
{ name = "pandas" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "ruff" },
{ name = "uvicorn" },
]
@ -161,6 +166,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "ruff", specifier = ">=0.14.10" },
{ name = "uvicorn", specifier = ">=0.40.0" },
]
@ -904,6 +910,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "ruff"
version = "0.14.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
name = "six"
version = "1.17.0"

View File

@ -5,10 +5,27 @@
# Set colors
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}🚀 Starting FitMop Environment...${NC}"
# Pre-flight checks
echo -e "${BLUE}🔍 Running Pre-flight Checks...${NC}"
# Check for .env_gemini
if [ ! -f ".env_gemini" ]; then
echo -e "${RED}⚠️ Warning: .env_gemini not found.${NC}"
echo -e "${BLUE}Gemini AI features will be unavailable until set in the UI.${NC}"
fi
# Check for uv
if ! command -v uv &> /dev/null; then
echo -e "${RED}❌ Error: 'uv' is not installed.${NC}"
echo -e "${BLUE}Please install it: curl -LsSf https://astral.sh/uv/install.sh | sh${NC}"
exit 1
fi
# Kill any existing processes on ports 8000 and 5173
lsof -ti:8000 | xargs kill -9 2>/dev/null
lsof -ti:5173 | xargs kill -9 2>/dev/null
@ -30,6 +47,10 @@ echo -e "${BLUE}✅ Backend is Ready!${NC}"
# Start Frontend
echo -e "${BLUE}🌐 Starting Frontend (Port 5173)...${NC}"
cd ../frontend
# Ensure we use the modern Node.js version
export PATH="/usr/local/opt/node@24/bin:$PATH"
npm run dev -- --port 5173 > ../frontend.log 2>&1 &
FRONTEND_PID=$!

View File

@ -1,9 +0,0 @@
> frontend@0.0.0 dev
> vite --port 5173
VITE v7.3.0 ready in 457 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

7
frontend/.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100
}

34
frontend/eslint.config.js Normal file
View File

@ -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'
}
}
]

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

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')
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,67 +174,83 @@ 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>
<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>
<nav class="main-nav">
<button :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
<LayoutDashboard :size="18" /> Dashboard
<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'">
<LineChart :size="18" /> Analyze
<button :class="{ active: currentView === 'analyze' }" @click="currentView = 'analyze'">
<TrendingUp :size="18" /> Analysis
</button>
<button :class="{active: currentView === 'plan'}" @click="currentView = 'plan'">
<Calendar :size="18" /> Plan
<button :class="{ active: currentView === 'plan' }" @click="currentView = 'plan'">
<Dumbbell :size="18" /> Workout Plans
</button>
</nav>
<button class="settings-btn" @click="settingsOpen = true"><Settings :size="24" /></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);">
<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>
<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 }" />
<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">4.2h</div>
<p>+12% from last week</p>
<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 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>
@ -195,36 +261,73 @@ onMounted(() => {
</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" />
<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;">
<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);">
<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>
</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;">
<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>
<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
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 style="font-weight: 600">{{ Math.round(activity.duration / 60) }}m</div>
</div>
</div>
</div>
@ -234,7 +337,6 @@ onMounted(() => {
<!-- PLAN VIEW -->
<PlanView v-if="currentView === 'plan'" />
</main>
<!-- Settings Modal -->
@ -247,52 +349,105 @@ onMounted(() => {
<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" />
<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>
<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;

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')
})
})

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>

View File

@ -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>

View File

@ -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>

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 {

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
}
@ -52,11 +59,49 @@ const runSmartSync = async () => {
await fetchData()
} else {
syncStatus.value = 'warning'
syncMessage.value = "Auth check failed"
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
}
}
@ -95,10 +140,10 @@ onMounted(() => {
<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>
<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>
@ -111,10 +156,67 @@ onMounted(() => {
</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>
@ -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>

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)
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,76 +149,126 @@ onMounted(() => {
<template>
<div class="plan-view">
<!-- 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>
<!-- 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 v-if="loading" style="text-align: center; padding: 2rem">
<Loader2 class="spinner" /> Loading remote workouts...
</div>
<div class="input-group">
<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="chatInput"
@keyup.enter="generateWorkout"
type="text"
placeholder="Type your workout request..."
:disabled="chatLoading"
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>
<!-- 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 v-if="aiError" class="ai-error">{{ aiError }}</div>
</div>
<div class="segments">
<div v-for="(segment, sIdx) in currentWorkout.workoutSegments" :key="sIdx" class="segment">
<h4>Segment {{ sIdx + 1 }}</h4>
<div v-for="(step, stepIdx) in segment.workoutSteps" :key="stepIdx" class="step-item">
<span class="step-type">{{ step.stepType.stepTypeKey }}</span>
<span class="step-detail">
{{ step.endConditionValue }}
{{ step.endCondition.conditionTypeKey }}
</span>
</div>
</div>
</div>
</div>
<!-- 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>
<!-- 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>
<!-- 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-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 v-else-if="editorTab === 'json'" class="h-full">
<WorkoutJsonEditor v-model="workingWorkout" />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@ -152,30 +278,69 @@ 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));
}
.chat-interface {
text-align: center;
padding: 1rem;
}
.input-group {
/* Toolbar & Header */
.toolbar {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
justify-content: space-between;
align-items: center;
}
.input-group input {
.browser-mode {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.workout-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.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;
}
.w-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.w-header h4 {
margin: 0;
font-size: 1rem;
}
.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;
}
.workout-editor {
.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;
@ -185,77 +350,191 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
}
.left-controls,
.right-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.title-input {
font-size: 1.5rem;
font-weight: bold;
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);
width: 100%;
font-size: 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
.ai-input-wrapper input:focus {
outline: none;
}
.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 {
.ai-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
color: var(--accent-color);
}
.workout-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
.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;
}
.workout-item {
.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;
padding: 1rem;
background: var(--bg-color);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: transform 0.2s;
}
.workout-item:hover {
border-color: var(--accent-color);
transform: translateY(-2px);
}
.workout-icon {
width: 40px;
height: 40px;
.step-idx {
background: var(--border-color);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
border-radius: 50%;
color: var(--accent-color);
font-size: 0.8rem;
font-weight: bold;
}
.meta {
font-size: 0.8rem;
color: var(--text-muted);
.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>

View File

@ -3,5 +3,5 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue()]
})

17
frontend/vitest.config.js Normal file
View File

@ -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'),
},
},
})