FitMop/frontend/src/App.vue

964 lines
26 KiB
Vue

<script setup>
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 loading = ref(true)
const syncing = ref(false)
const authenticated = ref(false)
const mfaRequired = ref(false)
const authError = ref('')
// Navigation State
const currentView = ref('dashboard')
// Settings State
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: '' },
withings: { client_id: '', client_secret: '' },
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')
const data = await res.json()
authenticated.value = data.authenticated
if (data.status === 'MFA_REQUIRED') {
mfaRequired.value = true
}
// Always fetch local data regardless of online bauth status
fetchData()
} catch (error) {
console.error('Auth check failed:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
checkAuth()
fetchSettings()
})
const fetchSettings = async () => {
try {
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 status:', error)
}
}
const saveServiceSettings = async (service) => {
loading.value = true
try {
const res = await fetch(`http://localhost:8000/settings/${service}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settingsForms.value[service])
})
if (res.ok) {
await fetchSettings()
} else {
const err = await res.json()
authError.value = err.detail || 'Save failed'
}
} catch {
authError.value = 'Failed to communicate with backend'
} finally {
loading.value = false
}
}
const triggerSync = async () => {
if (syncing.value) return
syncing.value = true
try {
const res = await fetch('http://localhost:8000/sync', { method: 'POST' })
if (res.ok) {
await fetchData()
}
} catch (error) {
console.error('Sync failed:', error)
} finally {
syncing.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 (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 {
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
} catch (error) {
console.error('Data fetch failed:', error)
}
}
const setTheme = (theme) => {
currentTheme.value = theme
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
const openGeminiSettings = () => {
settingsOpen.value = true
activeTab.value = 'gemini'
}
const saveProfile = async () => {
loading.value = true
try {
const res = await fetch('http://localhost:8000/settings/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile.value)
})
if (!res.ok) {
authError.value = 'Failed to save profile'
}
} catch {
authError.value = 'Failed to connect'
} finally {
loading.value = false
}
}
</script>
<template>
<header>
<h1>FitMop</h1>
<p>Your Ultimate Strength & Endurance Companion</p>
<button class="settings-btn icon-btn" @click="settingsOpen = true">
<AlertTriangle
v-if="!settingsStatus.gemini.configured"
:size="24"
style="color: var(--error-color); margin-right: 0.5rem"
/>
<Settings :size="24" />
</button>
<div class="main-nav">
<button :class="{ active: currentView === 'dashboard' }" @click="currentView = 'dashboard'">
<Activity :size="18" /> Dashboard
</button>
<button :class="{ active: currentView === 'analyze' }" @click="currentView = 'analyze'">
<TrendingUp :size="18" /> Analysis
</button>
<button :class="{ active: currentView === 'plan' }" @click="currentView = 'plan'">
<Dumbbell :size="18" /> Workout Plans
</button>
</div>
</header>
<main class="content-area">
<!-- DASHBOARD VIEW -->
<div v-if="currentView === 'dashboard'" class="dashboard">
<!-- Sync Status Overlay (Visible when syncing or if forced) -->
<div v-if="syncing" class="sync-overlay">
<div
class="card"
style="display: flex; align-items: center; gap: 1rem; border-color: var(--accent-color)"
>
<Loader2 class="spinner" :size="32" />
<div>
<h3 style="margin: 0">Syncing Garmin Data...</h3>
<p style="margin: 0; font-size: 0.9rem">Gathering your latest workouts locally.</p>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: start">
<h3><Activity :size="20" /> Last 7 Days</h3>
<button v-if="authenticated" class="icon-btn" :disabled="syncing" @click="triggerSync">
<RefreshCw :size="16" :class="{ spinner: syncing }" />
</button>
</div>
<div class="stat-value">{{ dashboardStats.summary.total_hours }}h</div>
<p
:style="{
color:
dashboardStats.summary.trend_pct >= 0 ? 'var(--success-color)' : 'var(--error-color)'
}"
>
{{ dashboardStats.summary.trend_pct >= 0 ? '+' : ''
}}{{ dashboardStats.summary.trend_pct }}% from previous
</p>
<div 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">{{ dashboardStats.strength_sessions }}</div>
<p>Target: 4 sessions</p>
</div>
<div class="card">
<h3><TrendingUp :size="20" /> VO2 Max</h3>
<div class="stat-value">{{ dashboardStats.vo2_max || '—' }}</div>
<p>Status: {{ dashboardStats.vo2_max ? 'Excellent' : 'Not enough data' }}</p>
</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"
/>
<AlertTriangle
v-else
color="var(--error-color)"
:size="18"
title="Gemini API Key missing"
/>
</div>
<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="openGeminiSettings">Settings</a>
to get personalized coaching.
</div>
<p v-else-if="loading">Thinking...</p>
<p v-else style="font-size: 1.1rem; font-style: italic">"{{ recommendation }}"</p>
</div>
<!-- Recent Activities -->
<div class="card" style="grid-column: 1 / -1">
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
"
>
<h3>Recent Workouts</h3>
<span v-if="!authenticated" style="font-size: 0.8rem; color: var(--text-muted)">
Offline Mode - <a href="#" @click.prevent="settingsOpen = true">Connect Garmin</a>
</span>
</div>
<div
v-if="loading && !dashboardStats.recent_activities"
style="text-align: center; padding: 2rem"
>
Loading history...
</div>
<div
v-else-if="
!dashboardStats.recent_activities || dashboardStats.recent_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 dashboardStats.recent_activities"
:key="activity.activityId"
class="activity-item"
>
<div style="display: flex; align-items: center; gap: 1rem">
<!-- Icon based on type -->
<div class="activity-icon">
<Activity v-if="activity.activityType.typeKey.includes('running')" :size="20" />
<Dumbbell v-else-if="activity.activityType.typeKey.includes('strength')" :size="20" />
<TrendingUp v-else :size="20" />
</div>
<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>
</div>
</div>
<div style="font-weight: 600">
{{ Math.round(activity.duration / 60) }}m
<span
style="
font-size: 0.8rem;
font-weight: normal;
color: var(--text-muted);
margin-left: 0.5rem;
"
>
{{ (activity.distance / 1000).toFixed(2) }}km
</span>
</div>
</div>
</div>
</div>
<!-- ANALYZE VIEW -->
<AnalyzeView v-if="currentView === 'analyze'" />
<!-- PLAN VIEW -->
<PlanView v-if="currentView === 'plan'" />
</main>
<!-- Settings Modal -->
<div v-if="settingsOpen" class="modal-overlay" @click.self="settingsOpen = false">
<div class="modal-content">
<div class="modal-header">
<h3>Settings</h3>
<button class="icon-btn" @click="settingsOpen = false"><X :size="20" /></button>
</div>
<div class="modal-body">
<div class="modal-sidebar">
<div
class="sidebar-item"
:class="{ active: activeTab === 'garmin' }"
@click="activeTab = 'garmin'"
>
Garmin
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === 'withings' }"
@click="activeTab = 'withings'"
>
Withings
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === 'gemini' }"
@click="activeTab = 'gemini'"
>
Gemini AI
</div>
<div
class="sidebar-item"
:class="{ active: activeTab === '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 -->
<!-- STATE 1 & 2: Not Configured or Configured but not Authenticated -->
<div v-if="!authenticated && !mfaRequired">
<div v-if="settingsStatus.garmin.configured" class="doc-box success-border">
<strong>Credentials Saved</strong><br />
Ready to connect.
</div>
<div v-else class="doc-box">
<strong>Not Connected</strong><br />
Enter your Garmin credentials to start.
</div>
<div class="form-group">
<input
v-model="settingsForms.garmin.email"
type="email"
placeholder="Garmin Email"
:disabled="loading"
/>
<input
v-model="settingsForms.garmin.password"
type="password"
placeholder="Garmin Password"
:disabled="loading"
/>
<button :disabled="loading" @click="loginGarmin">
<span v-if="loading" class="spinner"><RefreshCw :size="16" /></span>
{{ settingsStatus.garmin.configured ? 'Request MFA Code' : 'Connect Garmin' }}
</button>
</div>
</div>
<!-- STATE 3: MFA Required -->
<div v-else-if="mfaRequired">
<div class="doc-box" style="border-color: var(--accent-color)">
<strong>MFA Required</strong><br />
Please check your email for a verification code from Garmin.
</div>
<div class="form-group">
<input
v-model="settingsForms.garmin.mfa_code"
type="text"
placeholder="Enter 6-digit MFA Code"
:disabled="loading"
/>
<button :disabled="loading || !settingsForms.garmin.mfa_code" @click="loginGarmin">
<span v-if="loading" class="spinner"><RefreshCw :size="16" /></span>
Verify Code
</button>
<div style="display: flex; gap: 1rem; margin-top: 0.5rem">
<button class="secondary" style="flex: 1" :disabled="loading" @click="loginGarmin">
Resend Code
</button>
<button class="secondary" style="flex: 1" @click="mfaRequired = false">
Cancel
</button>
</div>
</div>
</div>
<!-- STATE 4 & 5: Authenticated & Syncing -->
<div v-else-if="authenticated">
<div class="doc-box success-border">
<CheckCircle2
:size="20"
color="var(--success-color)"
style="float: left; margin-right: 0.5rem"
/>
<strong>Connected</strong><br />
Logged in as {{ settingsStatus.garmin.email || settingsForms.garmin.email }}
</div>
<div class="form-group">
<button :disabled="syncing" @click="triggerSync">
<RefreshCw :size="16" :class="{ spinner: syncing }" />
{{ syncing ? 'Syncing...' : 'Sync Now' }}
</button>
<div class="text-center" style="margin-top: 1rem">
<p style="font-size: 0.8rem; color: var(--text-muted)">
Garmin session is valid. Data is synced locally.
</p>
</div>
</div>
</div>
<p v-if="authError" class="error">{{ authError }}</p>
<!-- 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 />
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 :disabled="loading" @click="saveServiceSettings('withings')">
Save Withings Config
</button>
<p v-if="settingsStatus.withings.configured" class="success">✓ Withings Configured</p>
</div>
</div>
<!-- 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 />
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 :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 />
Choose the aesthetic that fits your mood.
</div>
<div class="theme-preview">
<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')"
>
<Terminal :size="32" />
<p>Retro Hacker</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
:root {
/* Modern Blue (Default) */
--bg-color: #010409;
--card-bg: #0d1117;
--text-color: #c9d1d9;
--text-muted: #8b949e;
--accent-color: #1f6feb;
--accent-hover: #388bfd;
--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);
}
[data-theme='hacker'] {
--bg-color: #002b36;
--card-bg: #073642;
--text-color: #859900;
--text-muted: #586e75;
--accent-color: #2aa198;
--accent-hover: #93a1a1;
--border-color: #586e75;
--error-color: #dc322f;
--success-color: #859900;
--font-family: 'Courier New', Courier, monospace;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
margin: 0;
padding: 0;
transition: all 0.3s ease;
}
#app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
</style>
<style scoped>
header {
margin-bottom: 3rem;
text-align: center;
position: relative;
}
.settings-btn {
position: absolute;
top: 0;
right: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.settings-btn:hover {
color: var(--accent-color);
}
header h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
header p {
color: var(--text-muted);
font-size: 1.2rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
box-shadow: var(--card-shadow);
}
.doc-box {
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid var(--accent-color);
padding: 0.75rem;
font-size: 0.9rem;
line-height: 1.4;
color: var(--text-muted);
margin-bottom: 1rem;
}
.success-border {
border-left-color: var(--success-color) !important;
background: rgba(35, 134, 54, 0.1);
color: var(--text-color);
}
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin: 0.5rem 0;
color: var(--accent-color);
}
.form-group {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
input {
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.75rem;
border-radius: 6px;
}
button {
background: var(--accent-color);
color: white;
border: none;
padding: 0.75rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: var(--error-color);
font-size: 0.9rem;
margin-top: 1rem;
}
.success {
color: var(--success-color);
font-size: 0.9rem;
margin-top: 1rem;
}
.main-nav {
display: flex;
gap: 1rem;
margin-top: 1rem;
margin-bottom: -1rem;
}
.main-nav button {
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 20px;
font-size: 0.9rem;
}
.main-nav button:hover {
background: var(--card-bg);
color: var(--text-color);
}
.main-nav button.active {
background: var(--card-bg);
color: var(--accent-color);
border-color: var(--border-color);
font-weight: 600;
}
.sync-overlay {
grid-column: 1 / -1;
margin-bottom: 2rem;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.icon-btn {
background: transparent;
padding: 0.25rem;
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;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.activity-item:last-child {
border-bottom: none;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-color);
width: 90%;
max-width: 800px;
max-height: 90vh;
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
display: flex;
flex: 1;
overflow: hidden;
}
.modal-sidebar {
width: 200px;
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid var(--border-color);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-item {
padding: 0.75rem 1rem;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s;
}
.sidebar-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-color);
}
.sidebar-item.active {
background: var(--accent-color);
color: white;
}
.modal-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
/* Fix for Menu Visibility Issues */
.main-nav button {
white-space: nowrap; /* Prevent text wrapping */
opacity: 0.7; /* Better than muted color for text visibility */
}
.main-nav button:hover,
.main-nav button.active {
opacity: 1;
}
</style>