refix
This commit is contained in:
@@ -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 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>
|
||||
|
||||
|
||||
@@ -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