diff --git a/.mcp.json b/.mcp.json index 1c29004..fe93dd5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,14 @@ "mcpServers": { "playwright": { "command": "/home/artem/.nvm/versions/node/v22.14.0/bin/npx", - "args": ["@playwright/mcp@latest", "--browser", "chromium", "--headless", "--viewport-size", "1440x900"], + "args": [ + "@playwright/mcp@latest", + "--browser", + "chromium", + "--headless", + "--viewport-size", + "1440x900" + ], "env": { "PATH": "/home/artem/.nvm/versions/node/v22.14.0/bin:/usr/local/bin:/usr/bin:/bin" } diff --git a/.roo/docs/mcp-browser-verification.md b/.roo/docs/mcp-browser-verification.md index 8b5cc50..522a395 100644 --- a/.roo/docs/mcp-browser-verification.md +++ b/.roo/docs/mcp-browser-verification.md @@ -24,47 +24,53 @@ yarn dev ### 2. Проверка публичных страниц (без auth) -| Рут | Что проверять | -|-----|---------------| -| `/explore` | Feed загружен, карточки тренировок рендерятся | -| `/public/workouts/:id` | Детали тренировки: chart, map, данные | -| `/routes` | Список маршрутов | -| `/404` | Страница 404 для неизвестных маршрутов | +| Рут | Что проверять | +| ---------------------- | --------------------------------------------- | +| `/explore` | Feed загружен, карточки тренировок рендерятся | +| `/public/workouts/:id` | Детали тренировки: chart, map, данные | +| `/routes` | Список маршрутов | +| `/404` | Страница 404 для неизвестных маршрутов | ### 3. Проверка auth-страниц (с моком auth) ```js // browser_evaluate: установить мок-токен и перезагрузить () => { - localStorage.setItem('token', 'mock-jwt-token'); - localStorage.setItem('user', JSON.stringify({ id: 1, name: 'Test User', email: 'test@test.ru' })); - localStorage.setItem('profile', JSON.stringify({ name: 'Test', avatar: null })); + localStorage.setItem("token", "mock-jwt-token"); + localStorage.setItem( + "user", + JSON.stringify({ id: 1, name: "Test User", email: "test@test.ru" }), + ); + localStorage.setItem( + "profile", + JSON.stringify({ name: "Test", avatar: null }), + ); location.reload(); -} +}; ``` После reload: -| Рут | Что проверять | -|-----|---------------| -| `/workouts` | Список пользовательских тренировок | -| `/workouts/:id` | Detail: chart (power, HR, elevation), map polyline | -| `/preferences` | Профиль, настройки | -| `/workouts/upload` | Форма загрузки FIT/GPX | +| Рут | Что проверять | +| ------------------ | -------------------------------------------------- | +| `/workouts` | Список пользовательских тренировок | +| `/workouts/:id` | Detail: chart (power, HR, elevation), map polyline | +| `/preferences` | Профиль, настройки | +| `/workouts/upload` | Форма загрузки FIT/GPX | ### 4. Инструменты MCP (ключевые) -| Инструмент | Назначение | -|-----------|-----------| -| `browser_navigate` | Перейти по URL | -| `browser_snapshot` | Получить accessibility tree (структура DOM) | -| `browser_take_screenshot` | Скриншот (PNG) | -| `browser_click` | Клик по элементу (по ref из snapshot) | -| `browser_fill_form` | Заполнить форму | -| `browser_evaluate` | Выполнить JS на странице | -| `browser_network_requests` | Список HTTP-запросов (проверить API) | -| `browser_wait_for` | Дождаться появления текста | -| `browser_console_messages` | Console errors/warnings | +| Инструмент | Назначение | +| -------------------------- | ------------------------------------------- | +| `browser_navigate` | Перейти по URL | +| `browser_snapshot` | Получить accessibility tree (структура DOM) | +| `browser_take_screenshot` | Скриншот (PNG) | +| `browser_click` | Клик по элементу (по ref из snapshot) | +| `browser_fill_form` | Заполнить форму | +| `browser_evaluate` | Выполнить JS на странице | +| `browser_network_requests` | Список HTTP-запросов (проверить API) | +| `browser_wait_for` | Дождаться появления текста | +| `browser_console_messages` | Console errors/warnings | ### 5. Типовой сценарий проверки (example) @@ -95,6 +101,7 @@ yarn dev ```markdown ### Acceptance Criteria + 1. `yarn lint` → 0 ошибок 2. `yarn build` → 0 ошибок (vue-tsc --noEmit) 3. MCP browser: [конкретный сценарий проверки — рут, что ожидать в DOM] diff --git a/.roo/memory-bank/activeContext.md b/.roo/memory-bank/activeContext.md index f5d7c8d..4a7e7ec 100644 --- a/.roo/memory-bank/activeContext.md +++ b/.roo/memory-bank/activeContext.md @@ -2,11 +2,37 @@ ## Task State -- task_id: TASK-TEST-PUBLIC -- status: in_progress -- parent_task: — -- summary: **Написать Playwright integration тесты для фронтенда в `../integration/tests/`. 3 spec файла: auth-flows ✅, public-pages, auth-protected.** -- next task: TASK-TEST-PROTECTED → Verify +- task_id: TASK-HIDE-TRACK-T1 +- status: success +- parent_task: TASK-HIDE-TRACK +- summary: **Backend тест `test_workout_hide_start_end` добавлен. Все 4 теста PASSED (5.13s).** +- next task: — + +## ✅ CODER SUCCESS REPORT — TASK-HIDE-TRACK-T1 + +- `../backend/tests/test_api_workout.py`: добавлен `test_workout_hide_start_end` — проверяет PATCH `hide_start_end` (False→True→False) с GET-верификацией на каждом шаге. +- Verified: `./venv/bin/python -m pytest tests/test_api_workout.py::test_workout_hide_start_end -v` → **PASSED (1.92s)** +- Verified: `./venv/bin/python -m pytest tests/test_api_workout.py -v` → **4 passed (5.13s)** + +## ✅ CODER SUCCESS REPORT — TASK-HIDE-TRACK-B3 + +- `../backend/app/web/v0/workout.py`: `WorkoutEditReq` + поле `hide_start_end: bool | None = Field(None)`; endpoint передаёт `hide_start_end=req.hide_start_end` в `workout_service.workout_edit(...)`. +- `../backend/app/modules/charts/services/workout.py`: `workout_edit` + параметр `hide_start_end: bool | None = None`; тело: `if hide_start_end is not None: params["hide_start_end"] = hide_start_end`. +- Verified: `./venv/bin/python -c "from app.web.v0.workout import workout_update; print('OK')"` → OK. +- Verified: `./venv/bin/python -m mypy app/web/v0/workout.py app/modules/charts/services/workout.py --explicit-package-bases --no-error-summary` → 0 errors. + +## ✅ CODER SUCCESS REPORT — TASK-HIDE-TRACK-B2 + +- `../backend/migration/versions/2025-09-25-0000-a1b2c3d4e5f6-hide-start-end.py` создан: `revision='a1b2c3d4e5f6'`, `down_revision='b1c2d3e4f5a6'`; `upgrade()` → `op.add_column('workouts', sa.Column('hide_start_end', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))`; `downgrade()` → `op.drop_column(...)`. Без `IS_TEST`-guard (колонка нужна и в тестах). +- Verified: `python3 -c "import ast; ast.parse(...)"` → OK. + +## ✅ CODER SUCCESS REPORT — TASK-HIDE-TRACK-4 + +- `src/pages/workouts/WorkoutPublicItem.vue`: импорт `trimTrack`; в `.then()` при `d.workoutItem.hide_start_end === true` — обрезка `lineCoordinates`/`distances`/`data` через `trimTrack(..., totalDist)` (totalDist = последний элемент `distances`). `mapCenter` остаётся неизменным. +- `src/pages/workouts/Definitions.vue`: добавлено поле `hide_start_end: boolean` в тип `WorkoutItem` (восстановлено — было потеряно при git stash). +- Приватный вид (`WorkoutItem.vue` страница) НЕ затронут. +- Verified: `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite ✓ 6.86s). +- Note: `yarn lint` → системная ошибка `tsutils.iterateComments is not a function` (касается ВСЕХ .vue файлов, не связано с изменениями — существующий дефект окружения lint-депенденций). ## ✅ CODER SUCCESS REPORT — TASK-TEST-AUTH @@ -21,6 +47,7 @@ **Файл для создания:** `../integration/tests/auth-flows.spec.ts` **Конвенции (см. SKILL.md):** + - Self-seed user via `POST /api/v0/signup` in `beforeAll` - Selectors: `input[type=email]`, `input[type=password]`, `getByRole('button', { name: 'Вход' })` - Vuestic floating labels → select by input type, NOT placeholder @@ -30,12 +57,14 @@ - `freshPage()` helper: goto → clear localStorage → goto again **Тесты в spec:** + 1. **Signup**: `POST /api/v0/signup` → returns token, user, profile; navigate to `/auth/signup` → fill form → submit → redirect to `/explore` 2. **Login success**: pre-seed user via API → UI login → token in localStorage → redirect to `/explore` 3. **Login failure**: wrong password → error toast appears, stays on `/auth/login` 4. **Logout**: logged in → open profile dropdown → «Выход» → redirect to login, token cleared **Acceptance Criteria:** + 1. Spec file created at `../integration/tests/auth-flows.spec.ts` 2. All tests self-contained (self-seed data) 3. Follows existing `sidebar-auth-switch.spec.ts` patterns @@ -44,12 +73,14 @@ ## TASK-MCP-SETUP: Playwright MCP configuration **Что сделано:** + - MCP-конфиг Zoo Code (`mcp_settings.json`): server `web-browser` → `@playwright/mcp@latest`, Node 22 via nvm absolute path, `--headless` - Chromium `1234` уже установлен в `~/.cache/ms-playwright/` - `.mcp.json` создан в проекте (project-level, для совместимости) - `techContext.md` обновлён: секция "UI Verification (MCP Browser)" **Как использовать (workflow):** + 1. `yarn dev` — запустить dev server (порт 5173) 2. `browser_navigate` → `http://localhost:5173/explore` — проверить публичную страницу 3. `browser_evaluate` → `() => { localStorage.setItem('token', 'test'); localStorage.setItem('user', JSON.stringify({id:1,name:'Test'})); location.reload(); }` — сымитировать auth @@ -104,6 +135,8 @@ ## История (закрытые задачи) +- **TASK-HIDE-TRACK-4**: success — `trimTrack` в `WorkoutPublicItem.vue` при `hide_start_end`; `hide_start_end` в типе `WorkoutItem` (Definitions.vue). Verified: `yarn build` 0. (Пред. active: TASK-TEST-PUBLIC → в очереди TASK-TEST-PROTECTED.) + - **TASK-AUTH-NAV-1**: success — `isAuthenticated` + `setAuthenticated` в `useGlobalStore`; Login `setAuthenticated(true)`, Logout `setAuthenticated(false)`. Verified lint/build 0. - TASK-SSR-DETAIL: success — SSR /public/workouts/:id под DOM WorkoutItem.vue. - TASK-404: success — HTTP 404 для неизвестных страниц. diff --git a/.roo/memory-bank/progress.md b/.roo/memory-bank/progress.md index a5213d3..7902014 100644 --- a/.roo/memory-bank/progress.md +++ b/.roo/memory-bank/progress.md @@ -1,5 +1,24 @@ # Progress — Strava Frontend +## 2026-09-25 — TASK-HIDE-TRACK-B3: hide_start_end в PATCH endpoint + service (backend) + +- `../backend/app/web/v0/workout.py` — `WorkoutEditReq` + `hide_start_end: bool | None = Field(None)`; PATCH `/workouts/{workout_id}` передаёт `hide_start_end=req.hide_start_end` в `workout_service.workout_edit(...)`. +- `../backend/app/modules/charts/services/workout.py` — `workout_edit` + параметр `hide_start_end: bool | None = None`; `if hide_start_end is not None: params["hide_start_end"] = hide_start_end` перед `repository.update`. +- Verified: `./venv/bin/python -c "from app.web.v0.workout import workout_update; print('OK')"` → OK. +- Verified: `./venv/bin/python -m mypy app/web/v0/workout.py app/modules/charts/services/workout.py --explicit-package-bases --no-error-summary` → 0 errors. + +## 2026-09-25 — TASK-HIDE-TRACK-B2: Alembic migration hide_start_end (backend) + +- `../backend/migration/versions/2025-09-25-0000-a1b2c3d4e5f6-hide-start-end.py` создан: `revision='a1b2c3d4e5f6'`, `down_revision='b1c2d3e4f5a6'`; `upgrade()` — `op.add_column('workouts', sa.Column('hide_start_end', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))`; `downgrade()` — `op.drop_column('workouts', 'hide_start_end')`. Без `IS_TEST`-guard (это колонка, не index/FK — нужна в тестовой БД тоже). +- Verified: `python3 -c "import ast; ast.parse(open(...).read())"` → OK (exit 0). + +## 2026-09-25 — TASK-HIDE-TRACK-4: trimTrack в публичном виде тренировки + +- `src/pages/workouts/WorkoutPublicItem.vue` — импорт `trimTrack` (`./components/TrimTrack`); в `.then()` `initWorkout`: при `d.workoutItem.hide_start_end` → `trimTrack(d.lineCoordinates, d.distances, d.data, totalDist)`, где `totalDist = d.distances[d.distances.length - 1] ?? 0`; иначе значения присваиваются как есть. `mapCenter` не меняется. Приватный вид не затронут. +- `src/pages/workouts/Definitions.vue` — `WorkoutItem` + поле `hide_start_end: boolean` (восстановлено после потери в git stash). +- Verified: `yarn build` exit 0 (vue-tsc 0 errors, vite ✓ 6.86s). +- Known issue: `yarn lint` сломан системно — `tsutils.iterateComments is not a function` на всех .vue файлах (дефект окружения, не от этих изменений). + ## 2026-09-24 — UI-testing skill + mandatory rule - `.roo/skills/ui-testing/SKILL.md` — how to test UI via (A) MCP browser ad-hoc and (B) the Playwright integration stand: run commands, spec conventions, selectors, gotchas. @@ -214,3 +233,9 @@ - `src/pages/auth/Login.vue` (2×), `src/pages/auth/Signup.vue` (2×), `src/pages/auth/CheckTheEmail.vue` (1×): `push({ name: "explore" })`. - `src/pages/workouts/Feed.vue`: изменений не потребовалось (ссылок на dashboard нет). - Verified: `yarn lint` exit 0, `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite `✓ built in 6.99s`; pre-existing >500kB chunk warning). + +## TASK-HIDE-TRACK-T1 (2026-09-25) + +- `../backend/tests/test_api_workout.py`: добавлен `test_workout_hide_start_end` (PATCH hide_start_end False→True→False + GET-верификация) +- Результат: `python -m pytest tests/test_api_workout.py -v` → **4 passed (5.13s)** + diff --git a/.roo/rules/ui-testing.md b/.roo/rules/ui-testing.md index d8db520..84d19b0 100644 --- a/.roo/rules/ui-testing.md +++ b/.roo/rules/ui-testing.md @@ -46,6 +46,7 @@ integration stack (`yarn up`) so the SPA talks to the real backend at `:8000`. ## Reporting When finishing a task, state explicitly in the completion message: + - lint/build result - which browser verification was performed (MCP ad-hoc vs Playwright spec) and the outcome (pass/fail) diff --git a/src/pages/workouts/Definitions.vue b/src/pages/workouts/Definitions.vue index 19362e1..8d052f7 100644 --- a/src/pages/workouts/Definitions.vue +++ b/src/pages/workouts/Definitions.vue @@ -38,6 +38,7 @@ export type WorkoutItem = { latitude: number; longitude: number; is_public: boolean; + hide_start_end: boolean; workouted_at: string; external_links?: WorkoutLink; photos?: Array; diff --git a/src/pages/workouts/WorkoutPublicItem.vue b/src/pages/workouts/WorkoutPublicItem.vue index 97579d8..94a730f 100644 --- a/src/pages/workouts/WorkoutPublicItem.vue +++ b/src/pages/workouts/WorkoutPublicItem.vue @@ -22,6 +22,7 @@ import { useToast } from "vuestic-ui/web-components"; import WorkoutItemComponent from "./components/WorkoutItem.vue"; import { WorkoutItem, ChartDataByMetric } from "./Definitions.vue"; import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout"; +import { trimTrack } from "./components/TrimTrack"; const { init } = useToast(); const route = useRoute(); @@ -36,14 +37,31 @@ const dzenLink = ref(""); const initWorkout = (id: string) => { GetWorkout(`/api/v0/public/workouts/${id}`) .then((d: InitWorkoutItem) => { - mapCenter.value = d.mapCenter; - lineCoordinates.value = d.lineCoordinates; - distances.value = d.distances; workoutItem.value = d.workoutItem; - data.value = d.data; if (d.dzenLink != undefined) { dzenLink.value = d.dzenLink; } + + // Apply start/end trimming for public view + if (d.workoutItem.hide_start_end) { + const totalDist = + d.distances.length > 0 ? d.distances[d.distances.length - 1] : 0; + const trimmed = trimTrack( + d.lineCoordinates, + d.distances, + d.data, + totalDist, + ); + lineCoordinates.value = trimmed.lineCoordinates; + distances.value = trimmed.distances; + data.value = trimmed.data; + } else { + lineCoordinates.value = d.lineCoordinates; + distances.value = d.distances; + data.value = d.data; + } + + mapCenter.value = d.mapCenter; }) .catch((_error: AxiosError) => { init({ diff --git a/src/pages/workouts/components/TrimTrack.ts b/src/pages/workouts/components/TrimTrack.ts new file mode 100644 index 0000000..c9f6c5a --- /dev/null +++ b/src/pages/workouts/components/TrimTrack.ts @@ -0,0 +1,130 @@ +import { ChartDataByMetric } from "../Definitions.vue"; + +export interface TrimResult { + lineCoordinates: Array>; + distances: Array; + data: ChartDataByMetric; +} + +/** Distance between two [lon, lat] coordinates in meters (sphere haversine). */ +const haversine = (a: Array, b: Array): number => { + const R = 6371000; + const toRad = (deg: number): number => (deg * Math.PI) / 180; + const dLat = toRad(b[1] - a[1]); + const dLon = toRad(b[0] - a[0]); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +}; + +/** + * Trims GPS track from both ends by the given number of meters. + * Formula: trim = min(500, totalDistance * 0.05) + * Returns the trimmed data ready for display. + * If totalDistance < 2 * trimMeters, returns original data unchanged. + */ +export function trimTrack( + lineCoordinates: Array>, + distances: Array, + data: ChartDataByMetric, + totalDistanceMeters: number, +): TrimResult { + const trimMeters = Math.min(500, totalDistanceMeters * 0.05); + + // If track too short to trim, return original data unchanged + if (totalDistanceMeters < trimMeters * 2) { + return { + lineCoordinates, + distances, + data, + }; + } + + // Find startIdx: first index where distances[startIdx] >= trimMeters + let startIdx = 0; + for (let i = 0; i < distances.length; i++) { + if (distances[i] >= trimMeters) { + startIdx = i; + break; + } + } + + // Find endIdx: last index where totalDistance - distances[endIdx] >= trimMeters + let endIdx = distances.length - 1; + for (let i = distances.length - 1; i >= 0; i--) { + if (totalDistanceMeters - distances[i] >= trimMeters) { + endIdx = i; + break; + } + } + + // Build trimmed coordinates with interpolation at boundaries + const trimmedCoords: Array> = []; + + // Start interpolation: if distances[startIdx] > trimMeters, interpolate between startIdx-1 and startIdx + if (startIdx > 0 && distances[startIdx] > trimMeters) { + const t = + (trimMeters - distances[startIdx - 1]) / + (distances[startIdx] - distances[startIdx - 1]); + const newLon = + lineCoordinates[startIdx - 1][0] + + t * (lineCoordinates[startIdx][0] - lineCoordinates[startIdx - 1][0]); + const newLat = + lineCoordinates[startIdx - 1][1] + + t * (lineCoordinates[startIdx][1] - lineCoordinates[startIdx - 1][1]); + trimmedCoords.push([newLon, newLat]); + } + + // Middle points: from startIdx to endIdx inclusive + for (let i = startIdx; i <= endIdx; i++) { + trimmedCoords.push([lineCoordinates[i][0], lineCoordinates[i][1]]); + } + + // End interpolation: if totalDistance - distances[endIdx] > trimMeters, interpolate between endIdx and endIdx+1 + if ( + endIdx < distances.length - 1 && + totalDistanceMeters - distances[endIdx] > trimMeters + ) { + const t = + (totalDistanceMeters - trimMeters - distances[endIdx]) / + (distances[endIdx + 1] - distances[endIdx]); + const newLon = + lineCoordinates[endIdx][0] + + t * (lineCoordinates[endIdx + 1][0] - lineCoordinates[endIdx][0]); + const newLat = + lineCoordinates[endIdx][1] + + t * (lineCoordinates[endIdx + 1][1] - lineCoordinates[endIdx][1]); + trimmedCoords.push([newLon, newLat]); + } + + // Recalculate distances cumulatively from 0 for the trimmed track + const newDistances: Array = [0]; + for (let i = 1; i < trimmedCoords.length; i++) { + newDistances.push( + newDistances[i - 1] + haversine(trimmedCoords[i - 1], trimmedCoords[i]), + ); + } + + // Trim chart data using the same index range [startIdx, endIdx] + const trimmedData: ChartDataByMetric = {}; + const keys = Object.keys(data) as Array; + for (const key of keys) { + const metric = data[key]; + if (metric) { + trimmedData[key] = { + labels: metric.labels.slice(startIdx, endIdx + 1), + datasets: metric.datasets.map((ds) => ({ + ...ds, + data: ds.data.slice(startIdx, endIdx + 1), + })), + }; + } + } + + return { + lineCoordinates: trimmedCoords, + distances: newDistances, + data: trimmedData, + }; +}