414 lines
9.6 KiB
Vue
414 lines
9.6 KiB
Vue
<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 {
|
|
Activity,
|
|
Loader2,
|
|
CheckCircle,
|
|
AlertTriangle,
|
|
Send,
|
|
Bot
|
|
} 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 (err) {
|
|
syncStatus.value = 'warning'
|
|
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
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<!-- 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">
|
|
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);
|
|
}
|
|
|
|
.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>
|