This commit is contained in:
xds
2026-03-16 15:43:20 +03:00
parent 002e4cca31
commit 1d76f29244
14 changed files with 546 additions and 89 deletions

View File

@@ -94,9 +94,18 @@ export const useCoachingStore = defineStore('coaching', () => {
}
}
async function linkActivity(activityId: string, planId: string, week: number, day: string): Promise<void> {
await api.post('/coaching/link', { activity_id: activityId, plan_id: planId, week, day })
}
async function unlinkActivity(activityId: string): Promise<void> {
await api.post(`/coaching/unlink/${activityId}`)
}
return {
currentChat, activePlan, todayWorkout, loading, sending,
startOnboarding, getOnboardingStatus, sendMessage, createChat, getChat, listChats,
generatePlan, fetchActivePlan, fetchCompliance, fetchTodayWorkout, startPlanAdjustment,
linkActivity, unlinkActivity,
}
})

View File

@@ -38,6 +38,15 @@ export interface Interval {
duration: number | null
}
export interface ExerciseSet {
exercise_name: string
exercise_category: string | null
repetitions: number | null
weight: number | null
duration: number | null
start_time: string | null
}
export interface Activity {
id: string
rider_id: string
@@ -49,6 +58,10 @@ export interface Activity {
elevation_gain: number | null
metrics: ActivityMetrics | null
intervals: Interval[]
exercise_sets: ExerciseSet[] | null
training_plan_id: string | null
plan_week: number | null
plan_day: string | null
}
export interface DataPoint {
@@ -173,6 +186,14 @@ export interface TrainingPlan {
weeks: TrainingPlanWeek[]
}
export interface ComplianceDayStatus {
day: string
planned: string
workout_type: string
activity_id: string | null
completed: boolean
}
export interface ComplianceWeek {
week_number: number
focus: string
@@ -184,6 +205,7 @@ export interface ComplianceWeek {
actual_rides: number
adherence_pct: number
status: string
days: ComplianceDayStatus[]
}
export interface TodayWorkout {
@@ -198,4 +220,6 @@ export interface TodayWorkout {
duration_minutes: number
target_tss: number
target_if: number
linked_activity_id: string | null
completed: boolean
}

View File

@@ -52,6 +52,22 @@ const moods = [
const hasGps = computed(() => stream.value.some(dp => dp.latitude && dp.longitude))
const hasAltitude = computed(() => stream.value.some(dp => dp.altitude != null))
const hasSpeed = computed(() => stream.value.some(dp => dp.speed != null && dp.speed > 0))
const hasPower = computed(() => stream.value.some(dp => dp.power != null))
const hasStream = computed(() => stream.value.length > 0)
const isStrength = computed(() => {
const t = activity.value?.activity_type?.toLowerCase() || ''
return t.includes('strength') || t.includes('training') || (activity.value?.exercise_sets?.length ?? 0) > 0
})
const groupedExercises = computed(() => {
const sets = activity.value?.exercise_sets
if (!sets?.length) return []
const groups: Record<string, typeof sets> = {}
for (const s of sets) {
const name = s.exercise_name || 'Unknown'
;(groups[name] ??= []).push(s)
}
return Object.entries(groups).map(([name, sets]) => ({ name, sets }))
})
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
@@ -336,7 +352,7 @@ onMounted(async () => {
<p class="text-lg font-bold">{{ formatDuration(activity.duration) }}</p>
</template>
</Card>
<Card>
<Card v-if="activity.distance">
<template #content>
<p class="text-surface-500 text-xs uppercase">Distance</p>
<p class="text-lg font-bold">{{ formatDistance(activity.distance) }}</p>
@@ -366,8 +382,55 @@ onMounted(async () => {
<p class="text-lg font-bold">{{ activity.elevation_gain }}m</p>
</template>
</Card>
<Card v-if="activity.metrics?.calories">
<template #content>
<p class="text-surface-500 text-xs uppercase">Calories</p>
<p class="text-lg font-bold">{{ activity.metrics.calories }} kcal</p>
</template>
</Card>
<Card v-if="isStrength && groupedExercises.length">
<template #content>
<p class="text-surface-500 text-xs uppercase">Exercises</p>
<p class="text-lg font-bold">{{ groupedExercises.length }}</p>
</template>
</Card>
<Card v-if="isStrength && activity.exercise_sets?.length">
<template #content>
<p class="text-surface-500 text-xs uppercase">Sets</p>
<p class="text-lg font-bold">{{ activity.exercise_sets.length }}</p>
</template>
</Card>
</div>
<!-- Exercise Sets (Strength) -->
<Card v-if="groupedExercises.length > 0" class="mb-6">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-list text-primary"></i>
Exercises
</div>
</template>
<template #content>
<div class="space-y-4">
<div v-for="group in groupedExercises" :key="group.name">
<h4 class="font-semibold text-surface-800 mb-2">{{ group.name }}</h4>
<div class="flex flex-wrap gap-2">
<div
v-for="(set, i) in group.sets"
:key="i"
class="bg-surface-50 border border-surface-200 rounded-lg px-3 py-2 text-sm"
>
<span class="text-surface-400 text-xs mr-1">Set {{ i + 1 }}</span>
<span v-if="set.repetitions" class="font-semibold">{{ set.repetitions }} reps</span>
<span v-if="set.weight" class="text-surface-500"> x {{ set.weight }} kg</span>
<span v-if="set.duration && !set.repetitions" class="font-semibold">{{ Math.round(set.duration) }}s</span>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- AI Summary -->
<Card class="mb-6">
<template #title>
@@ -413,8 +476,8 @@ onMounted(async () => {
</Card>
<!-- Power/HR/Cadence chart -->
<Card v-if="stream.length > 0" class="mb-6">
<template #title>Power / HR / Cadence</template>
<Card v-if="hasStream && (hasPower || stream.some(dp => dp.heart_rate != null))" class="mb-6">
<template #title>{{ hasPower ? 'Power / HR / Cadence' : 'Heart Rate' }}</template>
<template #content>
<VChart :option="buildStreamChart()" style="height: 300px" autoresize />
</template>

View File

@@ -1,5 +1,6 @@
<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'
@@ -186,11 +187,14 @@ onMounted(async () => {
</div>
<!-- Today's workout card -->
<Card v-if="todayWorkout && onboardingCompleted" class="mb-6 border-l-4 border-l-primary">
<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>
<p class="text-surface-500 text-xs uppercase mb-1">Тренировка на сегодня</p>
<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>
@@ -351,44 +355,74 @@ onMounted(async () => {
<div v-else class="space-y-4">
<Card v-for="week in compliance" :key="week.week_number">
<template #content>
<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 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="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 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>
<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>
<!-- 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>