refix
This commit is contained in:
@@ -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 ###
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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>
|
||||
<!-- Weekly: volume -->
|
||||
<div v-if="trendsMode === 'weekly' && weeklyStats.length > 0">
|
||||
<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: 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>
|
||||
</template>
|
||||
<template #content>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user