fix html
Gitea Actions Demo / build_and_push (push) Successful in 58s Details

This commit is contained in:
artem 2026-09-27 06:52:55 +03:00
parent b17b67dc90
commit de2728ccb9
4 changed files with 118 additions and 17 deletions

1
.gitignore vendored
View File

@ -90,6 +90,7 @@ out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
dist-server
# Gatsby files # Gatsby files
.cache/ .cache/

View File

@ -2,12 +2,14 @@
## Task State ## Task State
- task_id: TASK-SECTION-DIST-FIX - task_id: TASK-SSR-EXPLORE-CARDS
- status: success - status: success
- summary: **Добавлена строка «Дистанция» в «Данные участка» + drag-zoom теперь персистится при смене вкладки (selectedRange вместо null в onZoomComplete). 3 passed.** - summary: **SSR `/explore` теперь рендерит карточки 1:1 с клиентом: `<section class="news-feed">` → `<article class="news-item">` (`.news-item-header`→`<h3><a>`, `ul.metadata` 9 условных `<li>` в порядке клиента, `.image-container`-`<a>` с background-image). Хэндлер + `distInt` в `server/index.ts`; добавлен отсутствовавший `tsconfig.server.json` (`yarn server:build` теперь работает).**
- next task: — - next task: —
- plan: `plans/TASK-SSR-EXPLORE-CARDS.md`
## ✅ CODER SUCCESS REPORT — TASK-INTEG-SECTION (+TASK-SECTION-BUGS) ## ✅ 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`). - `../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 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()` отбрасывает эти колбэки. - **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()` отбрасывает эти колбэки.
@ -20,11 +22,13 @@
**Scope:** 1 новый файл — `../integration/tests/section-duration.spec.ts` + запуск на стеке. Не трогать `src/`. **Scope:** 1 новый файл — `../integration/tests/section-duration.spec.ts` + запуск на стеке. Не трогать `src/`.
**Что тестируется (3 UX-требования, компонент `src/pages/workouts/components/WorkoutItem.vue`, страница `workout_item`):** **Что тестируется (3 UX-требования, компонент `src/pages/workouts/components/WorkoutItem.vue`, страница `workout_item`):**
1. Клик по графику (выделение участка) → в блоке `.area-data` появляется строка «Продолжительность». 1. Клик по графику (выделение участка) → в блоке `.area-data` появляется строка «Продолжительность».
2. Клик по вкладке «Дистанция» → тот же участок остаётся в zoom (не весь путь). 2. Клик по вкладке «Дистанция» → тот же участок остаётся в zoom (не весь путь).
3. Клик по reset → участок сброшен; после смена вкладки → весь путь (reset на всех вкладках). 3. Клик по reset → участок сброшен; после смена вкладки → весь путь (reset на всех вкладках).
**Контракты (проверено Архитектором):** **Контракты (проверено Архитектором):**
- Детальная страница (приватная, с графиками + вкладками): маршрут `workout_item` → URL `{FRONTEND}/workouts/workouts/{id}` (родитель `workouts` + вложенный `workouts/:id`). Навигация `waitUntil: "domcontentloaded"` (Yandex map держит соединения). - Детальная страница (приватная, с графиками + вкладками): маршрут `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]). - 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`). - Файл: `../../backend/tests/fitness_tracker_data_parsing-main/20240507053735.fit` (тот же, что в `workout-upload-flow.spec.ts`).
@ -34,6 +38,7 @@
- Кнопки вкладок: по тексту «Время» и «Дистанция» (VaButton). Reset: `#zoom-botton`. Блок данных участка: `.area-data` (заголовок «Данные участка»), строка «Продолжительность». - Кнопки вкладок: по тексту «Время» и «Дистанция» (VaButton). Reset: `#zoom-botton`. Блок данных участка: `.area-data` (заголовок «Данные участка»), строка «Продолжительность».
**Структура спе (self-contained, repeatable):** **Структура спе (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 из списка). - `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): - 3 независимых `test`'а (каждый: `authedPage(token)` → `openWorkoutDetail(id)` → `expect` canvas visible):
1. «duration row on section select»: canvas-click → `expect .area-data` visible → `expect` текст «Продолжительность» в `.area-data`. 1. «duration row on section select»: canvas-click → `expect .area-data` visible → `expect` текст «Продолжительность» в `.area-data`.
@ -41,12 +46,14 @@
3. «reset clears section on all tabs»: canvas-click (zoom) → assert zoomed → click `#zoom-botton` (reset) → assert NOT zoomed → click «Дистанция» → assert NOT zoomed (весь путь). 3. «reset clears section on all tabs»: canvas-click (zoom) → assert zoomed → click `#zoom-botton` (reset) → assert NOT zoomed → click «Дистанция» → assert NOT zoomed (весь путь).
**Замечания:** **Замечания:**
- Не `expect` внутри `page.evaluate` — только `return` данных наружу. - Не `expect` внутри `page.evaluate` — только `return` данных наружу.
- `authedPage`: goto → set `localStorage.token` → goto (паттерн из `workout-upload-flow.spec.ts`). - `authedPage`: goto → set `localStorage.token` → goto (паттерн из `workout-upload-flow.spec.ts`).
- Node ≥20: запуск через nvm `export PATH="$HOME/.nvm/versions/node/v22.14.0/bin:$PATH"`. - Node ≥20: запуск через nvm `export PATH="$HOME/.nvm/versions/node/v22.14.0/bin:$PATH"`.
- `yarn up` (`docker compose up -d --build`) → ждать backend: `until curl -sf http://localhost:8000/api/v0/html_test/login >/dev/null; do sleep 2; done` → `npx playwright test tests/section-duration.spec.ts`. - `yarn up` (`docker compose up -d --build`) → ждать backend: `until curl -sf http://localhost:8000/api/v0/html_test/login >/dev/null; do sleep 2; done` → `npx playwright test tests/section-duration.spec.ts`.
**Acceptance criteria:** **Acceptance criteria:**
1. `../integration/tests/section-duration.spec.ts` создан. 1. `../integration/tests/section-duration.spec.ts` создан.
2. `npx playwright test tests/section-duration.spec.ts` → **3 passed** на запущенном стеке. 2. `npx playwright test tests/section-duration.spec.ts` → **3 passed** на запущенном стеке.
3. Не сломать существующие спе (опционально: `npx playwright test --list`). 3. Не сломать существующие спе (опционально: `npx playwright test --list`).
@ -289,6 +296,19 @@
- `src/components/app-layout-navigation/AppLayoutNavigation.vue`: `const globalStore = useGlobalStore()`; `traverse(globalStore.isAuthenticated ? authRoutes : publicRoutes)` внутри `findRouteName` (вызывается в `items` computed → реactivity сохранена). - `src/components/app-layout-navigation/AppLayoutNavigation.vue`: `const globalStore = useGlobalStore()`; `traverse(globalStore.isAuthenticated ? authRoutes : publicRoutes)` внутри `findRouteName` (вызывается в `items` computed → реactivity сохранена).
- Verified: `yarn lint` exit 0, `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite ✓ 7.07s). - Verified: `yarn lint` exit 0, `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite ✓ 7.07s).
## ✅ CODER SUCCESS REPORT — TASK-SSR-EXPLORE-CARDS
- `server/index.ts`: хэндлер `GET /explore` (~строки 135–228) переписан — `content` теперь `<section class="news-feed">` с `<article class="news-item">` на каждый workout: `.news-item-header` → `<h3><a href="/public/workouts/{id}">`, `ul.metadata` с 9 условными `<li>` в порядке клиента (Дата, Фото, Скорость, Пульс, Расстояние, Продолжительность, Каденс, Мощность, Ссылки), `.image-container` — `<a>` c `background-image` (только если `w.attachment?.url`). Имя/ссылки экранированы `escapeHtml`. SEO-мета (`title`/`description`/`canonical`/`jsonLd`) и `catch → 500` сохранены без изменений.
- `server/index.ts`: новый форматтер `distInt(m) = Math.round(m/1000)` (~строка 52, после `formatSpeed`) — расстояние в ленте целыми км (совпадение с клиентским `distConvert`); `formatDistance` (toFixed(1)) не тронут — используется в `/` и detail.
- `server/index.ts`: импорт `WorkoutItem` из `./api` добавлен.
- **`tsconfig.server.json` создан (был отсутствует)** — `yarn server:build` (`tsc -p tsconfig.server.json`) до этого падал «config file missing»; теперь `commonjs`/`outDir: dist-server`/`strict`, как ожидал `server:start` (`node dist-server/index.js`).
- `.gitignore`: добавлен `dist-server` (артефакт server:build).
- Verified: `yarn server:build` → Done (0 ошибок); `yarn build` (vue-tsc --noEmit + vite) → ✓ built in 6.58s (0 ошибок).
- Verified HTML (SSR dev, реальный production-backend): `curl http://localhost:3001/explore` — 3× `news-item`, 3× `metadata`, 3× `image-container`, 1× `news-feed`, `<a href="/public/workouts/{id}"`, метки Дата/Фото/Скорость/Пульс/Расстояние/Продолжительность/Каденс/Мощность. «Ссылки» корректно отсутствует: у всех 3 workout'ов ленты `external_links.values` пустые (v-if — 1:1 с клиентом).
- Verified browser (MCP): `browser_navigate http://localhost:3001/explore` → snapshot: 3 `article` c `<h3>`-заголовками-ссылками на `/public/workouts/{id}` + `list` c «Дата: …»; title «Публичные велотренировки — Cycle Rider». Консоль: только 3 pre-existing 404 на статические ассеты (в dev SSR `dist/` не сервится — в prod это делает nginx; app-ошибок нет).
- Known (вне scope): `yarn lint` (только `./src/**/*`) — 73 pre-existing errors, src не трогался.
- Note: в процессе проверка `git stash`/`pop` столкнулась с конкурентным обновлением `activeContext.md` (TASK-SECTION-DIST-FIX); состояние восстановлено, конфликтных потерь нет.
## История (закрытые задачи) ## История (закрытые задачи)
- **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-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.)

