From 4e34378b0564708b56a8171137df19dc100c56b6 Mon Sep 17 00:00:00 2001 From: artem Date: Fri, 2 Oct 2026 22:44:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B8=D1=80=D1=83=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE=D1=89=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/memory-bank/activeContext.md | 27 ++++- .roo/memory-bank/progress.md | 5 + src/i18n/locales/br.json | 17 ++- src/i18n/locales/cn.json | 17 ++- src/i18n/locales/es.json | 17 ++- src/i18n/locales/gb.json | 17 ++- src/i18n/locales/ir.json | 17 ++- src/i18n/locales/ru.json | 17 ++- src/pages/preferences/settings/Settings.vue | 57 ++++++++- src/pages/workouts/Definitions.vue | 5 + src/pages/workouts/WorkoutItem.vue | 1 + src/pages/workouts/components/GetWorkout.ts | 36 ++++++ src/pages/workouts/components/WorkoutItem.vue | 110 ++++++++++++++++++ src/stores/user-store.ts | 8 ++ 14 files changed, 341 insertions(+), 10 deletions(-) diff --git a/.roo/memory-bank/activeContext.md b/.roo/memory-bank/activeContext.md index a2c1df7..971138a 100644 --- a/.roo/memory-bank/activeContext.md +++ b/.roo/memory-bank/activeContext.md @@ -2,11 +2,32 @@ ## Task State -- task_id: TASK-MAP-DOTS-RESET +- task_id: TASK-FVP-1..4 (Virtual Power Frontend) - status: success -- summary: **Фикс: серые точки на карте нельзя поставить повторно после reset. Корень: `resetChartZoom()` не очищает `mapX` и `clickCoordinates`. Фикс: добавить `mapX.value = []` + `clickCoordinates.value = []` в `resetChartZoom()`; inline onClick вынесен в `onMapClick` (строка 536). `yarn build` exit 0.** +- summary: **Все 4 задачи VP-фронтенда завершены: вес в профиле, кнопка+modal+POST, график (merge/separate), avg в шапке. `yarn build` → 0 ошибок. Browser verification pending (requires `yarn up`).** - next task: — -- plan: `plans/TASK-MAP-DOTS-RESET.md` +- plan: `plans/TASK-FRONTEND-VIRTUAL-POWER.md` + +## ✅ CODER SUCCESS REPORT — TASK-FVP-4 + +- `src/pages/workouts/components/WorkoutItem.vue`: добавлена карточка `v-if="workoutItem.virtual_power != null"` после блока «Максимальная мощность» (линия 151), перед блоком «Сделать публичной». +- `yarn build` (vue-tsc --noEmit + vite) → exit 0. +- Lint: предсуществующая `tsutils.iterateComments` (не связана с изменением). + +## ✅ CODER SUCCESS REPORT — TASK-FVP-2 + +- `components/WorkoutItem.vue`: кнопка VP (active state по `virtual_power_bike_type`), VaModal с 2 VaSelect, `calculateVP()` POST, emit `vpCalculated`, error handling (400/422). +- `pages/WorkoutItem.vue`: `@vpCalculated="initWorkout(...)"`. +- `Definitions.vue`: добавлено `virtual_power_bike_type?: string | null`. +- 6 locale-файлов: 12 ключей `virtualPower*`. +- `yarn build` → 0 ошибок. + +## ✅ CODER SUCCESS REPORT — TASK-FVP-1 + +- `src/stores/user-store.ts`: `weight: null as number | null` в SSR + localStorage hydration, action `setWeight(w)`. +- `src/pages/preferences/settings/Settings.vue`: 4-я строка с `VaInput type="number"` + save button, `PATCH /api/v0/profiles/{id}` с `{ profile: { weight } }`, toast success/error. +- 6 locale-файлов: `"preferences": { "weight": "..." }`. +- `yarn build` → 0 ошибок. Lint: предсуществующая `tsutils.iterateComments` (не связана). ## ✅ CODER SUCCESS REPORT — TASK-INTEG-SECTION (+TASK-SECTION-BUGS) diff --git a/.roo/memory-bank/progress.md b/.roo/memory-bank/progress.md index 5c96436..92f7a88 100644 --- a/.roo/memory-bank/progress.md +++ b/.roo/memory-bank/progress.md @@ -1,5 +1,10 @@ # Progress — Strava Frontend +## 2026-10-02 — TASK-FVP-4: карточка «Вирт. мощность» в шапке тренировки + +- `src/pages/workouts/components/WorkoutItem.vue`: блок `v-if="workoutItem.virtual_power != null"` (label `t("workout.virtualPower")`, значение `Math.floor(workoutItem.virtual_power)` Вт), вставлен после «Максимальная мощность» (линия 151), перед «Сделать публичной». +- Verified: `yarn build` (vue-tsc --noEmit + vite) → exit 0. Lint: только предсуществующая `tsutils.iterateComments` (не связана). + ## 2026-09-26 — TASK-SECTION-REZOOM-LOOP: фикс бесконечного цикла re-render (CPU-шум) - `src/pages/workouts/components/WorkoutItem.vue`: diff --git a/src/i18n/locales/br.json b/src/i18n/locales/br.json index 7b8f693..8d707b4 100644 --- a/src/i18n/locales/br.json +++ b/src/i18n/locales/br.json @@ -159,6 +159,21 @@ "fileDeleted": "Arquivo excluído" }, "workout": { - "hide_start_end": "Ocultar início e fim da trilha" + "hide_start_end": "Ocultar início e fim da trilha", + "virtualPower": "Potência virtual", + "virtualPowerBtn": "Calcular potência virtual", + "virtualPowerBikeType": "Tipo de bicicleta", + "virtualPowerRoad": "Estrada", + "virtualPowerOther": "Outro", + "virtualPowerSurface": "Superfície", + "virtualPowerAsphalt": "Asfalto", + "virtualPowerGravel": "Cascalho", + "virtualPowerDirt": "Terra", + "virtualPowerSnow": "Neve", + "virtualPowerCalculate": "Calcular", + "virtualPowerNeedWeight": "Defina seu peso nas configurações primeiro" + }, + "preferences": { + "weight": "Peso (kg)" } } diff --git a/src/i18n/locales/cn.json b/src/i18n/locales/cn.json index b761a8b..b2707f7 100644 --- a/src/i18n/locales/cn.json +++ b/src/i18n/locales/cn.json @@ -192,6 +192,21 @@ "fileDeleted": "文件已删除" }, "workout": { - "hide_start_end": "隐藏轨迹起点和终点" + "hide_start_end": "隐藏轨迹起点和终点", + "virtualPower": "虚拟功率", + "virtualPowerBtn": "计算虚拟功率", + "virtualPowerBikeType": "自行车类型", + "virtualPowerRoad": "公路", + "virtualPowerOther": "其他", + "virtualPowerSurface": "路面", + "virtualPowerAsphalt": "柏油路", + "virtualPowerGravel": "碎石", + "virtualPowerDirt": "泥土", + "virtualPowerSnow": "雪", + "virtualPowerCalculate": "计算", + "virtualPowerNeedWeight": "请先在个人资料设置中设置体重" + }, + "preferences": { + "weight": "体重 (kg)" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e3a321b..ac841a3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -148,6 +148,21 @@ "fileDeleted": "Archivo eliminado" }, "workout": { - "hide_start_end": "Ocultar inicio y fin de la ruta" + "hide_start_end": "Ocultar inicio y fin de la ruta", + "virtualPower": "Potencia virtual", + "virtualPowerBtn": "Calcular potencia virtual", + "virtualPowerBikeType": "Tipo de bicicleta", + "virtualPowerRoad": "Carretera", + "virtualPowerOther": "Otro", + "virtualPowerSurface": "Superficie", + "virtualPowerAsphalt": "Asfalto", + "virtualPowerGravel": "Grava", + "virtualPowerDirt": "Tierra", + "virtualPowerSnow": "Nieve", + "virtualPowerCalculate": "Calcular", + "virtualPowerNeedWeight": "Establezca su peso en la configuración primero" + }, + "preferences": { + "weight": "Peso (kg)" } } diff --git a/src/i18n/locales/gb.json b/src/i18n/locales/gb.json index 3458877..0b2c187 100644 --- a/src/i18n/locales/gb.json +++ b/src/i18n/locales/gb.json @@ -185,6 +185,21 @@ "fileDeleted": "File deleted" }, "workout": { - "hide_start_end": "Hide start and end of track" + "hide_start_end": "Hide start and end of track", + "virtualPower": "Virtual Power", + "virtualPowerBtn": "Calculate Virtual Power", + "virtualPowerBikeType": "Bike type", + "virtualPowerRoad": "Road", + "virtualPowerOther": "Other", + "virtualPowerSurface": "Surface", + "virtualPowerAsphalt": "Asphalt", + "virtualPowerGravel": "Gravel", + "virtualPowerDirt": "Dirt", + "virtualPowerSnow": "Snow", + "virtualPowerCalculate": "Calculate", + "virtualPowerNeedWeight": "Set your weight in profile settings first" + }, + "preferences": { + "weight": "Weight (kg)" } } diff --git a/src/i18n/locales/ir.json b/src/i18n/locales/ir.json index 3a651ae..6751aa4 100644 --- a/src/i18n/locales/ir.json +++ b/src/i18n/locales/ir.json @@ -200,6 +200,21 @@ "fileDeleted": "فایل حذف شد" }, "workout": { - "hide_start_end": "پنهان کردن شروع و پایان مسیر" + "hide_start_end": "پنهان کردن شروع و پایان مسیر", + "virtualPower": "توان مجازی", + "virtualPowerBtn": "محاسبه توان مجازی", + "virtualPowerBikeType": "نوع دوچرخه", + "virtualPowerRoad": "جاده‌ای", + "virtualPowerOther": "دیگر", + "virtualPowerSurface": "سطح", + "virtualPowerAsphalt": "آسفالت", + "virtualPowerGravel": "شن و ماسه", + "virtualPowerDirt": "گل", + "virtualPowerSnow": "برف", + "virtualPowerCalculate": "محاسبه", + "virtualPowerNeedWeight": "ابتدا وزن خود را در تنظیمات پروفایل وارد کنید" + }, + "preferences": { + "weight": "وزن (کیلوگرم)" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 85d7cac..9b2102d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -189,6 +189,21 @@ "fileDeleted": "Файл удалён" }, "workout": { - "hide_start_end": "Скрыть начало и конец трека" + "hide_start_end": "Скрыть начало и конец трека", + "virtualPower": "Вирт. мощность", + "virtualPowerBtn": "Рассчитать вирт. мощность", + "virtualPowerBikeType": "Тип велосипеда", + "virtualPowerRoad": "Шоссейный", + "virtualPowerOther": "Другой", + "virtualPowerSurface": "Покрытие", + "virtualPowerAsphalt": "Асфальт", + "virtualPowerGravel": "Гравий", + "virtualPowerDirt": "Грязь", + "virtualPowerSnow": "Снег", + "virtualPowerCalculate": "Рассчитать", + "virtualPowerNeedWeight": "Сначала укажите вес в настройках профиля" + }, + "preferences": { + "weight": "Вес (кг)" } } diff --git a/src/pages/preferences/settings/Settings.vue b/src/pages/preferences/settings/Settings.vue index 76bd42b..923d266 100644 --- a/src/pages/preferences/settings/Settings.vue +++ b/src/pages/preferences/settings/Settings.vue @@ -45,13 +45,68 @@ +
+

