Files
sport-platform/frontend/src/views/CoachView.vue
2026-03-16 15:43:20 +03:00

445 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref, computed, nextTick } from 'vue'
import { RouterLink } from 'vue-router'
import { useCoachingStore } from '../stores/coaching'
import { useAuthStore } from '../stores/auth'
import type { ChatMessage, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models'
import Card from 'primevue/card'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
const coaching = useCoachingStore()
const auth = useAuthStore()
const activeTab = ref<'chat' | 'plan' | 'progress'>('chat')
const messageInput = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
const compliance = ref<ComplianceWeek[]>([])
const messages = computed<ChatMessage[]>(() => coaching.currentChat?.messages || [])
const plan = computed<TrainingPlan | null>(() => coaching.activePlan)
const todayWorkout = computed<TodayWorkout | null>(() => coaching.todayWorkout)
const onboardingCompleted = ref(false)
const chatReady = ref(false)
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
async function sendMessage() {
const text = messageInput.value.trim()
if (!text || !coaching.currentChat?.chat_id) return
messageInput.value = ''
await coaching.sendMessage(coaching.currentChat.chat_id, text)
scrollToBottom()
// Check if onboarding just completed
if (coaching.currentChat?.onboarding_completed && !onboardingCompleted.value) {
onboardingCompleted.value = true
await auth.fetchRider()
}
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
await sendMessage()
}
}
async function startNewChat() {
await coaching.createChat()
scrollToBottom()
}
async function handleGeneratePlan() {
await coaching.generatePlan()
if (coaching.activePlan) {
activeTab.value = 'plan'
await loadCompliance()
}
}
async function handleAdjustPlan() {
await coaching.startPlanAdjustment()
activeTab.value = 'chat'
scrollToBottom()
}
async function loadCompliance() {
if (plan.value?.id) {
try {
compliance.value = await coaching.fetchCompliance(plan.value.id)
} catch { /* no compliance data */ }
}
}
function workoutTypeColor(type: string): string {
const colors: Record<string, string> = {
rest: 'secondary',
recovery: 'secondary',
endurance: 'info',
tempo: 'warn',
sweetspot: 'warn',
threshold: 'danger',
vo2max: 'danger',
sprint: 'contrast',
race: 'contrast',
}
return colors[type] || 'info'
}
function workoutTypeLabel(type: string): string {
const labels: Record<string, string> = {
rest: 'Отдых',
recovery: 'Восстановление',
endurance: 'Выносливость',
tempo: 'Темпо',
sweetspot: 'Sweet Spot',
threshold: 'Порог',
vo2max: 'VO2max',
sprint: 'Спринт',
race: 'Гонка',
}
return labels[type] || type
}
function dayLabel(day: string): string {
const labels: Record<string, string> = {
monday: 'Пн',
tuesday: 'Вт',
wednesday: 'Ср',
thursday: 'Чт',
friday: 'Пт',
saturday: 'Сб',
sunday: 'Вс',
}
return labels[day] || day
}
onMounted(async () => {
// Check onboarding status
try {
const status = await coaching.getOnboardingStatus()
onboardingCompleted.value = status.onboarding_completed
if (!status.onboarding_completed) {
// Start or resume onboarding
await coaching.startOnboarding()
chatReady.value = true
scrollToBottom()
} else {
// Load active plan
await coaching.fetchActivePlan()
await coaching.fetchTodayWorkout()
if (plan.value) {
await loadCompliance()
}
// Start a general chat
await coaching.createChat()
chatReady.value = true
}
} catch {
chatReady.value = true
}
})
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">AI Тренер</h1>
<div v-if="onboardingCompleted" class="flex gap-2">
<Button
:label="activeTab === 'chat' ? '' : 'Чат'"
icon="pi pi-comments"
:severity="activeTab === 'chat' ? 'primary' : 'secondary'"
:text="activeTab !== 'chat'"
size="small"
@click="activeTab = 'chat'"
/>
<Button
:label="activeTab === 'plan' ? '' : 'План'"
icon="pi pi-calendar"
:severity="activeTab === 'plan' ? 'primary' : 'secondary'"
:text="activeTab !== 'plan'"
size="small"
@click="activeTab = 'plan'"
/>
<Button
:label="activeTab === 'progress' ? '' : 'Прогресс'"
icon="pi pi-chart-bar"
:severity="activeTab === 'progress' ? 'primary' : 'secondary'"
:text="activeTab !== 'progress'"
size="small"
@click="activeTab = 'progress'"
/>
</div>
</div>
<!-- Today's workout card -->
<Card v-if="todayWorkout && onboardingCompleted" :class="['mb-6 border-l-4', todayWorkout.completed ? 'border-l-green-500' : 'border-l-primary']">
<template #content>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2 mb-1">
<p class="text-surface-500 text-xs uppercase">Тренировка на сегодня</p>
<Tag v-if="todayWorkout.completed" value="Выполнено" severity="success" class="text-xs" />
</div>
<p class="text-lg font-bold">{{ todayWorkout.title }}</p>
<p class="text-surface-600 text-sm mt-1">{{ todayWorkout.description }}</p>
</div>
<div class="flex items-center gap-3">
<Tag :value="workoutTypeLabel(todayWorkout.workout_type)" :severity="workoutTypeColor(todayWorkout.workout_type)" />
<div v-if="todayWorkout.duration_minutes" class="text-right">
<p class="text-sm text-surface-500">{{ todayWorkout.duration_minutes }} мин</p>
<p v-if="todayWorkout.target_tss" class="text-xs text-surface-400">TSS {{ todayWorkout.target_tss }}</p>
</div>
</div>
</div>
</template>
</Card>
<!-- Onboarding banner -->
<Card v-if="!onboardingCompleted && chatReady" class="mb-4 bg-blue-50 border-blue-200">
<template #content>
<div class="flex items-center gap-3">
<i class="pi pi-info-circle text-blue-500 text-xl"></i>
<div>
<p class="font-semibold text-blue-800">Интервью с тренером</p>
<p class="text-blue-600 text-sm">Ответьте на несколько вопросов, чтобы AI тренер мог создать персональный план тренировок.</p>
</div>
</div>
</template>
</Card>
<!-- Chat Tab -->
<div v-if="activeTab === 'chat'" class="flex flex-col" style="height: calc(100vh - 300px); min-height: 400px;">
<!-- Chat actions -->
<div v-if="onboardingCompleted" class="flex gap-2 mb-3">
<Button label="Новый чат" icon="pi pi-plus" size="small" severity="secondary" outlined @click="startNewChat" />
<Button v-if="!plan" label="Сгенерировать план" icon="pi pi-bolt" size="small" severity="primary" :loading="coaching.loading" @click="handleGeneratePlan" />
<Button v-if="plan" label="Корректировать план" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto space-y-3 p-4 bg-surface-50 rounded-xl border border-surface-200">
<div v-if="coaching.loading && messages.length === 0" class="flex items-center justify-center h-full text-surface-400">
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
Загрузка...
</div>
<div v-for="(msg, i) in messages" :key="i" :class="['flex', msg.role === 'user' ? 'justify-end' : 'justify-start']">
<div
:class="[
'max-w-[80%] md:max-w-[70%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
msg.role === 'user'
? 'bg-primary text-white rounded-br-md'
: 'bg-white border border-surface-200 text-surface-800 rounded-bl-md shadow-sm'
]"
>
<div v-if="msg.role === 'model'" class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold text-primary">VeloBrain</span>
</div>
<div class="whitespace-pre-wrap" v-html="formatMessage(msg.text)"></div>
</div>
</div>
<div v-if="coaching.sending" class="flex justify-start">
<div class="bg-white border border-surface-200 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div class="flex items-center gap-2 text-surface-400 text-sm">
<i class="pi pi-spin pi-spinner"></i>
Думаю...
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="flex gap-2 mt-3">
<InputText
v-model="messageInput"
:placeholder="onboardingCompleted ? 'Задайте вопрос тренеру...' : 'Ваш ответ...'"
class="flex-1"
:disabled="coaching.sending"
@keydown="handleKeydown"
/>
<Button
icon="pi pi-send"
:disabled="!messageInput.trim() || coaching.sending"
@click="sendMessage"
/>
</div>
</div>
<!-- Plan Tab -->
<div v-if="activeTab === 'plan'">
<div v-if="!plan" class="text-center py-12">
<i class="pi pi-calendar text-4xl text-surface-300 mb-4"></i>
<p class="text-surface-500 mb-4">План тренировок ещё не создан</p>
<Button label="Сгенерировать план" icon="pi pi-bolt" :loading="coaching.loading" @click="handleGeneratePlan" />
</div>
<div v-else>
<!-- Plan header -->
<Card class="mb-6">
<template #content>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-xl font-bold">{{ plan.goal }}</h2>
<p class="text-surface-500 text-sm mt-1">{{ plan.description }}</p>
<div class="flex items-center gap-3 mt-2">
<Tag :value="plan.phase || 'base'" severity="info" />
<span class="text-sm text-surface-500">
{{ new Date(plan.start_date).toLocaleDateString('ru-RU') }} —
{{ new Date(plan.end_date).toLocaleDateString('ru-RU') }}
</span>
</div>
</div>
<div class="flex gap-2">
<Button label="Корректировать" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
<Button label="Новый план" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="coaching.loading" @click="handleGeneratePlan" />
</div>
</div>
</template>
</Card>
<!-- Weeks -->
<div v-for="week in plan.weeks" :key="week.week_number" class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-lg">Неделя {{ week.week_number }}</h3>
<div class="flex items-center gap-3 text-sm text-surface-500">
<span>{{ week.focus }}</span>
<span v-if="week.target_tss">TSS {{ week.target_tss }}</span>
<span v-if="week.target_hours">{{ week.target_hours }}ч</span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<Card v-for="day in week.days" :key="day.day" class="!shadow-none border border-surface-200">
<template #content>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-surface-600">{{ dayLabel(day.day) }}</span>
<Tag :value="workoutTypeLabel(day.workout_type)" :severity="workoutTypeColor(day.workout_type)" class="text-xs" />
</div>
<p class="font-medium text-sm">{{ day.title }}</p>
<p v-if="day.description" class="text-xs text-surface-500 mt-1">{{ day.description }}</p>
<div v-if="day.duration_minutes > 0" class="flex items-center gap-3 mt-2 text-xs text-surface-400">
<span>{{ day.duration_minutes }} мин</span>
<span v-if="day.target_tss">TSS {{ day.target_tss }}</span>
<span v-if="day.target_if">IF {{ day.target_if }}</span>
</div>
</template>
</Card>
</div>
</div>
</div>
</div>
<!-- Progress Tab -->
<div v-if="activeTab === 'progress'">
<div v-if="compliance.length === 0" class="text-center py-12">
<i class="pi pi-chart-bar text-4xl text-surface-300 mb-4"></i>
<p class="text-surface-500">Нет данных о прогрессе</p>
</div>
<div v-else class="space-y-4">
<Card v-for="week in compliance" :key="week.week_number">
<template #content>
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold">Неделя {{ week.week_number }}</h3>
<Tag
:value="week.status === 'upcoming' ? 'Впереди' : week.status === 'current' ? 'Текущая' : 'Завершена'"
:severity="week.status === 'upcoming' ? 'secondary' : week.status === 'current' ? 'info' : 'success'"
class="text-xs"
/>
<span class="text-sm text-surface-500">{{ week.focus }}</span>
</div>
<div class="mb-2">
<div class="flex items-center justify-between text-sm mb-1">
<span class="text-surface-500">Выполнение</span>
<span class="font-semibold">{{ week.adherence_pct }}%</span>
</div>
<ProgressBar
:value="week.adherence_pct"
:showValue="false"
style="height: 8px"
:class="week.adherence_pct >= 80 ? '' : week.adherence_pct >= 50 ? '[&_.p-progressbar-value]:!bg-amber-500' : '[&_.p-progressbar-value]:!bg-red-500'"
/>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<p class="text-surface-500 text-xs">Тренировки</p>
<p class="font-semibold">{{ week.actual_rides }}/{{ week.planned_rides }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">TSS</p>
<p class="font-semibold">{{ week.actual_tss }}/{{ week.planned_tss }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">Часы</p>
<p class="font-semibold">{{ week.actual_hours }}/{{ week.planned_hours }}</p>
</div>
</div>
</div>
<!-- Per-day breakdown -->
<div v-if="week.days?.length" class="flex flex-wrap gap-2">
<div
v-for="d in week.days"
:key="d.day"
:class="[
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs border',
d.completed
? 'bg-green-50 border-green-200 text-green-700'
: d.workout_type === 'rest'
? 'bg-surface-50 border-surface-200 text-surface-400'
: 'bg-white border-surface-200 text-surface-600'
]"
>
<i :class="['pi text-xs', d.completed ? 'pi-check-circle text-green-500' : d.workout_type === 'rest' ? 'pi-minus text-surface-300' : 'pi-circle text-surface-300']"></i>
<span class="font-semibold">{{ dayLabel(d.day) }}</span>
<span>{{ d.planned }}</span>
<RouterLink
v-if="d.activity_id"
:to="{ name: 'activity-detail', params: { id: d.activity_id } }"
class="text-primary hover:underline ml-1"
@click.stop
>
<i class="pi pi-external-link text-xs"></i>
</RouterLink>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</template>
<script lang="ts">
function formatMessage(text: string): string {
// Strip [ONBOARDING_COMPLETE] and JSON blocks from display
let cleaned = text.replace(/\[ONBOARDING_COMPLETE\]/g, '')
cleaned = cleaned.replace(/```json[\s\S]*?```/g, '')
cleaned = cleaned.replace(/\[PLAN_ADJUSTED\]/g, '')
return cleaned.trim()
}
</script>