This commit is contained in:
xds
2026-03-17 10:51:01 +03:00
parent e98ef3eae3
commit 195f96bb20
7 changed files with 370 additions and 51 deletions

View File

@@ -0,0 +1,30 @@
"""add max_hr to riders
Revision ID: 61f691fee44a
Revises: ab0f6e2939d3
Create Date: 2026-03-17 10:40:39.156732
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '61f691fee44a'
down_revision: Union[str, None] = 'ab0f6e2939d3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('riders', sa.Column('max_hr', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('riders', 'max_hr')
# ### end Alembic commands ###

View File

@@ -91,6 +91,9 @@ async def get_weekly_stats(
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.sum(ActivityMetrics.tss).label("tss"),
func.avg(ActivityMetrics.avg_power).label("avg_power"),
func.avg(ActivityMetrics.normalized_power).label("avg_np"),
func.avg(ActivityMetrics.avg_hr).label("avg_hr"),
)
.select_from(Activity)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
@@ -102,6 +105,7 @@ async def get_weekly_stats(
result = await session.execute(query)
weight = rider.weight
return [
{
"week": row.week.strftime("%Y-%m-%d") if row.week else None,
@@ -109,6 +113,10 @@ async def get_weekly_stats(
"duration": row.duration or 0,
"distance": round(float(row.distance or 0) / 1000, 1),
"tss": round(float(row.tss or 0), 0),
"avg_power": round(float(row.avg_power), 0) if row.avg_power else None,
"avg_np": round(float(row.avg_np), 0) if row.avg_np else None,
"avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None,
"w_per_kg": round(float(row.avg_power) / weight, 2) if row.avg_power and weight else None,
}
for row in result
]

View File

@@ -18,6 +18,7 @@ class Rider(Base):
name: Mapped[str] = mapped_column(String(100))
ftp: Mapped[float | None] = mapped_column(Float, nullable=True)
lthr: Mapped[int | None] = mapped_column(nullable=True)
max_hr: Mapped[int | None] = mapped_column(nullable=True)
weight: Mapped[float | None] = mapped_column(Float, nullable=True)
zones_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
goals: Mapped[str | None] = mapped_column(String(500), nullable=True)

View File

@@ -8,6 +8,7 @@ class RiderCreate(BaseModel):
name: str
ftp: float | None = None
lthr: int | None = None
max_hr: int | None = None
weight: float | None = None
goals: str | None = None
experience_level: str | None = None
@@ -17,6 +18,7 @@ class RiderUpdate(BaseModel):
name: str | None = None
ftp: float | None = None
lthr: int | None = None
max_hr: int | None = None
weight: float | None = None
zones_config: dict | None = None
goals: str | None = None
@@ -43,6 +45,7 @@ class RiderResponse(BaseModel):
name: str
ftp: float | None = None
lthr: int | None = None
max_hr: int | None = None
weight: float | None = None
zones_config: dict | None = None
goals: str | None = None

View File

@@ -6,6 +6,7 @@ export interface Rider {
name: string
ftp: number | null
lthr: number | null
max_hr: number | null
weight: number | null
zones_config: Record<string, unknown> | null
goals: string | null
@@ -115,6 +116,10 @@ export interface WeeklyStats {
duration: number
distance: number
tss: number
avg_power: number | null
avg_np: number | null
avg_hr: number | null
w_per_kg: number | null
}
export interface PersonalRecord {

View File

@@ -35,6 +35,7 @@ const digestLoading = ref(false)
const progress = ref<ProgressSummary | null>(null)
const aiProgress = ref('')
const aiProgressLoading = ref(false)
const trendsMode = ref<'weekly' | 'monthly'>('weekly')
// Delta helpers
function delta(current: number | null | undefined, previous: number | null | undefined): number | null {
@@ -181,6 +182,56 @@ function buildWeeklyChart() {
}
}
function buildWeeklyTrendsChart() {
if (weeklyStats.value.length === 0) return null
const weeks = weeklyStats.value.map(w => {
const d = new Date(w.week)
return `${d.getDate()}/${d.getMonth() + 1}`
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const week = params[0].name
return `<b>Неделя ${week}</b><br/>` +
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value ?? '—'}</b>`).join('<br/>')
},
},
legend: { data: ['Ср. мощность (W)', 'NP (W)', 'Ср. пульс', 'W/кг'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
grid: { left: 45, right: 45, top: 40, bottom: 30 },
xAxis: { type: 'category', data: weeks, axisLabel: { color: '#6b7280', fontSize: 10 } },
yAxis: [
{ type: 'value', name: 'W / bpm', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
{ type: 'value', name: 'W/кг', axisLabel: { color: '#6b7280' }, splitLine: { show: false } },
],
series: [
{
name: 'Ср. мощность (W)', type: 'line', data: weeklyStats.value.map(w => w.avg_power),
showSymbol: true, symbolSize: 5, itemStyle: { color: '#3b82f6' }, lineStyle: { width: 2 },
connectNulls: true,
},
{
name: 'NP (W)', type: 'line', data: weeklyStats.value.map(w => w.avg_np),
showSymbol: true, symbolSize: 5, itemStyle: { color: '#8b5cf6' }, lineStyle: { width: 2, type: 'dashed' },
connectNulls: true,
},
{
name: 'Ср. пульс', type: 'line', data: weeklyStats.value.map(w => w.avg_hr),
showSymbol: true, symbolSize: 5, itemStyle: { color: '#ef4444' }, lineStyle: { width: 2 },
connectNulls: true,
},
{
name: 'W/кг', type: 'line', yAxisIndex: 1, data: weeklyStats.value.map(w => w.w_per_kg),
showSymbol: true, symbolSize: 5, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
connectNulls: true,
},
],
}
}
function buildCalendarChart() {
if (calendarDays.value.length === 0) return null
@@ -608,29 +659,47 @@ onMounted(async () => {
</template>
</Card>
<!-- ===== WEEKLY VOLUME ===== -->
<Card v-if="weeklyStats.length > 0" class="mb-6">
<!-- ===== TRENDS (weekly / monthly toggle) ===== -->
<Card v-if="weeklyStats.length > 0 || monthlyTrends.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 class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-chart-bar text-indigo-500"></i>
Тренды
</div>
<div class="flex bg-surface-100 rounded-lg p-0.5 gap-0.5">
<button
class="px-3 py-1 rounded-md text-xs font-medium transition-colors"
:class="trendsMode === 'weekly' ? 'bg-white shadow text-primary' : 'text-surface-500 hover:text-surface-700'"
@click="trendsMode = 'weekly'"
>
По неделям
</button>
<button
class="px-3 py-1 rounded-md text-xs font-medium transition-colors"
:class="trendsMode === 'monthly' ? 'bg-white shadow text-primary' : 'text-surface-500 hover:text-surface-700'"
@click="trendsMode = 'monthly'"
>
По месяцам
</button>
</div>
</div>
</template>
<template #content>
<VChart :option="buildWeeklyChart() ?? undefined" style="height: 280px" autoresize />
</template>
</Card>
<!-- ===== 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>
Тренды по месяцам
<!-- Weekly: volume -->
<div v-if="trendsMode === 'weekly' && weeklyStats.length > 0">
<VChart :option="buildWeeklyChart() ?? undefined" style="height: 280px" autoresize />
<!-- Weekly: power/HR trends -->
<div v-if="weeklyStats.some(w => w.avg_power)" class="mt-4 pt-4 border-t border-surface-200">
<p class="text-xs text-surface-500 uppercase font-medium mb-2">Динамика мощности и пульса</p>
<VChart :option="buildWeeklyTrendsChart() ?? undefined" style="height: 240px" autoresize />
</div>
</div>
</template>
<template #content>
<VChart :option="buildMonthlyTrendsChart() ?? undefined" style="height: 300px" autoresize />
<!-- Monthly -->
<div v-else-if="trendsMode === 'monthly' && monthlyTrends.length > 0">
<VChart :option="buildMonthlyTrendsChart() ?? undefined" style="height: 300px" autoresize />
</div>
<p v-else class="text-surface-400 text-sm text-center py-4">Нет данных для выбранного периода</p>
</template>
</Card>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useActivitiesStore } from '../stores/activities'
import type { FtpDetection } from '../types/models'
@@ -19,6 +19,7 @@ const form = ref({
name: '',
ftp: null as number | null,
lthr: null as number | null,
max_hr: null as number | null,
weight: null as number | null,
goals: '',
experience_level: '',
@@ -29,6 +30,7 @@ const success = ref(false)
const detectingFtp = ref(false)
const ftpDetection = ref<FtpDetection | null>(null)
const showFtpDialog = ref(false)
const hrZoneMethod = ref<'lthr' | 'max_hr'>('lthr')
async function detectFtp() {
detectingFtp.value = true
@@ -55,14 +57,81 @@ const experienceLevels = [
{ label: 'Pro', value: 'pro' },
]
// Power zones — Coggan 7-zone model (% of FTP)
const powerZonesDef = [
{ zone: 1, name: 'Active Recovery', minPct: 0, maxPct: 55, color: '#94a3b8' },
{ zone: 2, name: 'Endurance', minPct: 55, maxPct: 75, color: '#3b82f6' },
{ zone: 3, name: 'Tempo', minPct: 75, maxPct: 90, color: '#22c55e' },
{ zone: 4, name: 'Threshold', minPct: 90, maxPct: 105, color: '#f59e0b' },
{ zone: 5, name: 'VO2max', minPct: 105, maxPct: 120, color: '#f97316' },
{ zone: 6, name: 'Anaerobic', minPct: 120, maxPct: 150, color: '#ef4444' },
{ zone: 7, name: 'Neuromuscular', minPct: 150, maxPct: null, color: '#991b1b' },
]
// HR zones — LTHR-based (% of LTHR)
const hrZonesLthrDef = [
{ zone: 1, name: 'Восстановление', minPct: 0, maxPct: 81, color: '#94a3b8' },
{ zone: 2, name: 'Аэробная', minPct: 81, maxPct: 90, color: '#3b82f6' },
{ zone: 3, name: 'Темпо', minPct: 90, maxPct: 95, color: '#22c55e' },
{ zone: 4, name: 'Порог', minPct: 95, maxPct: 100, color: '#f59e0b' },
{ zone: 5, name: 'Анаэробная', minPct: 100, maxPct: null, color: '#ef4444' },
]
// HR zones — MaxHR-based (% of MaxHR) — Karvonen-style 5-zone
const hrZonesMaxDef = [
{ zone: 1, name: 'Восстановление', minPct: 50, maxPct: 60, color: '#94a3b8' },
{ zone: 2, name: 'Аэробная', minPct: 60, maxPct: 70, color: '#3b82f6' },
{ zone: 3, name: 'Темпо', minPct: 70, maxPct: 80, color: '#22c55e' },
{ zone: 4, name: 'Порог', minPct: 80, maxPct: 90, color: '#f59e0b' },
{ zone: 5, name: 'Максимум', minPct: 90, maxPct: 100, color: '#ef4444' },
]
const powerZones = computed(() => {
const ftp = form.value.ftp
if (!ftp) return []
return powerZonesDef.map(z => ({
...z,
minW: Math.round(ftp * z.minPct / 100),
maxW: z.maxPct ? Math.round(ftp * z.maxPct / 100) : null,
}))
})
const hrZones = computed(() => {
if (hrZoneMethod.value === 'lthr') {
const lthr = form.value.lthr
if (!lthr) return []
return hrZonesLthrDef.map(z => ({
...z,
minBpm: Math.round(lthr * z.minPct / 100),
maxBpm: z.maxPct ? Math.round(lthr * z.maxPct / 100) : null,
basis: 'LTHR',
}))
} else {
const mhr = form.value.max_hr
if (!mhr) return []
return hrZonesMaxDef.map(z => ({
...z,
minBpm: Math.round(mhr * z.minPct / 100),
maxBpm: z.maxPct ? Math.round(mhr * z.maxPct / 100) : null,
basis: 'MaxHR',
}))
}
})
onMounted(() => {
if (auth.rider) {
form.value.name = auth.rider.name || ''
form.value.ftp = auth.rider.ftp
form.value.lthr = auth.rider.lthr
form.value.max_hr = auth.rider.max_hr
form.value.weight = auth.rider.weight
form.value.goals = auth.rider.goals || ''
form.value.experience_level = auth.rider.experience_level || ''
// Default HR zone method based on what's filled
if (!auth.rider.lthr && auth.rider.max_hr) {
hrZoneMethod.value = 'max_hr'
}
}
})
@@ -74,6 +143,7 @@ async function save() {
name: form.value.name || undefined,
ftp: form.value.ftp,
lthr: form.value.lthr,
max_hr: form.value.max_hr,
weight: form.value.weight,
goals: form.value.goals || null,
experience_level: form.value.experience_level || null,
@@ -88,50 +158,50 @@ async function save() {
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<h1 class="text-2xl font-semibold mb-6">Настройки</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Profile -->
<Card>
<template #title>Profile</template>
<template #title>Профиль</template>
<template #content>
<div class="flex flex-col gap-4">
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Name</label>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Имя</label>
<InputText v-model="form.name" class="w-full" />
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Experience Level</label>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Уровень</label>
<Select
v-model="form.experience_level"
:options="experienceLevels"
option-label="label"
option-value="value"
placeholder="Select level"
placeholder="Выберите уровень"
class="w-full"
/>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Goals</label>
<Textarea v-model="form.goals" rows="3" class="w-full" placeholder="e.g. Complete a century ride, improve FTP to 300W..." />
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Цели</label>
<Textarea v-model="form.goals" rows="3" class="w-full" placeholder="Например: проехать 200 км, FTP 300W..." />
</div>
</div>
</template>
</Card>
<!-- Training Zones -->
<!-- Training Parameters -->
<Card>
<template #title>Training Parameters</template>
<template #title>Параметры</template>
<template #content>
<div class="flex flex-col gap-4">
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">FTP (Functional Threshold Power)</label>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">FTP (функциональная пороговая мощность)</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="e.g. 250" />
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="250" />
<span class="text-surface-500 text-sm font-medium">W</span>
</div>
<Button
label="Auto-detect FTP"
label="Автоопределение FTP"
icon="pi pi-sparkles"
severity="secondary"
size="small"
@@ -141,41 +211,174 @@ async function save() {
/>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (Lactate Threshold Heart Rate)</label>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (порог лактата)</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.lthr" :min="100" :max="220" class="w-full" placeholder="e.g. 170" />
<InputNumber v-model="form.lthr" :min="100" :max="220" class="w-full" placeholder="170" />
<span class="text-surface-500 text-sm font-medium">bpm</span>
</div>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Weight</label>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Макс. пульс</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.weight" :min="30" :max="200" :max-fraction-digits="1" class="w-full" placeholder="e.g. 75" />
<span class="text-surface-500 text-sm font-medium">kg</span>
<InputNumber v-model="form.max_hr" :min="120" :max="240" class="w-full" placeholder="190" />
<span class="text-surface-500 text-sm font-medium">bpm</span>
</div>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Вес</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.weight" :min="30" :max="200" :max-fraction-digits="1" class="w-full" placeholder="75" />
<span class="text-surface-500 text-sm font-medium">кг</span>
</div>
</div>
<div v-if="form.ftp && form.weight" class="bg-surface-50 rounded-lg p-3 border border-surface-200">
<p class="text-surface-500 text-xs uppercase mb-1">W/kg ratio</p>
<p class="text-xl font-bold text-primary">{{ (form.ftp / form.weight).toFixed(2) }} <span class="text-sm font-normal text-surface-500">W/kg</span></p>
<p class="text-surface-500 text-xs uppercase mb-1">W/кг</p>
<p class="text-xl font-bold text-primary">{{ (form.ftp / form.weight).toFixed(2) }} <span class="text-sm font-normal text-surface-500">W/кг</span></p>
</div>
</div>
</template>
</Card>
</div>
<div class="flex items-center gap-4 mt-6">
<Button label="Save Changes" icon="pi pi-check" :loading="saving" @click="save" />
<div class="flex items-center gap-4 mt-6 mb-8">
<Button label="Сохранить" icon="pi pi-check" :loading="saving" @click="save" />
<transition enter-active-class="transition-opacity" leave-active-class="transition-opacity" enter-from-class="opacity-0" leave-to-class="opacity-0">
<Message v-if="success" severity="success" :closable="false" class="m-0">Settings saved successfully</Message>
<Message v-if="success" severity="success" :closable="false" class="m-0">Настройки сохранены</Message>
</transition>
</div>
<!-- ===== ZONES ===== -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Power Zones -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-bolt text-amber-500"></i>
Зоны мощности
</div>
</template>
<template #content>
<div v-if="powerZones.length > 0">
<div class="flex flex-col gap-1.5">
<div
v-for="z in powerZones"
:key="z.zone"
class="flex items-center gap-3 rounded-lg px-3 py-2"
:style="{ backgroundColor: z.color + '12' }"
>
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
:style="{ backgroundColor: z.color }"
>
{{ z.zone }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{{ z.name }}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-bold">
{{ z.minW }}
<span v-if="z.maxW"> {{ z.maxW }}</span>
<span v-else>+</span>
<span class="text-xs font-normal text-surface-500"> W</span>
</p>
<p class="text-xs text-surface-400">
{{ z.minPct }}{{ z.maxPct ?? '∞' }}% FTP
</p>
</div>
</div>
</div>
</div>
<div v-else class="text-surface-400 text-sm text-center py-6">
Укажите FTP для расчёта зон мощности
</div>
</template>
</Card>
<!-- HR Zones -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-heart text-red-500"></i>
Зоны пульса
</div>
<div class="flex bg-surface-100 rounded-lg p-0.5 gap-0.5">
<button
class="px-3 py-1 rounded-md text-xs font-medium transition-colors"
:class="hrZoneMethod === 'lthr' ? 'bg-white shadow text-primary' : 'text-surface-500 hover:text-surface-700'"
@click="hrZoneMethod = 'lthr'"
>
По LTHR
</button>
<button
class="px-3 py-1 rounded-md text-xs font-medium transition-colors"
:class="hrZoneMethod === 'max_hr' ? 'bg-white shadow text-primary' : 'text-surface-500 hover:text-surface-700'"
@click="hrZoneMethod = 'max_hr'"
>
По макс. ЧСС
</button>
</div>
</div>
</template>
<template #content>
<div v-if="hrZones.length > 0">
<p class="text-xs text-surface-400 mb-3">
<template v-if="hrZoneMethod === 'lthr'">
На основе порога лактата (LTHR): <b>{{ form.lthr }} bpm</b>
</template>
<template v-else>
На основе максимального пульса: <b>{{ form.max_hr }} bpm</b>
</template>
</p>
<div class="flex flex-col gap-1.5">
<div
v-for="z in hrZones"
:key="z.zone"
class="flex items-center gap-3 rounded-lg px-3 py-2"
:style="{ backgroundColor: z.color + '12' }"
>
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
:style="{ backgroundColor: z.color }"
>
{{ z.zone }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{{ z.name }}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-bold">
{{ z.minBpm }}
<span v-if="z.maxBpm"> {{ z.maxBpm }}</span>
<span v-else>+</span>
<span class="text-xs font-normal text-surface-500"> bpm</span>
</p>
<p class="text-xs text-surface-400">
{{ z.minPct }}{{ z.maxPct ?? '∞' }}%
</p>
</div>
</div>
</div>
</div>
<div v-else class="text-surface-400 text-sm text-center py-6">
<template v-if="hrZoneMethod === 'lthr'">
Укажите LTHR для расчёта зон пульса
</template>
<template v-else>
Укажите макс. пульс для расчёта зон
</template>
</div>
</template>
</Card>
</div>
<!-- FTP Detection Dialog -->
<Dialog v-model:visible="showFtpDialog" header="FTP Auto-Detection" :modal="true" :style="{ width: '420px' }">
<Dialog v-model:visible="showFtpDialog" header="Автоопределение FTP" :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-surface-500 text-xs uppercase mb-1">Определённый 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">
@@ -184,28 +387,28 @@ async function save() {
<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="text-surface-500 text-xs">Текущий 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="text-surface-500 text-xs">Тренировка</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="text-surface-500 text-xs">Дата</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>
<p class="text-surface-500 text-xs">FTP = Best 20min Power x 0.95. TSS/IF всех тренировок будут пересчитаны.</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" />
<Button label="Отмена" severity="secondary" @click="showFtpDialog = false" />
<Button label="Применить" 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" />
<p class="text-surface-500">{{ ftpDetection.message || 'Нет данных 20-минутной мощности. Загрузите больше тренировок!' }}</p>
<Button label="Закрыть" severity="secondary" class="mt-4" @click="showFtpDialog = false" />
</div>
</div>
</Dialog>