{{ t("preferences.weight") }}

+
+ + кг + + Сохранить + +
+
diff --git a/src/pages/workouts/Definitions.vue b/src/pages/workouts/Definitions.vue index 8d052f7..f9a6ab2 100644 --- a/src/pages/workouts/Definitions.vue +++ b/src/pages/workouts/Definitions.vue @@ -40,6 +40,9 @@ export type WorkoutItem = { is_public: boolean; hide_start_end: boolean; workouted_at: string; + virtual_power_bike_type?: string | null; + virtual_power?: number | null; + virtual_power_surface?: string | null; external_links?: WorkoutLink; photos?: Array; }; @@ -78,6 +81,7 @@ export type ChartDataset = { borderColor: string; data: Array; yAxisID?: string; + borderDash?: Array; }; export type ChartData = { labels: Array; @@ -87,6 +91,7 @@ export type ChartDataByMetric = { speed?: ChartData; heartRate?: ChartData; power?: ChartData; + virtualPower?: ChartData; elevation?: ChartData; }; export default {}; diff --git a/src/pages/workouts/WorkoutItem.vue b/src/pages/workouts/WorkoutItem.vue index 41374a3..e740557 100644 --- a/src/pages/workouts/WorkoutItem.vue +++ b/src/pages/workouts/WorkoutItem.vue @@ -9,6 +9,7 @@ :distances="distances" :dzenLink="dzenLink" :isPrivate="true" + @vpCalculated="initWorkout(route.params.id as string)" />
diff --git a/src/pages/workouts/components/GetWorkout.ts b/src/pages/workouts/components/GetWorkout.ts index fe72ba0..018823f 100644 --- a/src/pages/workouts/components/GetWorkout.ts +++ b/src/pages/workouts/components/GetWorkout.ts @@ -40,10 +40,12 @@ export const GetWorkout = (url: string) => { const speed = []; const heart_rate = []; const power = []; + const virtual_power = []; const coords = []; const elevation = []; let is_elevation = false; let is_power = false; + let is_virtual_power = false; let is_heart_rate = false; let is_speed = false; for (const i in response.data.results) { @@ -60,6 +62,10 @@ export const GetWorkout = (url: string) => { if (response.data.results[i].power !== null) { is_power = true; } + virtual_power.push(response.data.results[i].virtual_power ?? null); + if (response.data.results[i].virtual_power != null) { + is_virtual_power = true; + } heart_rate.push(response.data.results[i].heart_rate); if (response.data.results[i].heart_rate !== null) { is_heart_rate = true; @@ -126,6 +132,36 @@ export const GetWorkout = (url: string) => { ], }; } + if (is_virtual_power) { + if (is_power) { + // Merge into power chart as second dataset (dashed orange) + byMetric.power!.datasets.push({ + yAxisID: "logAxis", + radius: 0, + label: "Вирт. мощность", + borderColor: "#ff9800", + backgroundColor: "#ff9800", + borderDash: [5, 5], + data: virtual_power, + }); + } else { + // Separate chart when no real power + byMetric.virtualPower = { + labels: times, + datasets: [ + { + yAxisID: "logAxis", + radius: 0, + label: "Вирт. мощность", + borderColor: "#ff9800", + backgroundColor: "#ff9800", + borderDash: [5, 5], + data: virtual_power, + }, + ], + }; + } + } if (is_elevation) { byMetric.elevation = { labels: times, diff --git a/src/pages/workouts/components/WorkoutItem.vue b/src/pages/workouts/components/WorkoutItem.vue index 692002b..3f34a6a 100644 --- a/src/pages/workouts/components/WorkoutItem.vue +++ b/src/pages/workouts/components/WorkoutItem.vue @@ -149,6 +149,14 @@ {{ Math.floor(workoutItem.max_power) }} Вт
+
+
+ {{ t("workout.virtualPower") }}: +
+
+ {{ Math.floor(workoutItem.virtual_power) }} Вт +
+
Сделать публичной: 
+
+
+ {{ t("workout.virtualPowerBtn") }}:  +
+ + {{ t("workout.virtualPower") }} + +
Ссылки на описание: 
@@ -366,6 +389,31 @@ /> + +

{{ t("workout.virtualPowerBtn") }}

+ +

{{ t("workout.virtualPowerBikeType") }}

+ +

{{ t("workout.virtualPowerSurface") }}

+ +
+ + {{ t("workout.virtualPowerCalculate") }} + +
+
+
>(() => { yScaleById.yGroung.options, ); push("power", "Мощность", data.power, "logAxis", yScaleById.logAxis.options); + push( + "virtualPower", + "Вирт. мощность", + data.virtualPower, + "logAxis", + yScaleById.logAxis.options, + ); return configs; }); const collectCharts = () => { @@ -1192,6 +1247,61 @@ onMounted(() => { group.charts = collectCharts(); }); const axiosAuth = inject("axiosAuth") as AxiosInstance; + +const showVPModal = ref(false); +const vpBikeType = ref("road"); +const vpSurface = ref("asphalt"); +const vpLoading = ref(false); + +const bikeTypeOptions = computed(() => [ + { label: t("workout.virtualPowerRoad"), value: "road" }, + { label: t("workout.virtualPowerOther"), value: "other" }, +]); +const surfaceOptions = computed(() => [ + { label: t("workout.virtualPowerAsphalt"), value: "asphalt" }, + { label: t("workout.virtualPowerGravel"), value: "gravel" }, + { label: t("workout.virtualPowerDirt"), value: "dirt" }, + { label: t("workout.virtualPowerSnow"), value: "snow" }, +]); + +const emits = defineEmits(["vpCalculated"]); + +const calculateVP = () => { + vpLoading.value = true; + const id = workoutItem!.id; + axiosAuth + .post(`/api/v0/workouts/${id}/virtual-power`, { + bike_type: vpBikeType.value, + surface: vpSurface.value, + }) + .then(() => { + showVPModal.value = false; + emits("vpCalculated"); + }) + .catch((err: unknown) => { + if (err instanceof AxiosError) { + if (err.response?.status === 400) { + init({ + message: t("workout.virtualPowerNeedWeight"), + color: "warning", + }); + } else if (err.response?.status === 422) { + const detail = err.response.data?.detail; + const msg = Array.isArray(detail) + ? detail.map((d: { msg?: string }) => d.msg || "").join("; ") + : "Validation error"; + init({ message: msg || "Validation error", color: "error" }); + } else { + init({ message: "Что-то пошло не так.", color: "error" }); + } + } else { + init({ message: "Что-то пошло не так.", color: "error" }); + } + }) + .finally(() => { + vpLoading.value = false; + }); +}; const saveLink = (hide: () => void) => { if (!dzenLink.value) { return; diff --git a/src/stores/user-store.ts b/src/stores/user-store.ts index 9db4152..3795d87 100644 --- a/src/stores/user-store.ts +++ b/src/stores/user-store.ts @@ -12,6 +12,7 @@ export const useUserStore = defineStore("user", { memberSince: "", pfp: "", is2FAEnabled: false, + weight: null as number | null, }; } const defaultAvatar = @@ -25,6 +26,7 @@ export const useUserStore = defineStore("user", { memberSince: "", pfp: "", is2FAEnabled: false, + weight: null as number | null, }; } const dataProfile = JSON.parse(localStorage.getItem("profile")!); @@ -56,6 +58,9 @@ export const useUserStore = defineStore("user", { memberSince: formattedToday, pfp: "https://picsum.photos/id/22/200/300", is2FAEnabled: false, + weight: + ((dataProfile as Record).weight as number | null) ?? + null, }; }, @@ -71,5 +76,8 @@ export const useUserStore = defineStore("user", { changeAvatar(avatar: string) { this.avatar = avatar; }, + setWeight(w: number | null) { + this.weight = w; + }, }, });