refix
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi'
|
||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, FitnessEntry, DiaryEntry, WeeklyStats, PersonalRecord } from '../types/models'
|
||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, FitnessEntry, DiaryEntry, WeeklyStats, PersonalRecord, CalendarDay, MonthlyTrend, FtpDetection, WeeklyDigest, ProgressSummary } from '../types/models'
|
||||
|
||||
export const useActivitiesStore = defineStore('activities', () => {
|
||||
const { api } = useApi()
|
||||
@@ -49,8 +49,8 @@ export const useActivitiesStore = defineStore('activities', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchAiSummary(id: string): Promise<string> {
|
||||
const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`)
|
||||
async function fetchAiSummary(id: string, force = false): Promise<string> {
|
||||
const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`, { force })
|
||||
return data.summary
|
||||
}
|
||||
|
||||
@@ -85,5 +85,35 @@ export const useActivitiesStore = defineStore('activities', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
return { activities, total, loading, fetchActivities, fetchActivity, fetchStream, fetchZones, fetchPowerCurve, uploadFit, fetchAiSummary, fetchFitness, deleteActivity, fetchDiary, updateDiary, fetchWeeklyStats, fetchPersonalRecords }
|
||||
async function fetchActivityCalendar(months = 12): Promise<CalendarDay[]> {
|
||||
const { data } = await api.get<CalendarDay[]>('/rider/activity-calendar', { params: { months } })
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchMonthlyTrends(months = 12): Promise<MonthlyTrend[]> {
|
||||
const { data } = await api.get<MonthlyTrend[]>('/rider/monthly-trends', { params: { months } })
|
||||
return data
|
||||
}
|
||||
|
||||
async function detectFtp(): Promise<FtpDetection> {
|
||||
const { data } = await api.get<FtpDetection>('/rider/detect-ftp')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchWeeklyDigest(): Promise<WeeklyDigest> {
|
||||
const { data } = await api.get<WeeklyDigest>('/rider/weekly-digest')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchProgressSummary(): Promise<ProgressSummary> {
|
||||
const { data } = await api.get<ProgressSummary>('/rider/progress-summary')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchAiProgress(): Promise<string> {
|
||||
const { data } = await api.get<{ analysis: string }>('/rider/ai-progress')
|
||||
return data.analysis
|
||||
}
|
||||
|
||||
return { activities, total, loading, fetchActivities, fetchActivity, fetchStream, fetchZones, fetchPowerCurve, uploadFit, fetchAiSummary, fetchFitness, deleteActivity, fetchDiary, updateDiary, fetchWeeklyStats, fetchPersonalRecords, fetchActivityCalendar, fetchMonthlyTrends, detectFtp, fetchWeeklyDigest, fetchProgressSummary, fetchAiProgress }
|
||||
})
|
||||
|
||||
@@ -208,6 +208,75 @@ export interface ComplianceWeek {
|
||||
days: ComplianceDayStatus[]
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string
|
||||
count: number
|
||||
duration: number
|
||||
tss: number
|
||||
}
|
||||
|
||||
export interface MonthlyTrend {
|
||||
month: string
|
||||
rides: number
|
||||
hours: number
|
||||
distance_km: number
|
||||
avg_power: number | null
|
||||
avg_np: number | null
|
||||
avg_hr: number | null
|
||||
tss: number
|
||||
w_per_kg: number | null
|
||||
}
|
||||
|
||||
export interface FtpDetection {
|
||||
detected_ftp: number | null
|
||||
best_20min_power: number | null
|
||||
activity_id: string | null
|
||||
activity_name: string | null
|
||||
date: string | null
|
||||
current_ftp: number | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface WeeklyDigest {
|
||||
digest: string | null
|
||||
message?: string
|
||||
week_start: string
|
||||
week_end: string
|
||||
total_activities: number
|
||||
total_hours: number
|
||||
total_tss: number
|
||||
total_distance_km: number
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
rides: number
|
||||
hours: number
|
||||
distance_km: number
|
||||
tss: number
|
||||
avg_power: number | null
|
||||
avg_np: number | null
|
||||
avg_hr: number | null
|
||||
}
|
||||
|
||||
export interface FitnessSnapshot {
|
||||
ctl: number | null
|
||||
atl: number | null
|
||||
tsb: number | null
|
||||
}
|
||||
|
||||
export interface ProgressSummary {
|
||||
this_week: PeriodStats
|
||||
last_week: PeriodStats
|
||||
this_month: PeriodStats
|
||||
last_month: PeriodStats
|
||||
fitness: FitnessSnapshot
|
||||
fitness_7d_ago: FitnessSnapshot
|
||||
total_activities: number
|
||||
days_since_last_activity: number | null
|
||||
ftp: number | null
|
||||
weight: number | null
|
||||
}
|
||||
|
||||
export interface TodayWorkout {
|
||||
plan_id: string
|
||||
plan_goal: string
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { onMounted, ref, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, DiaryEntry } from '../types/models'
|
||||
import { useCoachingStore } from '../stores/coaching'
|
||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, DiaryEntry, TrainingPlan } from '../types/models'
|
||||
import Card from 'primevue/card'
|
||||
import Tag from 'primevue/tag'
|
||||
import DataTable from 'primevue/datatable'
|
||||
@@ -27,6 +28,7 @@ use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, Lege
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useActivitiesStore()
|
||||
const coaching = useCoachingStore()
|
||||
|
||||
const activity = ref<Activity | null>(null)
|
||||
const stream = ref<DataPoint[]>([])
|
||||
@@ -41,6 +43,62 @@ const diary = ref<DiaryEntry>({ rider_notes: null, mood: null, rpe: null, sleep_
|
||||
const diarySaving = ref(false)
|
||||
const diarySaved = ref(false)
|
||||
|
||||
// Plan linking
|
||||
const activePlan = ref<TrainingPlan | null>(null)
|
||||
const linkMenuOpen = ref(false)
|
||||
const linking = ref(false)
|
||||
|
||||
const planDayOptions = computed(() => {
|
||||
if (!activePlan.value?.weeks) return []
|
||||
const options: { label: string; week: number; day: string }[] = []
|
||||
for (const week of activePlan.value.weeks) {
|
||||
for (const d of week.days) {
|
||||
if (d.workout_type === 'rest') continue
|
||||
options.push({
|
||||
label: `Нед. ${week.week_number}, ${dayLabelMap[d.day] || d.day} — ${d.title || d.workout_type}`,
|
||||
week: week.week_number,
|
||||
day: d.day,
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const dayLabelMap: Record<string, string> = {
|
||||
monday: 'Пн', tuesday: 'Вт', wednesday: 'Ср', thursday: 'Чт',
|
||||
friday: 'Пт', saturday: 'Сб', sunday: 'Вс',
|
||||
}
|
||||
|
||||
async function linkToPlan(option: { week: number; day: string }) {
|
||||
if (!activity.value || !activePlan.value) return
|
||||
linking.value = true
|
||||
try {
|
||||
await coaching.linkActivity(activity.value.id, activePlan.value.id, option.week, option.day)
|
||||
activity.value.training_plan_id = activePlan.value.id
|
||||
activity.value.plan_week = option.week
|
||||
activity.value.plan_day = option.day
|
||||
linkMenuOpen.value = false
|
||||
// Clear cached summary so re-generation picks up plan context
|
||||
aiSummary.value = ''
|
||||
} finally {
|
||||
linking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkFromPlan() {
|
||||
if (!activity.value) return
|
||||
linking.value = true
|
||||
try {
|
||||
await coaching.unlinkActivity(activity.value.id)
|
||||
activity.value.training_plan_id = null
|
||||
activity.value.plan_week = null
|
||||
activity.value.plan_day = null
|
||||
aiSummary.value = ''
|
||||
} finally {
|
||||
linking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moods = [
|
||||
{ label: 'Great', value: 'great' },
|
||||
{ label: 'Good', value: 'good' },
|
||||
@@ -281,6 +339,19 @@ async function loadAiSummary() {
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateSummary() {
|
||||
if (!activity.value) return
|
||||
aiLoading.value = true
|
||||
aiSummary.value = ''
|
||||
try {
|
||||
aiSummary.value = await store.fetchAiSummary(activity.value.id, true)
|
||||
} catch {
|
||||
aiSummary.value = 'Failed to generate summary. Check your Gemini API key.'
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiary() {
|
||||
if (!activity.value) return
|
||||
diarySaving.value = true
|
||||
@@ -301,6 +372,114 @@ async function handleDelete() {
|
||||
router.push({ name: 'activities' })
|
||||
}
|
||||
|
||||
async function shareActivity() {
|
||||
if (!activity.value) return
|
||||
const a = activity.value
|
||||
const m = a.metrics
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const W = 800, H = 420
|
||||
canvas.width = W
|
||||
canvas.height = H
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// Background gradient
|
||||
const grad = ctx.createLinearGradient(0, 0, W, H)
|
||||
grad.addColorStop(0, '#0f172a')
|
||||
grad.addColorStop(1, '#1e3a5f')
|
||||
ctx.fillStyle = grad
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// Accent line
|
||||
ctx.fillStyle = '#3b82f6'
|
||||
ctx.fillRect(0, 0, W, 4)
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 28px system-ui, -apple-system, sans-serif'
|
||||
ctx.fillText(a.name || 'Ride', 40, 60)
|
||||
|
||||
// Date & type
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.font = '16px system-ui, -apple-system, sans-serif'
|
||||
const dateStr = new Date(a.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
ctx.fillText(`${dateStr} • ${a.activity_type}`, 40, 90)
|
||||
|
||||
// Metrics grid
|
||||
const metrics: { label: string; value: string; unit: string }[] = []
|
||||
metrics.push({ label: 'Duration', value: formatDuration(a.duration), unit: '' })
|
||||
if (a.distance) metrics.push({ label: 'Distance', value: (a.distance / 1000).toFixed(1), unit: 'km' })
|
||||
if (a.elevation_gain) metrics.push({ label: 'Elevation', value: `${a.elevation_gain.toFixed(0)}`, unit: 'm' })
|
||||
if (m?.avg_power) metrics.push({ label: 'Avg Power', value: `${m.avg_power}`, unit: 'W' })
|
||||
if (m?.normalized_power) metrics.push({ label: 'NP', value: `${m.normalized_power}`, unit: 'W' })
|
||||
if (m?.tss) metrics.push({ label: 'TSS', value: `${m.tss}`, unit: '' })
|
||||
if (m?.intensity_factor) metrics.push({ label: 'IF', value: `${m.intensity_factor}`, unit: '' })
|
||||
if (m?.avg_hr) metrics.push({ label: 'Avg HR', value: `${m.avg_hr}`, unit: 'bpm' })
|
||||
|
||||
const cols = Math.min(metrics.length, 4)
|
||||
const boxW = (W - 80) / cols
|
||||
const startY = 130
|
||||
|
||||
metrics.forEach((item, i) => {
|
||||
const row = Math.floor(i / cols)
|
||||
const col = i % cols
|
||||
const x = 40 + col * boxW
|
||||
const y = startY + row * 110
|
||||
|
||||
// Box background
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, boxW - 12, 90, 10)
|
||||
ctx.fill()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.font = '12px system-ui, -apple-system, sans-serif'
|
||||
ctx.fillText(item.label.toUpperCase(), x + 14, y + 28)
|
||||
|
||||
// Value
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 28px system-ui, -apple-system, sans-serif'
|
||||
ctx.fillText(item.value, x + 14, y + 62)
|
||||
|
||||
// Unit
|
||||
if (item.unit) {
|
||||
const valWidth = ctx.measureText(item.value).width
|
||||
ctx.fillStyle = '#64748b'
|
||||
ctx.font = '14px system-ui, -apple-system, sans-serif'
|
||||
ctx.fillText(item.unit, x + 14 + valWidth + 4, y + 62)
|
||||
}
|
||||
})
|
||||
|
||||
// Branding
|
||||
ctx.fillStyle = '#475569'
|
||||
ctx.font = '14px system-ui, -apple-system, sans-serif'
|
||||
ctx.fillText('VeloBrain', 40, H - 30)
|
||||
ctx.fillStyle = '#334155'
|
||||
ctx.fillText('velobrain.app', W - 140, H - 30)
|
||||
|
||||
// Export
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) return
|
||||
if (navigator.share && navigator.canShare?.({ files: [new File([blob], 'activity.png', { type: 'image/png' })] })) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: a.name || 'My Ride',
|
||||
files: [new File([blob], 'activity.png', { type: 'image/png' })],
|
||||
})
|
||||
return
|
||||
} catch { /* fallback to download */ }
|
||||
}
|
||||
// Fallback: download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${(a.name || 'activity').replace(/\s+/g, '_')}.png`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
try {
|
||||
@@ -320,6 +499,11 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Load active plan for linking
|
||||
try {
|
||||
activePlan.value = await coaching.fetchActivePlan()
|
||||
} catch { /* no plan */ }
|
||||
|
||||
// Init map after loading=false triggers DOM render of #route-map
|
||||
if (stream.value.some(dp => dp.latitude && dp.longitude)) {
|
||||
await nextTick()
|
||||
@@ -341,7 +525,10 @@ onMounted(async () => {
|
||||
<h1 class="text-2xl font-semibold">{{ activity.name || 'Ride' }}</h1>
|
||||
<Tag :value="activity.activity_type" severity="info" />
|
||||
</div>
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-share-alt" severity="secondary" text rounded size="small" title="Share activity card" @click="shareActivity" />
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key metrics -->
|
||||
@@ -431,22 +618,78 @@ onMounted(async () => {
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Plan link -->
|
||||
<Card v-if="activity.training_plan_id" class="mb-4 bg-blue-50 border-blue-200">
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="pi pi-calendar-check text-blue-500 text-lg"></i>
|
||||
<div>
|
||||
<p class="font-semibold text-blue-800 text-sm">Тренировка по плану</p>
|
||||
<p class="text-blue-600 text-xs">Неделя {{ activity.plan_week }}, {{ dayLabelMap[activity.plan_day || ''] || activity.plan_day }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-times" text rounded severity="secondary" size="small" :loading="linking" @click="unlinkFromPlan" title="Отвязать от плана" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="activePlan" class="mb-4">
|
||||
<template #content>
|
||||
<div v-if="!linkMenuOpen" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="pi pi-link text-surface-400 text-lg"></i>
|
||||
<p class="text-surface-500 text-sm">Активность не привязана к тренировочному плану</p>
|
||||
</div>
|
||||
<Button label="Привязать к плану" icon="pi pi-calendar-plus" size="small" severity="secondary" outlined @click="linkMenuOpen = true" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="font-semibold text-sm">Выберите тренировку из плана</p>
|
||||
<Button icon="pi pi-times" text rounded severity="secondary" size="small" @click="linkMenuOpen = false" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
v-for="opt in planDayOptions"
|
||||
:key="`${opt.week}-${opt.day}`"
|
||||
class="text-left px-3 py-2 rounded-lg hover:bg-primary/10 text-sm transition-colors border border-transparent hover:border-primary/20"
|
||||
:disabled="linking"
|
||||
@click="linkToPlan(opt)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<p v-if="planDayOptions.length === 0" class="text-surface-400 text-sm px-3 py-2">Нет доступных тренировок в плане</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- AI Summary -->
|
||||
<Card class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles text-primary"></i>
|
||||
AI Coach Summary
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles text-primary"></i>
|
||||
{{ activity.training_plan_id ? 'AI Анализ и соответствие плану' : 'AI Coach Summary' }}
|
||||
</div>
|
||||
<Button v-if="aiSummary" icon="pi pi-refresh" text rounded severity="secondary" size="small" @click="regenerateSummary" :loading="aiLoading" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="aiSummary" v-html="aiSummary" class="text-surface-700 leading-relaxed whitespace-pre-line"></div>
|
||||
<div v-else-if="aiLoading" class="flex items-center gap-3">
|
||||
<ProgressSpinner style="width: 20px; height: 20px" />
|
||||
<span class="text-surface-500 text-sm">Analyzing your ride...</span>
|
||||
<span class="text-surface-500 text-sm">{{ activity.training_plan_id ? 'Анализирую соответствие плану...' : 'Analyzing your ride...' }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button label="Generate AI Analysis" icon="pi pi-sparkles" severity="secondary" outlined size="small" @click="loadAiSummary" />
|
||||
<Button
|
||||
:label="activity.training_plan_id ? 'Анализировать выполнение плана' : 'Generate AI Analysis'"
|
||||
icon="pi pi-sparkles"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
@click="loadAiSummary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
@@ -141,6 +141,7 @@ onMounted(async () => {
|
||||
await coaching.fetchTodayWorkout()
|
||||
|
||||
if (plan.value) {
|
||||
activeTab.value = 'plan'
|
||||
await loadCompliance()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,20 @@ import { onMounted, computed, ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import { useCoachingStore } from '../stores/coaching'
|
||||
import type { FitnessEntry, WeeklyStats, PersonalRecord, TodayWorkout } from '../types/models'
|
||||
import type { FitnessEntry, WeeklyStats, PersonalRecord, TodayWorkout, CalendarDay, MonthlyTrend, WeeklyDigest, ProgressSummary } from '../types/models'
|
||||
import Card from 'primevue/card'
|
||||
import Tag from 'primevue/tag'
|
||||
import Button from 'primevue/button'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, BarChart } from 'echarts/charts'
|
||||
import { LineChart, BarChart, HeatmapChart, GaugeChart } from 'echarts/charts'
|
||||
import {
|
||||
TooltipComponent, LegendComponent, GridComponent,
|
||||
TooltipComponent, LegendComponent, GridComponent, VisualMapComponent, CalendarComponent,
|
||||
} from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
|
||||
use([CanvasRenderer, LineChart, BarChart, HeatmapChart, GaugeChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent, CalendarComponent])
|
||||
|
||||
const auth = useAuthStore()
|
||||
const store = useActivitiesStore()
|
||||
@@ -27,6 +28,44 @@ const fitness = ref<FitnessEntry[]>([])
|
||||
const weeklyStats = ref<WeeklyStats[]>([])
|
||||
const records = ref<PersonalRecord[]>([])
|
||||
const todayWorkout = ref<TodayWorkout | null>(null)
|
||||
const calendarDays = ref<CalendarDay[]>([])
|
||||
const monthlyTrends = ref<MonthlyTrend[]>([])
|
||||
const weeklyDigest = ref<WeeklyDigest | null>(null)
|
||||
const digestLoading = ref(false)
|
||||
const progress = ref<ProgressSummary | null>(null)
|
||||
const aiProgress = ref('')
|
||||
const aiProgressLoading = ref(false)
|
||||
|
||||
// Delta helpers
|
||||
function delta(current: number | null | undefined, previous: number | null | undefined): number | null {
|
||||
if (current == null || previous == null) return null
|
||||
return current - previous
|
||||
}
|
||||
|
||||
function deltaPercent(current: number | null | undefined, previous: number | null | undefined): number | null {
|
||||
if (current == null || previous == null || previous === 0) return null
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
function trendClass(d: number | null, inverted = false): string {
|
||||
if (d == null || d === 0) return 'text-surface-400'
|
||||
const positive = inverted ? d < 0 : d > 0
|
||||
return positive ? 'text-green-600' : 'text-red-500'
|
||||
}
|
||||
|
||||
function trendIcon(d: number | null): string {
|
||||
if (d == null || d === 0) return ''
|
||||
return d > 0 ? 'pi pi-arrow-up' : 'pi pi-arrow-down'
|
||||
}
|
||||
|
||||
function fitnessZoneLabel(tsb: number | null): { label: string; color: string; description: string } {
|
||||
if (tsb == null) return { label: '—', color: 'text-surface-400', description: '' }
|
||||
if (tsb > 25) return { label: 'Восстановлен', color: 'text-emerald-600', description: 'Высокая свежесть, готовность к интенсивной работе' }
|
||||
if (tsb > 5) return { label: 'Свежий', color: 'text-green-600', description: 'Хорошая форма для качественных тренировок' }
|
||||
if (tsb > -10) return { label: 'Оптимально', color: 'text-blue-600', description: 'Баланс нагрузки и восстановления' }
|
||||
if (tsb > -25) return { label: 'Устал', color: 'text-amber-600', description: 'Накопленная усталость, следите за восстановлением' }
|
||||
return { label: 'Перегрузка', color: 'text-red-600', description: 'Высокий риск перетренированности' }
|
||||
}
|
||||
|
||||
function formatDurationLabel(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
@@ -46,9 +85,11 @@ function formatDistance(meters: number | null): string {
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Charts
|
||||
|
||||
function buildFitnessChart() {
|
||||
if (fitness.value.length === 0) return null
|
||||
|
||||
@@ -67,7 +108,7 @@ function buildFitnessChart() {
|
||||
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value.toFixed(1)}</b>`).join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { data: ['Fitness (CTL)', 'Fatigue (ATL)', 'Form (TSB)'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||
legend: { data: ['Фитнес (CTL)', 'Усталость (ATL)', 'Форма (TSB)'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -77,10 +118,10 @@ function buildFitnessChart() {
|
||||
},
|
||||
yAxis: { type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
||||
series: [
|
||||
{ name: 'Fitness (CTL)', type: 'line', data: ctlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#3b82f6' } },
|
||||
{ name: 'Fatigue (ATL)', type: 'line', data: atlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#ef4444' } },
|
||||
{ name: 'Фитнес (CTL)', type: 'line', data: ctlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#3b82f6' } },
|
||||
{ name: 'Усталость (ATL)', type: 'line', data: atlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#ef4444' } },
|
||||
{
|
||||
name: 'Form (TSB)', type: 'line', data: tsbData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#10b981' },
|
||||
name: 'Форма (TSB)', type: 'line', data: tsbData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#10b981' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
@@ -111,11 +152,11 @@ function buildWeeklyChart() {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const week = params[0].name
|
||||
return `<b>Week of ${week}</b><br/>` +
|
||||
return `<b>Неделя ${week}</b><br/>` +
|
||||
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value}</b>`).join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { data: ['Distance (km)', 'TSS', 'Hours'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||
legend: { data: ['Дистанция (км)', 'TSS', 'Часы'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||
grid: { left: 40, right: 40, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', data: weeks, axisLabel: { color: '#6b7280', fontSize: 10 } },
|
||||
yAxis: [
|
||||
@@ -124,7 +165,7 @@ function buildWeeklyChart() {
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Distance (km)', type: 'bar', data: weeklyStats.value.map(w => w.distance),
|
||||
name: 'Дистанция (км)', type: 'bar', data: weeklyStats.value.map(w => w.distance),
|
||||
itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
||||
},
|
||||
{
|
||||
@@ -132,7 +173,7 @@ function buildWeeklyChart() {
|
||||
itemStyle: { color: '#a855f7', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
||||
},
|
||||
{
|
||||
name: 'Hours', type: 'line', yAxisIndex: 1,
|
||||
name: 'Часы', type: 'line', yAxisIndex: 1,
|
||||
data: weeklyStats.value.map(w => +(w.duration / 3600).toFixed(1)),
|
||||
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
|
||||
},
|
||||
@@ -140,28 +181,146 @@ function buildWeeklyChart() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCalendarChart() {
|
||||
if (calendarDays.value.length === 0) return null
|
||||
|
||||
const now = new Date()
|
||||
const yearAgo = new Date(now)
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
||||
const rangeStart = yearAgo.toISOString().slice(0, 10)
|
||||
const rangeEnd = now.toISOString().slice(0, 10)
|
||||
|
||||
const data = calendarDays.value.map(d => [d.date, d.tss || d.count])
|
||||
const maxVal = Math.max(...data.map(d => d[1] as number), 1)
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
formatter: (params: any) => {
|
||||
const d = calendarDays.value.find(c => c.date === params.data[0])
|
||||
if (!d) return params.data[0]
|
||||
return `<b>${params.data[0]}</b><br/>Тренировок: ${d.count}<br/>Время: ${Math.round(d.duration / 60)} мин<br/>TSS: ${d.tss}`
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: maxVal,
|
||||
show: false,
|
||||
inRange: { color: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'] },
|
||||
},
|
||||
calendar: {
|
||||
range: [rangeStart, rangeEnd],
|
||||
cellSize: [13, 13],
|
||||
top: 30,
|
||||
left: 40,
|
||||
right: 10,
|
||||
itemStyle: { borderWidth: 2, borderColor: '#fff', borderRadius: 2 },
|
||||
yearLabel: { show: false },
|
||||
monthLabel: { color: '#6b7280', fontSize: 10 },
|
||||
dayLabel: { color: '#6b7280', fontSize: 9, firstDay: 1, nameMap: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'] },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
series: [{
|
||||
type: 'heatmap',
|
||||
coordinateSystem: 'calendar',
|
||||
data,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function buildMonthlyTrendsChart() {
|
||||
if (monthlyTrends.value.length === 0) return null
|
||||
|
||||
const months = monthlyTrends.value.map(m => m.month)
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const month = params[0].name
|
||||
return `<b>${month}</b><br/>` +
|
||||
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value ?? '—'}</b>`).join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { data: ['Средняя мощность (W)', 'Средний пульс', 'W/kg', 'Часы'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', data: months, axisLabel: { color: '#6b7280', fontSize: 10 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'W / bpm', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
||||
{ type: 'value', name: 'W/kg / ч', axisLabel: { color: '#6b7280' }, splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Средняя мощность (W)', type: 'line', data: monthlyTrends.value.map(m => m.avg_power),
|
||||
showSymbol: true, symbolSize: 6, itemStyle: { color: '#3b82f6' }, lineStyle: { width: 2 },
|
||||
connectNulls: true,
|
||||
},
|
||||
{
|
||||
name: 'Средний пульс', type: 'line', data: monthlyTrends.value.map(m => m.avg_hr),
|
||||
showSymbol: true, symbolSize: 6, itemStyle: { color: '#ef4444' }, lineStyle: { width: 2 },
|
||||
connectNulls: true,
|
||||
},
|
||||
{
|
||||
name: 'W/kg', type: 'line', yAxisIndex: 1, data: monthlyTrends.value.map(m => m.w_per_kg),
|
||||
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
|
||||
connectNulls: true,
|
||||
},
|
||||
{
|
||||
name: 'Часы', type: 'bar', yAxisIndex: 1, data: monthlyTrends.value.map(m => m.hours),
|
||||
itemStyle: { color: 'rgba(168,85,247,0.3)', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 25,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDigest() {
|
||||
digestLoading.value = true
|
||||
try {
|
||||
weeklyDigest.value = await store.fetchWeeklyDigest()
|
||||
} catch {
|
||||
// digest not available
|
||||
} finally {
|
||||
digestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAiProgress() {
|
||||
aiProgressLoading.value = true
|
||||
try {
|
||||
aiProgress.value = await store.fetchAiProgress()
|
||||
} catch {
|
||||
aiProgress.value = ''
|
||||
} finally {
|
||||
aiProgressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fitnessZone = computed(() => fitnessZoneLabel(progress.value?.fitness?.tsb ?? null))
|
||||
|
||||
const weekVsWeekTss = computed(() => deltaPercent(progress.value?.this_week?.tss, progress.value?.last_week?.tss))
|
||||
const weekVsWeekHours = computed(() => deltaPercent(progress.value?.this_week?.hours, progress.value?.last_week?.hours))
|
||||
const monthVsMonthTss = computed(() => deltaPercent(progress.value?.this_month?.tss, progress.value?.last_month?.tss))
|
||||
const monthVsMonthPower = computed(() => delta(progress.value?.this_month?.avg_power, progress.value?.last_month?.avg_power))
|
||||
const ctlDelta = computed(() => delta(progress.value?.fitness?.ctl, progress.value?.fitness_7d_ago?.ctl))
|
||||
|
||||
onMounted(async () => {
|
||||
store.fetchActivities(5)
|
||||
try {
|
||||
fitness.value = await store.fetchFitness(90)
|
||||
} catch {
|
||||
// Fitness data may not be available yet
|
||||
}
|
||||
try {
|
||||
weeklyStats.value = await store.fetchWeeklyStats(8)
|
||||
} catch {
|
||||
// Weekly stats may not be available yet
|
||||
}
|
||||
try {
|
||||
records.value = await store.fetchPersonalRecords()
|
||||
} catch {
|
||||
// PR data may not be available yet
|
||||
}
|
||||
try {
|
||||
todayWorkout.value = await coaching.fetchTodayWorkout()
|
||||
} catch {
|
||||
// No active plan
|
||||
}
|
||||
// Load everything in parallel
|
||||
const promises: Promise<any>[] = [
|
||||
store.fetchActivities(5),
|
||||
]
|
||||
|
||||
promises.push(
|
||||
store.fetchProgressSummary().then(p => { progress.value = p }).catch(() => {}),
|
||||
store.fetchFitness(90).then(f => { fitness.value = f }).catch(() => {}),
|
||||
store.fetchWeeklyStats(8).then(w => { weeklyStats.value = w }).catch(() => {}),
|
||||
store.fetchPersonalRecords().then(r => { records.value = r }).catch(() => {}),
|
||||
coaching.fetchTodayWorkout().then(t => { todayWorkout.value = t }).catch(() => {}),
|
||||
store.fetchActivityCalendar(12).then(c => { calendarDays.value = c }).catch(() => {}),
|
||||
store.fetchMonthlyTrends(12).then(m => { monthlyTrends.value = m }).catch(() => {}),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -169,8 +328,80 @@ onMounted(async () => {
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
|
||||
|
||||
<!-- Rider stats cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-8">
|
||||
<!-- ===== FITNESS STATUS BAR ===== -->
|
||||
<div v-if="progress" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
<!-- Current Form -->
|
||||
<Card class="col-span-2 md:col-span-1">
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Состояние</p>
|
||||
<p class="text-lg font-bold" :class="fitnessZone.color">{{ fitnessZone.label }}</p>
|
||||
<p v-if="progress.fitness?.tsb != null" class="text-xs text-surface-400 mt-1">TSB {{ progress.fitness.tsb }}</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- CTL -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Фитнес (CTL)</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-2xl font-bold">{{ progress.fitness?.ctl ?? '—' }}</p>
|
||||
<span v-if="ctlDelta != null" class="text-xs font-medium" :class="trendClass(ctlDelta)">
|
||||
<i :class="trendIcon(ctlDelta)" class="text-[10px]"></i>
|
||||
{{ ctlDelta > 0 ? '+' : '' }}{{ ctlDelta.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-surface-400">за 7 дней</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- This week volume -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Эта неделя</p>
|
||||
<p class="text-2xl font-bold">{{ progress.this_week.hours }}<span class="text-sm font-normal text-surface-500">ч</span></p>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span v-if="weekVsWeekHours != null" class="text-xs font-medium" :class="trendClass(weekVsWeekHours)">
|
||||
<i :class="trendIcon(weekVsWeekHours)" class="text-[10px]"></i>
|
||||
{{ weekVsWeekHours > 0 ? '+' : '' }}{{ weekVsWeekHours }}%
|
||||
</span>
|
||||
<span class="text-xs text-surface-400">vs прошлая</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- This week TSS -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">TSS неделя</p>
|
||||
<p class="text-2xl font-bold">{{ progress.this_week.tss }}</p>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span v-if="weekVsWeekTss != null" class="text-xs font-medium" :class="trendClass(weekVsWeekTss)">
|
||||
<i :class="trendIcon(weekVsWeekTss)" class="text-[10px]"></i>
|
||||
{{ weekVsWeekTss > 0 ? '+' : '' }}{{ weekVsWeekTss }}%
|
||||
</span>
|
||||
<span class="text-xs text-surface-400">vs прошлая</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Monthly power change -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Мощность (месяц)</p>
|
||||
<p class="text-2xl font-bold">{{ progress.this_month.avg_power ?? '—' }}<span class="text-sm font-normal text-surface-500">W</span></p>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<span v-if="monthVsMonthPower != null" class="text-xs font-medium" :class="trendClass(monthVsMonthPower)">
|
||||
<i :class="trendIcon(monthVsMonthPower)" class="text-[10px]"></i>
|
||||
{{ monthVsMonthPower > 0 ? '+' : '' }}{{ monthVsMonthPower }}W
|
||||
</span>
|
||||
<span class="text-xs text-surface-400">vs прошлый</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Static fallback cards when no progress data -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">FTP</p>
|
||||
@@ -179,13 +410,13 @@ onMounted(async () => {
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Weight</p>
|
||||
<p class="text-2xl font-bold">{{ auth.rider?.weight ?? '—' }} <span class="text-sm text-surface-500 font-normal">kg</span></p>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Вес</p>
|
||||
<p class="text-2xl font-bold">{{ auth.rider?.weight ?? '—' }} <span class="text-sm text-surface-500 font-normal">кг</span></p>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">W/kg</p>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">W/кг</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ auth.rider?.ftp && auth.rider?.weight ? (auth.rider.ftp / auth.rider.weight).toFixed(2) : '—' }}
|
||||
</p>
|
||||
@@ -193,14 +424,14 @@ onMounted(async () => {
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Total Rides</p>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Тренировок</p>
|
||||
<p class="text-2xl font-bold">{{ store.total }}</p>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Today's Workout -->
|
||||
<Card v-if="todayWorkout" class="mb-8 border-l-4 border-l-primary cursor-pointer hover:shadow-md transition-shadow" @click="router.push({ name: 'coach' })">
|
||||
<!-- ===== TODAY'S WORKOUT ===== -->
|
||||
<Card v-if="todayWorkout" class="mb-6 border-l-4 border-l-primary cursor-pointer hover:shadow-md transition-shadow" @click="router.push({ name: 'coach' })">
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -223,12 +454,142 @@ onMounted(async () => {
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Personal Records -->
|
||||
<Card v-if="records.length > 0" class="mb-8">
|
||||
<!-- ===== WEEK VS WEEK COMPARISON (detailed) ===== -->
|
||||
<div v-if="progress && (progress.this_week.rides > 0 || progress.last_week.rides > 0)" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<Card>
|
||||
<template #title>
|
||||
<span class="text-sm">Эта неделя</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Тренировок</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.rides }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Часы</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.hours }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">TSS</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.tss }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.distance_km }} <span class="text-xs font-normal">км</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Ср. мощность</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.avg_power ?? '—' }} <span class="text-xs font-normal">W</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Ср. пульс</p>
|
||||
<p class="text-lg font-bold">{{ progress.this_week.avg_hr ?? '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #title>
|
||||
<span class="text-sm">Прошлая неделя</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Тренировок</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.rides }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Часы</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.hours }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">TSS</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.tss }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.distance_km }} <span class="text-xs font-normal">км</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Ср. мощность</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.avg_power ?? '—' }} <span class="text-xs font-normal">W</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-surface-500 text-xs">Ср. пульс</p>
|
||||
<p class="text-lg font-bold">{{ progress.last_week.avg_hr ?? '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- ===== FITNESS TREND ===== -->
|
||||
<Card v-if="fitness.length > 0" class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-blue-500"></i>
|
||||
Тренд фитнеса
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<VChart :option="buildFitnessChart() ?? undefined" style="height: 280px" autoresize />
|
||||
<div class="flex items-center justify-center gap-6 mt-2 text-xs text-surface-500">
|
||||
<span>CTL — хронич. нагрузка (фитнес)</span>
|
||||
<span>ATL — острая нагрузка (усталость)</span>
|
||||
<span>TSB — баланс формы</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ===== AI PROGRESS ANALYSIS ===== -->
|
||||
<Card class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles text-violet-500"></i>
|
||||
AI Анализ прогресса
|
||||
</div>
|
||||
<Button
|
||||
v-if="!aiProgress"
|
||||
label="Анализировать"
|
||||
icon="pi pi-sparkles"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
:loading="aiProgressLoading"
|
||||
@click="loadAiProgress"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:loading="aiProgressLoading"
|
||||
@click="loadAiProgress"
|
||||
title="Обновить анализ"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="aiProgressLoading" class="flex items-center justify-center py-8 text-surface-400">
|
||||
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
|
||||
Анализирую данные...
|
||||
</div>
|
||||
<div v-else-if="aiProgress" class="prose prose-sm max-w-none" v-html="aiProgress"></div>
|
||||
<p v-else class="text-surface-400 text-sm">
|
||||
AI проанализирует ваши тренировки за последние месяцы, оценит динамику прогресса и даст рекомендации по развитию.
|
||||
</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ===== PERSONAL RECORDS ===== -->
|
||||
<Card v-if="records.length > 0" class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-trophy text-amber-500"></i>
|
||||
Personal Records
|
||||
Личные рекорды
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -241,37 +602,105 @@ onMounted(async () => {
|
||||
>
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">{{ formatDurationLabel(pr.duration) }}</p>
|
||||
<p class="text-xl font-bold">{{ pr.power }}<span class="text-sm font-normal text-surface-500">W</span></p>
|
||||
<p v-if="auth.rider?.weight" class="text-xs text-surface-400">{{ (pr.power / auth.rider.weight).toFixed(1) }} W/kg</p>
|
||||
<p v-if="auth.rider?.weight" class="text-xs text-surface-400">{{ (pr.power / auth.rider.weight).toFixed(1) }} W/кг</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Fitness trend chart -->
|
||||
<Card v-if="fitness.length > 0" class="mb-8">
|
||||
<template #title>Fitness Trend</template>
|
||||
<template #content>
|
||||
<VChart :option="buildFitnessChart() ?? undefined" style="height: 280px" autoresize />
|
||||
<div class="flex items-center justify-center gap-6 mt-2 text-xs text-surface-500">
|
||||
<span>CTL: chronic training load (fitness)</span>
|
||||
<span>ATL: acute training load (fatigue)</span>
|
||||
<span>TSB: training stress balance (form)</span>
|
||||
<!-- ===== WEEKLY VOLUME ===== -->
|
||||
<Card v-if="weeklyStats.length > 0" class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-chart-bar text-indigo-500"></i>
|
||||
Нагрузка по неделям
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Weekly summary -->
|
||||
<Card v-if="weeklyStats.length > 0" class="mb-8">
|
||||
<template #title>Weekly Summary</template>
|
||||
<template #content>
|
||||
<VChart :option="buildWeeklyChart() ?? undefined" style="height: 280px" autoresize />
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Recent rides -->
|
||||
<h2 class="text-lg font-semibold mb-4">Recent Rides</h2>
|
||||
<!-- ===== MONTHLY TRENDS ===== -->
|
||||
<Card v-if="monthlyTrends.length > 0" class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-blue-500"></i>
|
||||
Тренды по месяцам
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<VChart :option="buildMonthlyTrendsChart() ?? undefined" style="height: 300px" autoresize />
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ===== ACTIVITY HEATMAP ===== -->
|
||||
<Card v-if="calendarDays.length > 0" class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar text-green-600"></i>
|
||||
Календарь активности
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<VChart :option="buildCalendarChart() ?? undefined" style="height: 180px" autoresize />
|
||||
<div class="flex items-center justify-end gap-1 mt-2 text-xs text-surface-400">
|
||||
<span>Меньше</span>
|
||||
<span class="inline-block w-3 h-3 rounded-sm" style="background: #ebedf0"></span>
|
||||
<span class="inline-block w-3 h-3 rounded-sm" style="background: #9be9a8"></span>
|
||||
<span class="inline-block w-3 h-3 rounded-sm" style="background: #40c463"></span>
|
||||
<span class="inline-block w-3 h-3 rounded-sm" style="background: #30a14e"></span>
|
||||
<span class="inline-block w-3 h-3 rounded-sm" style="background: #216e39"></span>
|
||||
<span>Больше</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ===== WEEKLY AI DIGEST ===== -->
|
||||
<Card class="mb-6">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles text-purple-500"></i>
|
||||
Еженедельный дайджест
|
||||
</div>
|
||||
<Button
|
||||
v-if="!weeklyDigest"
|
||||
label="Сгенерировать"
|
||||
icon="pi pi-sparkles"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
:loading="digestLoading"
|
||||
@click="loadDigest"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="digestLoading" class="flex items-center justify-center py-8 text-surface-400">
|
||||
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
|
||||
Генерирую дайджест...
|
||||
</div>
|
||||
<div v-else-if="weeklyDigest?.digest">
|
||||
<div class="flex items-center gap-4 text-sm text-surface-500 mb-4 flex-wrap">
|
||||
<span>{{ weeklyDigest.week_start }} — {{ weeklyDigest.week_end }}</span>
|
||||
<span>{{ weeklyDigest.total_activities }} трен.</span>
|
||||
<span>{{ weeklyDigest.total_hours }}ч</span>
|
||||
<span>TSS {{ weeklyDigest.total_tss }}</span>
|
||||
<span>{{ weeklyDigest.total_distance_km }} км</span>
|
||||
</div>
|
||||
<div class="prose prose-sm max-w-none" v-html="weeklyDigest.digest"></div>
|
||||
</div>
|
||||
<div v-else-if="weeklyDigest && !weeklyDigest.digest" class="text-surface-400 text-center py-4">
|
||||
{{ weeklyDigest.message || 'Нет тренировок за прошлую неделю' }}
|
||||
</div>
|
||||
<p v-else class="text-surface-400 text-sm">Итоги прошлой недели: объём, ключевые метрики, рекомендации AI-тренера.</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ===== RECENT ACTIVITIES ===== -->
|
||||
<h2 class="text-lg font-semibold mb-4">Последние тренировки</h2>
|
||||
<div v-if="recentActivities.length === 0" class="text-surface-500">
|
||||
No activities yet. Upload your first .FIT file!
|
||||
Нет тренировок. Загрузите первый .FIT файл!
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<Card
|
||||
@@ -284,22 +713,22 @@ onMounted(async () => {
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<p class="font-semibold">{{ a.name || 'Ride' }}</p>
|
||||
<p class="font-semibold">{{ a.name || 'Тренировка' }}</p>
|
||||
<p class="text-surface-500 text-sm">{{ formatDate(a.date) }}</p>
|
||||
</div>
|
||||
<Tag :value="a.activity_type" severity="info" class="text-xs" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4 md:gap-6 text-sm flex-wrap">
|
||||
<div class="text-center">
|
||||
<p class="text-surface-500 text-xs">Duration</p>
|
||||
<p class="text-surface-500 text-xs">Время</p>
|
||||
<p class="font-semibold">{{ formatDuration(a.duration) }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-surface-500 text-xs">Distance</p>
|
||||
<div v-if="a.distance" class="text-center">
|
||||
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||
<p class="font-semibold">{{ formatDistance(a.distance) }}</p>
|
||||
</div>
|
||||
<div v-if="a.metrics?.avg_power" class="text-center">
|
||||
<p class="text-surface-500 text-xs">Power</p>
|
||||
<p class="text-surface-500 text-xs">Мощность</p>
|
||||
<p class="font-semibold">{{ a.metrics.avg_power }}W</p>
|
||||
</div>
|
||||
<div v-if="a.metrics?.tss" class="text-center">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useActivitiesStore } from '../stores/activities'
|
||||
import type { FtpDetection } from '../types/models'
|
||||
import Card from 'primevue/card'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -8,8 +10,10 @@ import Select from 'primevue/select'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const store = useActivitiesStore()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
@@ -22,6 +26,27 @@ const form = ref({
|
||||
|
||||
const saving = ref(false)
|
||||
const success = ref(false)
|
||||
const detectingFtp = ref(false)
|
||||
const ftpDetection = ref<FtpDetection | null>(null)
|
||||
const showFtpDialog = ref(false)
|
||||
|
||||
async function detectFtp() {
|
||||
detectingFtp.value = true
|
||||
try {
|
||||
ftpDetection.value = await store.detectFtp()
|
||||
showFtpDialog.value = true
|
||||
} finally {
|
||||
detectingFtp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDetectedFtp() {
|
||||
if (ftpDetection.value?.detected_ftp) {
|
||||
form.value.ftp = ftpDetection.value.detected_ftp
|
||||
showFtpDialog.value = false
|
||||
await save()
|
||||
}
|
||||
}
|
||||
|
||||
const experienceLevels = [
|
||||
{ label: 'Beginner', value: 'beginner' },
|
||||
@@ -105,6 +130,15 @@ async function save() {
|
||||
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="e.g. 250" />
|
||||
<span class="text-surface-500 text-sm font-medium">W</span>
|
||||
</div>
|
||||
<Button
|
||||
label="Auto-detect FTP"
|
||||
icon="pi pi-sparkles"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
:loading="detectingFtp"
|
||||
@click="detectFtp"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (Lactate Threshold Heart Rate)</label>
|
||||
@@ -135,5 +169,45 @@ async function save() {
|
||||
<Message v-if="success" severity="success" :closable="false" class="m-0">Settings saved successfully</Message>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- FTP Detection Dialog -->
|
||||
<Dialog v-model:visible="showFtpDialog" header="FTP Auto-Detection" :modal="true" :style="{ width: '420px' }">
|
||||
<div v-if="ftpDetection">
|
||||
<div v-if="ftpDetection.detected_ftp" class="flex flex-col gap-4">
|
||||
<div class="bg-primary/10 rounded-lg p-4 text-center">
|
||||
<p class="text-surface-500 text-xs uppercase mb-1">Detected FTP</p>
|
||||
<p class="text-3xl font-bold text-primary">{{ ftpDetection.detected_ftp }} <span class="text-lg font-normal">W</span></p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-surface-50 rounded-lg p-3">
|
||||
<p class="text-surface-500 text-xs">Best 20min Power</p>
|
||||
<p class="font-semibold">{{ ftpDetection.best_20min_power }} W</p>
|
||||
</div>
|
||||
<div class="bg-surface-50 rounded-lg p-3">
|
||||
<p class="text-surface-500 text-xs">Current FTP</p>
|
||||
<p class="font-semibold">{{ ftpDetection.current_ftp ?? '—' }} W</p>
|
||||
</div>
|
||||
<div class="bg-surface-50 rounded-lg p-3">
|
||||
<p class="text-surface-500 text-xs">Activity</p>
|
||||
<p class="font-semibold text-xs">{{ ftpDetection.activity_name }}</p>
|
||||
</div>
|
||||
<div class="bg-surface-50 rounded-lg p-3">
|
||||
<p class="text-surface-500 text-xs">Date</p>
|
||||
<p class="font-semibold text-xs">{{ ftpDetection.date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-surface-500 text-xs">FTP = Best 20min Power x 0.95. This will recalculate TSS/IF for all your activities.</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Cancel" severity="secondary" @click="showFtpDialog = false" />
|
||||
<Button label="Apply FTP" icon="pi pi-check" @click="applyDetectedFtp" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<i class="pi pi-info-circle text-2xl text-surface-400 mb-2"></i>
|
||||
<p class="text-surface-500">{{ ftpDetection.message || 'No 20-minute power data found. Upload more rides!' }}</p>
|
||||
<Button label="Close" severity="secondary" class="mt-4" @click="showFtpDialog = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user