From 714229359116fd7f44dff8887657e5420832a9b7 Mon Sep 17 00:00:00 2001 From: artem Date: Sat, 26 Sep 2026 21:32:31 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=B7=D1=83=D0=BC=D0=BE=D0=BC=20=D0=B8=20=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/memory-bank/activeContext.md | 99 ++++++++++++++++++- .roo/memory-bank/progress.md | 52 ++++++++++ src/pages/workouts/components/ChartGroup.ts | 21 +++- src/pages/workouts/components/WorkoutItem.vue | 62 +++++++++--- 4 files changed, 218 insertions(+), 16 deletions(-) diff --git a/.roo/memory-bank/activeContext.md b/.roo/memory-bank/activeContext.md index f4d3443..ce57cd9 100644 --- a/.roo/memory-bank/activeContext.md +++ b/.roo/memory-bank/activeContext.md @@ -2,11 +2,106 @@ ## Task State -- task_id: TASK-PROFILE-ULID-BUG +- task_id: TASK-INTEG-SECTION - status: success -- summary: **PATCH /profiles/{id} возвращает 500 (смена имени не персистится). Root cause: `default=str(ULID())` в `profiles.py:32` и `users.py:20` — ULID считается один раз при импорте; `DBProfileRepository.update` включает `id` в SET → `IntegrityError`. Фикс: `default_factory=lambda: str(ULID())` + defensive `del new_dict["id"]` + новый API-тест (PATCH → 200 + персистентность имени).** +- summary: **Playwright-интеграционный тест для TASK-SECTION-DURATION-ZOOM: 3 passed. Фиксы 2 багов (setChartRef timing + resetAll programmatic zoom).** - next task: — +## ✅ CODER SUCCESS REPORT — TASK-INTEG-SECTION (+TASK-SECTION-BUGS) +- `../integration/tests/section-duration.spec.ts` — 3 теста (duration row, zoom persist across tabs, reset on all tabs), self-contained (stable owner `itest-section-owner`, API-seed, Chart.js via `canvas.__vueParentComponent.exposed.chart`). +- **Bug 1 фикс** (`WorkoutItem.vue` `setChartRef` ~1155): ref пушится сразу (без guard `r.chart`), финализация (`collectCharts` + `applySelectedRangeZoom`) через retry `nextTick` (до 20 попыток) — дождётся асинхронной инициализации Chart.js. `nextTick` добавлен в импорт из "vue" (~строка 440). +- **Bug 2 фикс** (`ChartGroup.ts` `resetAll` ~125): после `resetZoom(chart)` явно `x.min=undefined; x.max=undefined; chart.update("none")` (broadcastZoom писал min/max напрямую, минуя плагин). Дополнительно: на время цикла `resetAll` установлена `this.broadcasting=true` (try/finally) — `resetZoom` триггерит `onZoomComplete`, который иначе повторно разносит ещё-зумированный диапазон на остальные чарты; guard `isBroadcasting()` отбрасывает эти колбэки. +- `npx playwright test tests/section-duration.spec.ts` → **3 passed** (56s) на docker-стече. +- `yarn build` (vue-tsc --noEmit + vite) → exit 0. +- Known: диагностический `tests/chartprobe.spec.ts` не удалён (rm отклонён) — удалить вручную, если мешает. + +## TASK-INTEG-SECTION: план делегирования + +**Scope:** 1 новый файл — `../integration/tests/section-duration.spec.ts` + запуск на стеке. Не трогать `src/`. + +**Что тестируется (3 UX-требования, компонент `src/pages/workouts/components/WorkoutItem.vue`, страница `workout_item`):** +1. Клик по графику (выделение участка) → в блоке `.area-data` появляется строка «Продолжительность». +2. Клик по вкладке «Дистанция» → тот же участок остаётся в zoom (не весь путь). +3. Клик по reset → участок сброшен; после смена вкладки → весь путь (reset на всех вкладках). + +**Контракты (проверено Архитектором):** +- Детальная страница (приватная, с графиками + вкладками): маршрут `workout_item` → URL `{FRONTEND}/workouts/workouts/{id}` (родитель `workouts` + вложенный `workouts/:id`). Навигация `waitUntil: "domcontentloaded"` (Yandex map держит соединения). +- Seed через API (чисто, без UI-dedup): `POST {BACKEND}/api/v0/signup` (200) ИЛИ `POST /api/v0/signin` (fallback на повторном прогоне) → token; `POST /api/v0/attachment/upload` (FormData поле `file`, Bearer) → `{id}`; `POST /api/v0/workouts` `{attachment_id, name}` → `{id}` (400 `ObjectExists` → взять id из `GET /api/v0/workouts`[0]). + - Файл: `../../backend/tests/fitness_tracker_data_parsing-main/20240507053735.fit` (тот же, что в `workout-upload-flow.spec.ts`). +- Детальный ответ `GET /api/v0/workouts/{id}` → `{ results: [{ timestamp, longitude, latitude, speed, heart_rate, power, elevation, ... }], workout: {...} }` — из него взять полный диапазон (первая/последняя `timestamp`, длина) для порогов zoom. +- Chart.js: canvas графиков — `#workout-charts .workout-chart-canvas canvas` (несколько; брать `[0]`). Чертит только если есть метрика (у fixture есть speed). Zoom-состояние: `window.Chart.getChart(canvas).isZoomedOrPanned()` + `chart.options.scales.x.min/max` (broadcastZoom пишет сюда) + `chart.scales.x.min/max`. +- Клик по графику (выбор участка) — через Chart.js `onClick` опцию: `mouse.down` → `mouse.move` → `mouse.up` в точке внутри chartArea (не drag, чтобы не было drag-zoom). Координаты взять через `chart.chartArea` (left/right/top/bottom) в `page.evaluate`. +- Кнопки вкладок: по тексту «Время» и «Дистанция» (VaButton). Reset: `#zoom-botton`. Блок данных участка: `.area-data` (заголовок «Данные участка»), строка «Продолжительность». + +**Структура спе (self-contained, repeatable):** +- `beforeAll`: проверить стек (frontend `<500`, backend `/api/v0/html_test/login`==200), проверить наличие fixture-файла; seed stable owner (`itest-section-owner@example.com`, idempotent signup/signin) + его workout (upload attachment + create; на 400 — id из списка). +- 3 независимых `test`'а (каждый: `authedPage(token)` → `openWorkoutDetail(id)` → `expect` canvas visible): + 1. «duration row on section select»: canvas-click → `expect .area-data` visible → `expect` текст «Продолжительность» в `.area-data`. + 2. «section zoom persists across tab switch»: canvas-click (zoom) → assert zoomed (x.min/dev/null; do sleep 2; done` → `npx playwright test tests/section-duration.spec.ts`. + +**Acceptance criteria:** +1. `../integration/tests/section-duration.spec.ts` создан. +2. `npx playwright test tests/section-duration.spec.ts` → **3 passed** на запущенном стеке. +3. Не сломать существующие спе (опционально: `npx playwright test --list`). + +## TASK-SECTION-DURATION-ZOOM: план делегирования + +**Scope:** 1 файл — `src/pages/workouts/components/WorkoutItem.vue` (компонент, НЕ страница `src/pages/workouts/WorkoutItem.vue`). + +**Ключевые якоря (текущее состояние кода):** + +- `times` (~строка 1061) — сырые ISO-метки времени всех точек (общие для всех метрик). +- `distances` (prop) — накопительные метры на точку. +- `activeSection = ref(null)` (~строка 661) — номер секции, **зависит от `xAxisMode`** (потому что `sectionBounds` пересчитывается под режим). +- `computeAreaData(start, end)` (~строка 590) — считает средние по метрикам в блок «Данные участка» (`areaAvgData`). +- `switchXAxis(mode)` (~строка 1135) — сбрасывает `activeSection = null` (баг п.2). +- `onZoomComplete` (~строка 873) — `activeSection = null` + `computeAreaData(start,end)`. +- `onClick` (~строка 930) — выбор/тоггл секции. +- `resetChartZoom()` (~строка 1449) — reset. +- `setChartRef` (~строка 1126) — после сбора всех refs делает `group.charts = collectCharts()` (сигнал для re-zoom после remount). +- `secondsToDuration` — экспортируется из `../Definitions.vue` (не импортирован сейчас — добавить в импорт). + +**Изменения:** + +1. Заменить `activeSection: number|null` на `selectedRange: { startIdx: number; endIdx: number } | null` (единственный source of truth, **не зависит от режима** — индексы точек). +2. В `computeAreaData(start,end)`: добавить `avgData["Продолжительность"] = secondsToDuration(durSec)`, где `durSec = max(0, round((Number(new Date(times[end-1])) - Number(new Date(times[start])))/1000))`. Импортировать `secondsToDuration` из `../Definitions.vue`. +3. `onClick`: выбрать `selectedRange = {startIdx, endIdx}` по `sectionBounds`; тоггл-off когда `selectedRange` совпадает (startIdx== и endIdx==); выбрать иначе → `selectedRange`, `computeAreaData`, `broadcastZoom`. +4. `onZoomComplete`: `selectedRange = null` (ручной zoom снимает выбор), `computeAreaData(start,end)` сохранить. +5. `switchXAxis`: **НЕ** сбрасывать `selectedRange`; сохранить `areaAvgData`/`markedCoordinats` (они режим-независимы). Добавить `applySelectedRangeZoom()`: `labels = xAxisMode==='time' ? times : distances`; `toValue = time? Number(new Date(l)) : Number(l)`; `min=toValue(labels[startIdx])`, `max=toValue(labels[endIdx-1])`; `group.broadcastZoom(min,max)`. Вызвать `applySelectedRangeZoom()` в `setChartRef` после `group.charts = collectCharts()` (re-apply zoom в новом режиме после remount). +6. `resetChartZoom()`: `selectedRange = null` (чтобы после reset на другой вкладке тоже был весь путь), остальное как есть (`resetAll`, `areaAvgData={}`, `markedCoordinats=[]`). + +**Почему работает «на всех вкладках»: ** reset обнуляет `selectedRange` → при смене вкладки zoom re-apply вызывается только если `selectedRange` set → после reset всегда весь путь. + +**Acceptance criteria:** + +1. `yarn build` (vue-tsc --noEmit) → exit 0. +2. `yarn lint` → 0 ошибок (или зафиксировать пре-экзистинг системный `tsutils.iterateComments`). +3. Ручной сценарий (MCP browser): выделить участок → в «Данные участка» есть «Продолжительность» → перейти на вкладку «Дистанция» → та же секция остаётся в zoom (не весь путь) → нажать reset → участок сброшен на всех вкладках. + +## ✅ CODER SUCCESS REPORT — TASK-SECTION-DURATION-ZOOM + +- `src/pages/workouts/components/WorkoutItem.vue`: + - импорт `secondsToDuration` добавлен в импорт из `../Definitions.vue` (рядом с `formatTime`). + - `activeSection: number|null` → `selectedRange: { startIdx: number; endIdx: number } | null` (единственный source of truth; индексы точек, не зависят от `xAxisMode`). + - `computeAreaData(start,end)`: в начале цикла добавлена строка `avgData["Продолжительность"] = secondsToDuration(durSec)`, где `durSec = max(0, round((Number(new Date(times[end-1])) - Number(new Date(times[start])))/1000))`. + - `onClick`: тоггл-off по совпадению `selectedRange.startIdx===startIdx && selectedRange.endIdx===endIdx`; иначе `selectedRange={startIdx,endIdx}` + `computeAreaData` + `broadcastZoom`. + - `onZoomComplete`: `selectedRange=null` (ручной zoom снимает выбор). + - `switchXAxis`: УБРАЛ сброс `activeSection` (выбор сохраняется); `areaAvgData`/`markedCoordinats` не трогаю (режим-независимы). Добавлена `applySelectedRangeZoom()` (labels = time? times : distances; broadcastZoom(min,max)). + - `setChartRef`: внутри `if (chartRefs.length === chartConfigs.value.length)` после `group.charts = collectCharts()` вызывается `applySelectedRangeZoom()` (re-apply zoom в новом режиме после remount). + - `resetChartZoom`: `selectedRange=null` (после reset на любой вкладке — весь путь). +- Verified: `yarn build` (vue-tsc --noEmit) → ✓ built in 7.14s, Done (0 type errors). +- Verified: `grep activeSection` → 0 совпадений; `grep selectedRange` → 9 совпадений; `grep secondsToDuration` → импорт + использование (2 совпадения). +- Note: `yarn lint` сломан системно — `tsutils.iterateComments is not a function` на всех 73 файлах (пре-экзистинг, дефект env eslint/typescript-estree, не от этих изменений). +- Browser verification (MCP ad-hoc): НЕ выполнена — dev server `:5173` и интеграционный стек `:8000` не запущены (не поднимал docker-стек ради ad-hoc проверки). Результат по критериям 1–4. +- ✅ Architect review: прочитаны все изменённые блоки (импорт 481, `computeAreaData` 599-606, `selectedRange` 670, `onClick` 997-1008, `onZoomComplete` 921, `switchXAxis` 1164-1173, `applySelectedRangeZoom` 1142-1153 + вызов в `setChartRef` 1161, `resetChartZoom` 1479). Корректность подтверждена: `times`/`distances`/`lineCoordinates` одной длины; re-apply zoom после remount через `setChartRef`; после reset `selectedRange=null` → на любой вкладке весь путь. + ## TASK-PROFILE-ULID-BUG: план делегирования **Scope:** `../backend/` (3 файла фикса + 1 новый тест). diff --git a/.roo/memory-bank/progress.md b/.roo/memory-bank/progress.md index 4001bb3..848c81a 100644 --- a/.roo/memory-bank/progress.md +++ b/.roo/memory-bank/progress.md @@ -1,5 +1,57 @@ # Progress — Strava Frontend +## 2026-09-26 — TASK-INTEG-SECTION + TASK-SECTION-BUGS: интеграционный тест section-duration + фикс 2 багов + +- `../integration/tests/section-duration.spec.ts` (new): 3 Playwright-теста + (duration row on section select; zoom persist across Время↔Дистанция; + reset clears on all tabs). Self-contained: stable owner + `itest-section-owner@example.com` (idempotent signup/signin), API-seed + workout (upload + create, dedup-fallback → id из `GET /workouts`[0]), + Chart.js access через `canvas.__vueParentComponent.exposed.chart` + (глобального `window.Chart` в SPA нет); zoom-детект по + `options.scales.x.min/max` vs полный диапазон (`isZoomedOrPanned()` + всегда false — zoom применяется прямой записью, минуя плагин). +- **Bug 1** (`src/pages/workouts/components/WorkoutItem.vue`, + `setChartRef` ~строка 1155): ref пушится сразу (guard `r.chart` убран — + Chart.js создаёт инстанс асинхронно); финализация + (`group.charts = collectCharts()` + `applySelectedRangeZoom()`) через + `tryFinalize(retries)` — retry через `nextTick` (до 20 попыток) до тех + пор, пока все рефы не получат `chart`. `nextTick` добавлен в импорт из + "vue". Корень бага: на remount после `switchXAxis` `setChartRef` + срабатывал до создания chart → `chartRefs` не пополнялся → + `applySelectedRangeZoom()` не вызывался → zoom терялся. +- **Bug 2** (`src/pages/workouts/components/ChartGroup.ts`, `resetAll` + ~строка 125): после `resetZoom(chart)` явный сброс + `options.scales.x.min/max = undefined` + `chart.update("none")` + (broadcastZoom писал min/max напрямую, минуя внутреннее состояние + плагина, поэтому `resetZoom` один ничего не сбрасывал). Дополнительная + корневая причина: `resetZoom` триггерит `onZoomComplete`, который + повторно разносит ещё-зумированный диапазон на остальные чарты + (перетирая сброшенный) — в `resetAll` на время цикла + `this.broadcasting = true` (try/finally), guard `isBroadcasting()` + в `onZoomComplete` отбрасывает эти колбэки. +- Verified: `npx playwright test tests/section-duration.spec.ts` → + **3 passed** (56s, docker-стек); `yarn build` (vue-tsc --noEmit + + vite) → exit 0. +- Known: диагностический `../integration/tests/chartprobe.spec.ts` не + удалён (rm-команда отклонена окружением) — удалить вручную. + +## 2026-09-26 — TASK-SECTION-DURATION-ZOOM: «Продолжительность» + сохранение выбора участка при смене вкладки + +- `src/pages/workouts/components/WorkoutItem.vue`: + - импорт `secondsToDuration` из `../Definitions.vue`. + - `activeSection: number|null` → `selectedRange: { startIdx, endIdx } | null` (режим-независимый source of truth, индексы точек). + - `computeAreaData`: строка `avgData["Продолжительность"] = secondsToDuration(durSec)` по `times[start]`/`times[end-1]`. + - `onClick`: тоггл-off по совпадению индексов (`startIdx` и `endIdx`); иначе `selectedRange={startIdx,endIdx}` + `computeAreaData` + `broadcastZoom`. + - `onZoomComplete`: `selectedRange = null` (ручной zoom снимает выбор). + - `switchXAxis`: убран сброс выбора; добавлена `applySelectedRangeZoom()`; `areaAvgData`/`markedCoordinats` не сбрасываются. + - `setChartRef`: `applySelectedRangeZoom()` после `group.charts = collectCharts()`. + - `resetChartZoom`: `selectedRange = null` (после reset — весь путь на всех вкладках). +- Verified: `yarn build` → ✓ built in 7.14s (vue-tsc --noEmit 0 ошибок). +- Verified: `grep activeSection` → 0 совпадений; `grep selectedRange` → 9; `grep secondsToDuration` → 2 (импорт + использование). +- Known issue: `yarn lint` сломан системно — `tsutils.iterateComments is not a function` на всех 73 файлах (дефект окружения eslint/typescript-estree). +- Browser (MCP ad-hoc): не выполнена — dev server `:5173` и интеграционный стек `:8000` не запущены (не поднимал docker-стек ради ad-hoc проверки). Результат по критериям 1–4. + ## 2026-09-25 — TASK-PROFILE-ULID-BUG: фикс PATCH /profiles 500 (смена имени не персистится) - `../backend/app/modules/users/domains/profiles.py:32` — `default=str(ULID())` → `default_factory=lambda: str(ULID())`. diff --git a/src/pages/workouts/components/ChartGroup.ts b/src/pages/workouts/components/ChartGroup.ts index fe9e512..f644bf9 100644 --- a/src/pages/workouts/components/ChartGroup.ts +++ b/src/pages/workouts/components/ChartGroup.ts @@ -123,8 +123,25 @@ export class ChartGroup { } resetAll(): void { - for (const chart of this.charts) { - resetZoom(chart); + // The plugin's resetZoom fires onZoomComplete, and the app handler + // re-broadcasts the visible range to the other charts - which would + // re-zoom the charts we just reset. Hold the broadcasting guard for + // the whole loop so every reset-triggered onZoomComplete is dropped. + this.broadcasting = true; + try { + for (const chart of this.charts) { + resetZoom(chart); + // broadcastZoom writes min/max directly on scale options (bypassing + // the plugin), so resetZoom alone does not clear them. + const x = chart.options?.scales?.x; + if (x) { + x.min = undefined; + x.max = undefined; + chart.update("none"); + } + } + } finally { + this.broadcasting = false; } this.clearHover(); } diff --git a/src/pages/workouts/components/WorkoutItem.vue b/src/pages/workouts/components/WorkoutItem.vue index 3ed1a3c..472348d 100644 --- a/src/pages/workouts/components/WorkoutItem.vue +++ b/src/pages/workouts/components/WorkoutItem.vue @@ -437,6 +437,7 @@ import { onMounted, onBeforeUnmount, watch, + nextTick, } from "vue"; import { useToast, useForm } from "vuestic-ui/web-components"; import { useI18n } from "vue-i18n"; @@ -478,6 +479,7 @@ import { distConvert, speedConvert, formatTime, + secondsToDuration, ChartData, ChartDataByMetric, WorkoutPhoto, @@ -595,6 +597,14 @@ const computeAreaData = (start: number, end: number) => { ? lineCoordinates.slice(start, end) : []; let avgData: AvgData = {}; + const durSec = Math.max( + 0, + Math.round( + (Number(new Date(times[end - 1])) - Number(new Date(times[start]))) / + 1000, + ), + ); + avgData["Продолжительность"] = secondsToDuration(durSec); for (const config of chartConfigs.value) { const values = config.data.datasets[0].data.slice(start, end); const present = values.filter((v) => v !== null); @@ -658,7 +668,7 @@ const sectionBounds = computed< } return bounds; }); -const activeSection = ref(null); +const selectedRange = ref<{ startIdx: number; endIdx: number } | null>(null); // Power zones keyed by FTP; FTP is not a separate field, so the average // power is used as the FTP heuristic (Strava-style). Null -> no heatmap. // Absolute watt bounds do not depend on the x-axis mode, so the result is @@ -909,7 +919,7 @@ const buildChartOptions = ( } } // A manual zoom ends any earlier section selection. - activeSection.value = null; + selectedRange.value = null; computeAreaData(start, end); group.broadcastZoom(min, max); }, @@ -984,15 +994,19 @@ const buildChartOptions = ( if (sectionNo === null) { return; } - // Toggle: a second click on the already selected section resets. - if (activeSection.value === sectionNo) { - activeSection.value = null; + // Toggle: a second click on the already selected range resets it. + if ( + selectedRange.value && + selectedRange.value.startIdx === startIdx && + selectedRange.value.endIdx === endIdx + ) { + selectedRange.value = null; group.resetAll(); areaAvgData.value = {}; markedCoordinats.value = []; return; } - activeSection.value = sectionNo; + selectedRange.value = { startIdx, endIdx }; const min = toValue(labels[startIdx]); const max = toValue(labels[endIdx - 1]); computeAreaData(startIdx, endIdx); @@ -1123,14 +1137,39 @@ const collectCharts = () => { } return charts; }; +// Re-applies the selected point-range zoom in the current x-axis mode. Used +// after a mode change remounts every canvas, so the same section stays +// zoomed instead of resetting to the full path. +const applySelectedRangeZoom = () => { + const r = selectedRange.value; + if (!r) { + return; + } + const labels = xAxisMode.value === "time" ? times : distances; + const toValue = (l: string | number): number => + xAxisMode.value === "time" ? Number(new Date(l as string)) : Number(l); + const min = toValue(labels[r.startIdx]); + const max = toValue(labels[r.endIdx - 1]); + group.broadcastZoom(min, max); +}; const setChartRef = (_key: string, el: unknown) => { const r = el as ChartRefEl | null; - if (r && r.chart && chartRefs.length < chartConfigs.value.length) { + if (!r) return; + if (chartRefs.length < chartConfigs.value.length) { chartRefs.push(r); } - if (chartRefs.length === chartConfigs.value.length) { - group.charts = collectCharts(); - } + const tryFinalize = (retries: number): void => { + if ( + chartRefs.length === chartConfigs.value.length && + chartRefs.every((ref) => !!ref.chart) + ) { + group.charts = collectCharts(); + applySelectedRangeZoom(); + } else if (retries > 0) { + nextTick(() => tryFinalize(retries - 1)); + } + }; + tryFinalize(20); }; const switchXAxis = (mode: XAxisMode) => { if (mode === xAxisMode.value) { @@ -1140,7 +1179,6 @@ const switchXAxis = (mode: XAxisMode) => { // exactly the new set; hover/zoom state is intentionally reset. chartRefs.length = 0; group.charts = []; - activeSection.value = null; xAxisMode.value = mode; }; onMounted(() => { @@ -1448,7 +1486,7 @@ const deletePhoto = (photo: WorkoutPhoto) => { const resetChartZoom = () => { group.resetAll(); - activeSection.value = null; + selectedRange.value = null; areaAvgData.value = {}; markedCoordinats.value = []; };