From 195f96bb20b023e48151f5cc815540f60ce47236 Mon Sep 17 00:00:00 2001 From: xds Date: Tue, 17 Mar 2026 10:51:01 +0300 Subject: [PATCH] refix --- .../61f691fee44a_add_max_hr_to_riders.py | 30 ++ backend/app/api/rider.py | 8 + backend/app/models/rider.py | 1 + backend/app/schemas/rider.py | 3 + frontend/src/types/models.ts | 5 + frontend/src/views/DashboardView.vue | 105 +++++-- frontend/src/views/SettingsView.vue | 269 +++++++++++++++--- 7 files changed, 370 insertions(+), 51 deletions(-) create mode 100644 backend/alembic/versions/61f691fee44a_add_max_hr_to_riders.py diff --git a/backend/alembic/versions/61f691fee44a_add_max_hr_to_riders.py b/backend/alembic/versions/61f691fee44a_add_max_hr_to_riders.py new file mode 100644 index 0000000..fb69b3e --- /dev/null +++ b/backend/alembic/versions/61f691fee44a_add_max_hr_to_riders.py @@ -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 ### diff --git a/backend/app/api/rider.py b/backend/app/api/rider.py index 63dd6c0..c95a90e 100644 --- a/backend/app/api/rider.py +++ b/backend/app/api/rider.py @@ -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 ] diff --git a/backend/app/models/rider.py b/backend/app/models/rider.py index c2a397a..492384b 100644 --- a/backend/app/models/rider.py +++ b/backend/app/models/rider.py @@ -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) diff --git a/backend/app/schemas/rider.py b/backend/app/schemas/rider.py index 0b80f71..94c39b4 100644 --- a/backend/app/schemas/rider.py +++ b/backend/app/schemas/rider.py @@ -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 diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 3871131..654cb95 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -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 | 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 { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index bcdb7a1..c19821b 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -35,6 +35,7 @@ const digestLoading = ref(false) const progress = ref(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 `Неделя ${week}
` + + params.map((p: any) => `${p.marker} ${p.seriesName}: ${p.value ?? '—'}`).join('
') + }, + }, + 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 () => { - - + + - - - - - - diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4865f18..b682769 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,5 +1,5 @@