feat: Implement Analyze and Plan features, Smart Sync, Theming, and Optional Auth
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
<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 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 settingsStatus = ref({ garmin: {}, withings: {}, gemini: {} })
|
||||
const settingsForms = ref({
|
||||
garmin: { email: '', password: '', mfa_code: '' },
|
||||
withings: { client_id: '', client_secret: '' },
|
||||
gemini: { api_key: '' }
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/settings')
|
||||
settingsStatus.value = await res.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', 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()
|
||||
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'
|
||||
}
|
||||
} catch (error) {
|
||||
authError.value = 'Could not connect to backend'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const triggerSync = async () => {
|
||||
syncing.value = true
|
||||
try {
|
||||
await fetch('http://localhost:8000/sync', { method: 'POST' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error)
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const actRes = await fetch('http://localhost:8000/activities')
|
||||
activities.value = await actRes.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 === 'hacker' ? 'hacker' : '')
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
fetchSettings()
|
||||
setTheme(currentTheme.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<h1>Fit<span style="color: var(--accent-color);">Mop</span></h1>
|
||||
<p>Your personal coach orchestrator</p>
|
||||
|
||||
<nav class="main-nav">
|
||||
<button :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
|
||||
<LayoutDashboard :size="18" /> Dashboard
|
||||
</button>
|
||||
<button :class="{active: currentView === 'analyze'}" @click="currentView = 'analyze'">
|
||||
<LineChart :size="18" /> Analyze
|
||||
</button>
|
||||
<button :class="{active: currentView === 'plan'}" @click="currentView = 'plan'">
|
||||
<Calendar :size="18" /> Plan
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="settings-btn" @click="settingsOpen = true"><Settings :size="24" /></button>
|
||||
</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" /> Weekly Activity</h3>
|
||||
<button v-if="authenticated" class="icon-btn" @click="triggerSync" :disabled="syncing">
|
||||
<RefreshCw :size="16" :class="{ 'spinner': syncing }" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-value">4.2h</div>
|
||||
<p>+12% from last week</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><Dumbbell :size="20" /> Strength Sessions</h3>
|
||||
<div class="stat-value">3</div>
|
||||
<p>Target: 4 sessions</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><TrendingUp :size="20" /> VO2 Max</h3>
|
||||
<div class="stat-value">52</div>
|
||||
<p>Status: Superior</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" />
|
||||
</div>
|
||||
<p v-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 && 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>
|
||||
<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 style="font-weight: 600;">{{ Math.round(activity.duration / 60) }}m</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 === '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.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input v-model="settingsForms.garmin.email" type="email" placeholder="Garmin Email" />
|
||||
<input v-model="settingsForms.garmin.password" type="password" placeholder="Garmin Password" />
|
||||
|
||||
<div v-if="mfaRequired" class="form-group" style="margin-top:0">
|
||||
<p style="font-size: 0.8rem; margin:0">Enter MFA Code from email:</p>
|
||||
<input v-model="settingsForms.garmin.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">
|
||||
{{ 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>
|
||||
</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>
|
||||
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>
|
||||
<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 @click="saveServiceSettings('gemini')" :disabled="loading">Save API Key</button>
|
||||
<p v-if="settingsStatus.gemini.configured" class="success">✓ Gemini AI Configured</p>
|
||||
</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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.doc-box {
|
||||
background: var(--card-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<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"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,76 @@
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #0d1117;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: #58a6ff;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #58a6ff, #1f6feb);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { RotateCw, Activity, Loader2, Calendar, CheckCircle, AlertTriangle } from 'lucide-vue-next'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const loading = ref(true)
|
||||
const syncStatus = ref('idle') // idle, syncing, success, warning
|
||||
const syncMessage = ref('')
|
||||
const timeHorizon = ref(12) // Default 12 weeks
|
||||
const chartData = ref({ labels: [], datasets: [] })
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Hours' } }
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/analyze/stats?weeks=${timeHorizon.value}`)
|
||||
const data = await res.json()
|
||||
chartData.value = data.weekly
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runSmartSync = async () => {
|
||||
syncStatus.value = 'syncing'
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/sync/smart', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
syncStatus.value = 'success'
|
||||
syncMessage.value = data.synced_count > 0 ? `Synced ${data.synced_count} new` : 'Up to date'
|
||||
await fetchData()
|
||||
} else {
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = "Auth check failed"
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'warning'
|
||||
syncMessage.value = "Sync error"
|
||||
}
|
||||
}
|
||||
|
||||
watch(timeHorizon, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
runSmartSync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analyze-view">
|
||||
<div class="card header-card">
|
||||
<div>
|
||||
<h3><Activity :size="24" /> Analyze Performance</h3>
|
||||
<p>Your fitness journey over time.</p>
|
||||
</div>
|
||||
|
||||
<!-- Sync Status Indicator -->
|
||||
<div class="sync-status" :class="syncStatus">
|
||||
<Loader2 v-if="syncStatus === 'syncing'" class="spinner" :size="16" />
|
||||
<CheckCircle v-if="syncStatus === 'success'" :size="16" />
|
||||
<AlertTriangle v-if="syncStatus === 'warning'" :size="16" />
|
||||
|
||||
<span v-if="syncStatus === 'idle'">Ready</span>
|
||||
<span v-if="syncStatus === 'syncing'">Syncing...</span>
|
||||
<span v-if="syncStatus === 'success'">{{ syncMessage }}</span>
|
||||
<span v-if="syncStatus === 'warning'">Check Connection</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Weekly Volume</h3>
|
||||
<div class="time-toggles">
|
||||
<button :class="{active: timeHorizon === 1}" @click="timeHorizon = 1">7D</button>
|
||||
<button :class="{active: timeHorizon === 4}" @click="timeHorizon = 4">4W</button>
|
||||
<button :class="{active: timeHorizon === 12}" @click="timeHorizon = 12">12W</button>
|
||||
<button :class="{active: timeHorizon === 52}" @click="timeHorizon = 52">1Y</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<Loader2 class="spinner" :size="32" />
|
||||
<p>Crunching the numbers...</p>
|
||||
</div>
|
||||
<div v-else class="chart-wrapper">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for Withings -->
|
||||
<div class="card">
|
||||
<h3>Body Composition</h3>
|
||||
<p style="color: var(--text-muted); font-style: italic;">
|
||||
Connect Withings in Settings to visualize weight and body composition trends here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analyze-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sync-status.success {
|
||||
color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
background: rgba(46, 160, 67, 0.1);
|
||||
}
|
||||
|
||||
.sync-status.warning {
|
||||
color: #e3b341;
|
||||
border-color: #e3b341;
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.time-toggles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--bg-color);
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.time-toggles button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.time-toggles button.active {
|
||||
background: var(--card-bg); /* or accent if preferred, but usually subtler */
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Dumbbell, MessageSquare, Plus, Save, Upload, Loader2, Calendar } from 'lucide-vue-next'
|
||||
|
||||
const remoteWorkouts = ref([])
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const currentWorkout = ref(null)
|
||||
|
||||
const fetchWorkouts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts')
|
||||
if (res.ok) {
|
||||
remoteWorkouts.value = await res.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workouts", 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 uploadWorkout = async () => {
|
||||
if (!currentWorkout.value) return
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/workouts/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentWorkout.value)
|
||||
})
|
||||
if (res.ok) {
|
||||
alert("Workout uploaded to Garmin Connect successfully!")
|
||||
creating.value = false
|
||||
currentWorkout.value = null
|
||||
fetchWorkouts()
|
||||
} else {
|
||||
alert("Upload failed. Check backend logs.")
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Upload failed: " + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkouts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-view">
|
||||
|
||||
<!-- Hero / Creation Mode -->
|
||||
<div class="card creation-card">
|
||||
<div v-if="!creating" class="chat-interface">
|
||||
<h3><MessageSquare :size="24" /> AI Workout Crafter</h3>
|
||||
<p>Describe your goal (e.g., "Leg day with squats and lunges", "30 min interval run")</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="chatInput"
|
||||
@keyup.enter="generateWorkout"
|
||||
type="text"
|
||||
placeholder="Type your workout request..."
|
||||
:disabled="chatLoading"
|
||||
/>
|
||||
<button @click="generateWorkout" :disabled="chatLoading || !chatInput">
|
||||
<Loader2 v-if="chatLoading" class="spinner" :size="20" />
|
||||
<span v-else>Generate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="workout-editor">
|
||||
<div class="editor-header">
|
||||
<input v-model="currentWorkout.workoutName" class="title-input"/>
|
||||
<div class="actions">
|
||||
<button class="secondary" @click="creating = false">Cancel</button>
|
||||
<button @click="uploadWorkout"><Upload :size="16" /> Sync to Garmin</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Existing Workouts -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3><Calendar :size="20" /> Available Workouts (Garmin)</h3>
|
||||
<button class="icon-btn" @click="fetchWorkouts"><Loader2 v-if="loading" class="spinner" :size="16" /><span v-else>Refresh</span></button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && remoteWorkouts.length === 0" style="text-align: center; padding: 2rem;">Loading workouts...</div>
|
||||
|
||||
<div class="workout-grid">
|
||||
<div v-for="workout in remoteWorkouts" :key="workout.workoutId" class="workout-item">
|
||||
<div class="workout-icon">
|
||||
<Dumbbell v-if="workout.sportType.sportTypeKey === 'strength_training'" />
|
||||
<span v-else>🏃</span>
|
||||
</div>
|
||||
<div class="workout-info">
|
||||
<strong>{{ workout.workoutName }}</strong>
|
||||
<div class="meta">{{ workout.sportType.sportTypeKey }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workout-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-color);
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.step-type {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.workout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.workout-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.workout-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user