View File

@ -1,5 +1,17 @@
# Progress — Strava Frontend # Progress — Strava Frontend
## 2026-09-26 — TASK-SECTION-REZOOM-LOOP: фикс бесконечного цикла re-render (CPU-шум)
- `src/pages/workouts/components/WorkoutItem.vue`:
- Новый флаг `let pendingRezoom = false;` (рядом с `chartRefs`, ~строка 526).
- `switchXAxis`: после `chartRefs.length = 0` добавлено `pendingRezoom = true`.
- `setChartRef` / `tryFinalize`: `applySelectedRangeZoom()` вызывается
только если `pendingRezoom === true` (затем флаг сбрасывается в false).
Устранён цикл: клик → areaAvgData → re-render → setChartRef →
applySelectedRangeZoom → broadcastZoom → update("none") →
reactivity → re-render → setChartRef → ... (бесконечно).
- Verified: `yarn build` exit 0; `npx playwright test tests/section-duration.spec.ts` → 3 passed (13.7s).
## 2026-09-26 — TASK-SECTION-DIST-FIX: строка «Дистанция» + drag-zoom персистится при смене вкладки ## 2026-09-26 — TASK-SECTION-DIST-FIX: строка «Дистанция» + drag-zoom персистится при смене вкладки
- `src/pages/workouts/components/WorkoutItem.vue`: - `src/pages/workouts/components/WorkoutItem.vue`:
@ -332,3 +344,13 @@
- `../backend/tests/test_api_workout.py`: добавлен `test_workout_hide_start_end` (PATCH hide_start_end False→True→False + GET-верификация) - `../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)** - Результат: `python -m pytest tests/test_api_workout.py -v` → **4 passed (5.13s)**
## TASK-SSR-EXPLORE-CARDS (2026-09-27)
- `server/index.ts`: хэндлер `GET /explore` — `content` = `<section class="news-feed">` c `<article class="news-item">` (`.news-item-header`→`<h3><a href="/public/workouts/{id}">`, `ul.metadata` 9 условных `<li>` в порядке клиента, `.image-container`-`<a>` c `background-image`); SEO-мета и catch→500 без изменений; импорт `WorkoutItem` из `./api`.
- `server/index.ts`: новый форматтер `distInt(m)=Math.round(m/1000)` (после `formatSpeed`, ~строка 52); `formatDistance` не тронут.
- `tsconfig.server.json` (новый файл): `module commonjs`, `outDir dist-server`, `strict`, `rootDir server`, `include server/**/*.ts` — скрипт `server:build` ссылался на него, файла не было.
- `.gitignore`: добавлена строка `dist-server`.
- Verified: `yarn server:build` → Done (0 ошибок); `yarn build` → ✓ built in 6.58s (0 ошибок, pre-existing >500kB chunk warning).
- Verified HTML: `curl http://localhost:3001/explore` (SSR dev, production backend) — `news-feed`×1, `news-item`×3, `metadata`×3, `image-container`×3, `<a href="/public/workouts/{id}"`, метки Дата/Фото/Скорость/Пульс/Расстояние/Продолжительность/Каденс/Мощность; «Ссылки» нет (у всех 3 workout'ов `external_links.values` пустые — корректно, v-if).
- Verified browser (MCP): 3 карточки (`article` c `<h3>`-ссылкой + `list` «Дата: …»), title SSR-заголовка; console — только 3 pre-existing 404 на статические ассеты (dev SSR не сервит `dist/`; в prod — nginx), app-ошибок нет.

