скрыть начало и конец
Gitea Actions Demo / build_and_push (push) Successful in 1m18s Details

This commit is contained in:
artem 2026-09-25 09:18:48 +03:00
parent ad9e1a5bbf
commit e59523fd1c
8 changed files with 259 additions and 37 deletions

View File

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

View File

@ -25,7 +25,7 @@ yarn dev
### 2. Проверка публичных страниц (без auth)
| Рут | Что проверять |
|-----|---------------|
| ---------------------- | --------------------------------------------- |
| `/explore` | Feed загружен, карточки тренировок рендерятся |
| `/public/workouts/:id` | Детали тренировки: chart, map, данные |
| `/routes` | Список маршрутов |
@ -36,17 +36,23 @@ yarn dev
```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` | Профиль, настройки |
@ -55,7 +61,7 @@ yarn dev
### 4. Инструменты MCP (ключевые)
| Инструмент | Назначение |
|-----------|-----------|
| -------------------------- | ------------------------------------------- |
| `browser_navigate` | Перейти по URL |
| `browser_snapshot` | Получить accessibility tree (структура DOM) |
| `browser_take_screenshot` | Скриншот (PNG) |
@ -95,6 +101,7 @@ yarn dev
```markdown
### Acceptance Criteria
1. `yarn lint` → 0 ошибок
2. `yarn build` → 0 ошибок (vue-tsc --noEmit)
3. MCP browser: [конкретный сценарий проверки — рут, что ожидать в DOM]

View File

@ -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 для неизвестных страниц.

View File

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

View File

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

View File

@ -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<WorkoutPhoto>;

View File

@ -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({

View File

@ -0,0 +1,130 @@
import { ChartDataByMetric } from "../Definitions.vue";
export interface TrimResult {
lineCoordinates: Array<Array<number>>;
distances: Array<number>;
data: ChartDataByMetric;
}
/** Distance between two [lon, lat] coordinates in meters (sphere haversine). */
const haversine = (a: Array<number>, b: Array<number>): 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<Array<number>>,
distances: Array<number>,
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<Array<number>> = [];
// 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<number> = [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<keyof ChartDataByMetric>;
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,
};
}