This commit is contained in:
xds
2026-03-17 10:00:55 +03:00
parent 1d76f29244
commit 2f4a30ccaf
9 changed files with 1404 additions and 87 deletions

View File

@@ -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 }
})

View File

@@ -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

View File

@@ -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>

View File

@@ -141,6 +141,7 @@ onMounted(async () => {
await coaching.fetchTodayWorkout()
if (plan.value) {
activeTab.value = 'plan'
await loadCompliance()
}

View File

@@ -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">

View File

@@ -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>