feat: Implement Analyze and Plan features, Smart Sync, Theming, and Optional Auth

This commit is contained in:
Moritz Graf
2026-01-01 14:22:48 +01:00
commit 8e55078c14
51 changed files with 5433 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1333
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.5.1",
"lucide-vue-next": "^0.562.0",
"vue": "^3.5.24",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}
+1
View File
@@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+641
View File
@@ -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>
+1
View File
@@ -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

+43
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+76
View File
@@ -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;
}
+213
View File
@@ -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>
+261
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})