вирутальная мощность
Gitea Actions Demo / build_and_push (push) Successful in 1m16s Details

This commit is contained in:
artem 2026-10-02 22:44:11 +03:00
parent 75b07057ae
commit 4e34378b05
14 changed files with 341 additions and 10 deletions

View File

@ -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)

View File

@ -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`:

View File

@ -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)"
}
}

View File

@ -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)"
}
}

View File

@ -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)"
}
}

View File

@ -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)"
}
}

View File

@ -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": "وزن (کیلوگرم)"
}
}

View File

@ -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": "Вес (кг)"
}
}

View File

@ -45,13 +45,68 @@
</VaButton>
</div>
<VaDivider />
<div
class="flex flex-col md:flex-row md:items-center space-y-2 md:space-y-0 md:space-x-6 min-h-[36px] leading-5"
>
<p class="font-bold w-[200px]">{{ t("preferences.weight") }}</p>
<div class="flex-1 flex items-center gap-2">
<VaInput
v-model.number="weightValue"
type="number"
class="max-w-[200px]"
:placeholder="String(store.weight ?? 75)"
/>
<span class="text-sm text-gray-500">кг</span>
<VaButton
:style="buttonStyles"
preset="primary"
size="small"
class="w-fit h-fit"
:loading="savingWeight"
@click="saveWeight"
>
Сохранить
</VaButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, inject } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "vuestic-ui/web-components";
import { AxiosInstance } from "axios";
import { useUserStore } from "../../../stores/user-store";
import { buttonStyles } from "../styles";
const { t } = useI18n();
const { init } = useToast();
const store = useUserStore();
const axiosAuth = inject("axiosAuth") as AxiosInstance;
const weightValue = ref<number | undefined>(store.weight ?? undefined);
const savingWeight = ref(false);
const emits = defineEmits(["openNameModal", "openResetPasswordModal"]);
const saveWeight = () => {
if (weightValue.value === undefined || weightValue.value === null) return;
savingWeight.value = true;
const dataProfile = JSON.parse(localStorage.getItem("profile") || "{}");
dataProfile.weight = weightValue.value;
localStorage.setItem("profile", JSON.stringify(dataProfile));
axiosAuth
.patch(`/api/v0/profiles/${store.profileID}`, {
profile: { weight: weightValue.value },
})
.then(() => {
store.setWeight(weightValue.value as number);
init({ message: "Вес сохранён", color: "success" });
})
.catch(() => {
init({ message: "Ошибка сохранения", color: "error" });
})
.finally(() => {
savingWeight.value = false;
});
};
</script>

View File

@ -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<WorkoutPhoto>;
};
@ -78,6 +81,7 @@ export type ChartDataset = {
borderColor: string;
data: Array<number | null>;
yAxisID?: string;
borderDash?: Array<number>;
};
export type ChartData = {
labels: Array<string | number>;
@ -87,6 +91,7 @@ export type ChartDataByMetric = {
speed?: ChartData;
heartRate?: ChartData;
power?: ChartData;
virtualPower?: ChartData;
elevation?: ChartData;
};
export default {};

View File

@ -9,6 +9,7 @@
:distances="distances"
:dzenLink="dzenLink"
:isPrivate="true"
@vpCalculated="initWorkout(route.params.id as string)"
/>
<div v-else>
<VaInnerLoading loading :size="60"> </VaInnerLoading>

View File

@ -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,

View File

@ -149,6 +149,14 @@
{{ Math.floor(workoutItem.max_power) }} Вт
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.virtual_power != null">
<div class="workout-item-params-name">
{{ t("workout.virtualPower") }}:
</div>
<div class="workout-item-params-value">
{{ Math.floor(workoutItem.virtual_power) }} Вт
</div>
</div>
<div class="workout-item-params" v-if="isPrivate">
<div class="workout-item-params-name">Сделать публичной:&nbsp;</div>
<input
@ -167,6 +175,21 @@
v-on:change="changeHideStartEnd(workoutItem.hide_start_end)"
/>
</div>
<div class="workout-item-params" v-if="isPrivate">
<div class="workout-item-params-name">
{{ t("workout.virtualPowerBtn") }}:&nbsp;
</div>
<VaButton
preset="secondary"
size="small"
:color="
workoutItem!.virtual_power_bike_type ? 'primary' : 'textPrimary'
"
@click="showVPModal = true"
>
<VaIcon name="flash_on" /> {{ t("workout.virtualPower") }}
</VaButton>
</div>
<div class="workout-item-params" v-if="dzenLink || isPrivate">
<div class="workout-item-params-name">Ссылки на описание:&nbsp;</div>
<div v-if="dzenLink">
@ -366,6 +389,31 @@
/>
</VaForm>
</VaModal>
<VaModal
v-model="showVPModal"
size="small"
hide-default-actions
close-button
@update:modelValue="(v: boolean) => (showVPModal = v)"
>
<h1 class="va-h5 mb-4">{{ t("workout.virtualPowerBtn") }}</h1>
<VaForm @submit.prevent="calculateVP">
<p class="mb-1 font-bold">{{ t("workout.virtualPowerBikeType") }}</p>
<VaSelect v-model="vpBikeType" :items="bikeTypeOptions" class="mb-4" />
<p class="mb-1 font-bold">{{ t("workout.virtualPowerSurface") }}</p>
<VaSelect v-model="vpSurface" :items="surfaceOptions" class="mb-4" />
<div class="flex justify-end">
<VaButton
type="submit"
preset="primary"
size="small"
:loading="vpLoading"
>
{{ t("workout.virtualPowerCalculate") }}
</VaButton>
</div>
</VaForm>
</VaModal>
<div
v-if="showLightbox && lightboxSet.length > 0"
class="photo-lightbox"
@ -1131,6 +1179,13 @@ const chartConfigs = computed<Array<ChartConfig>>(() => {
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;

View File

@ -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<string, unknown>).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;
},
},
});