View File

@ -1,5 +1,5 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import { getPublicWorkouts, getPublicWorkout } from "./api"; import { getPublicWorkouts, getPublicWorkout, WorkoutItem } from "./api";
import { renderTemplate, getAssetTags, SeoMeta } from "./template"; import { renderTemplate, getAssetTags, SeoMeta } from "./template";
const app = express(); const app = express();
@ -49,6 +49,10 @@ function formatSpeed(ms: number): string {
return Math.round(ms * 3.6).toString(); return Math.round(ms * 3.6).toString();
} }
function distInt(m: number): string {
return Math.round(m / 1000).toString();
}
function getToken(req: Request): string | null { function getToken(req: Request): string | null {
const cookieHeader = req.headers.cookie; const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null; if (!cookieHeader) return null;
@ -132,24 +136,78 @@ app.get("/explore", async (_req: Request, res: Response) => {
try { try {
const workouts = await getPublicWorkouts(); const workouts = await getPublicWorkouts();
const list = workouts const list = workouts
.map( .map((w: WorkoutItem) => {
(w) => const meta: string[] = [];
` <li>\n <a href="/public/workouts/${w.id}">${escapeHtml( if (w.workouted_at) {
w.name, meta.push(
)}</a>\n — ${formatDate(w.workouted_at)}, ${formatDistance( ` <li><span>Дата:</span> ${formatDate(w.workouted_at)}</li>`,
w.distantion, );
)} км, ${formatSpeed(w.speed)} км/ч, ${ }
w.heart_rate if (w.photos && w.photos.length > 0) {
} уд/мин\n </li>`, meta.push(` <li><span>Фото:</span> ${w.photos.length}</li>`);
) }
if (w.speed) {
meta.push(
` <li><span>Скорость:</span> ${formatSpeed(
w.speed,
)} км/ч </li>`,
);
}
if (w.heart_rate) {
meta.push(
` <li><span>Пульс:</span> ${w.heart_rate} уд /мин </li>`,
);
}
if (w.distantion) {
meta.push(
` <li><span>Расстояние:</span> ${distInt(
w.distantion,
)} км </li>`,
);
}
if (w.duraion_sec) {
meta.push(
` <li><span>Продолжительность:</span> ${formatDuration(
w.duraion_sec,
)}</li>`,
);
}
if (w.cadence) {
meta.push(` <li><span>Каденс:</span> ${w.cadence} об/мин </li>`);
}
if (w.power) {
meta.push(
` <li><span>Мощность:</span> ${Math.round(w.power)} Вт </li>`,
);
}
if (w.external_links && w.external_links.values.length > 0) {
meta.push(
` <li><span>Ссылки:</span> <a href="${escapeHtml(
w.external_links.values[0].value,
)}" target="_blank">Дзен</a></li>`,
);
}
const imageUrl = w.attachment?.url;
const image = imageUrl
? `\n <a class="image-container" href="/public/workouts/${
w.id
}" style="background-image:url(${escapeHtml(imageUrl)})"></a>`
: "";
return ` <article class="news-item">
<div class="news-item-header"><h3><a href="/public/workouts/${
w.id
}">${escapeHtml(w.name)}</a></h3></div>
<ul class="metadata">
${meta.join("\n")}
</ul>${image}
</article>`;
})
.join("\n"); .join("\n");
const content = ` const content = `
<h1>Публичные тренировки</h1> <section class="news-feed">
<p>Лента велотренировок от сообщества.</p>
<ul>
${list} ${list}
</ul>`; </section>`;
const meta: SeoMeta = { const meta: SeoMeta = {
title: "Публичные велотренировки — Cycle Rider", title: "Публичные велотренировки — Cycle Rider",