Compare commits

...

2 Commits

Author SHA1 Message Date
artem 84cd573695 ssr render
Gitea Actions Demo / build_and_push (push) Successful in 57s Details
2026-09-21 16:35:46 +03:00
artem 87b6f3cca2 doc 2026-09-21 13:11:19 +03:00
3 changed files with 136 additions and 18 deletions

View File

@ -2,12 +2,18 @@
## Task State ## Task State
- task_id: TASK-404 - task_id: TASK-SSR-DETAIL
- status: success - status: success
- parent_task: — - parent_task: TASK-SSR-2
- summary: **404 для несуществующих URL — SSR + nginx + frontend router.** - summary: **SSR /public/workouts/:id — HTML-контент переписан под DOM-структуру WorkoutItem.vue (вёрстка + фото + OG:image + noscript).**
- next task: — (no active tasks) - next task: — (no active tasks)
### TASK-F12/F13/F14 — фиксы карты тренировки (WorkoutItem.vue)
- Ф12: `position="top-center left-center"` (центрирование маркера на координате); `findNearestTrackIndex` (радиус 40 м) вместо точного lookup `getKey`/`coordWithIndex`.
- Ф13: стабильные `:settings` фото-маркеров через Map-кэш (`getPhotoMarkerSettings`) — одного этого оказалось НЕДОСТАТОЧНО (Vue патчит VNode-ы и при неизменённых пропсах).
- Ф14: `v-memo="[photo.id]"` на фото-маркерах — жёсткий пропуск патча, `onUpdated`/`clearElement()` больше не срабатывают. Методология и причины записаны в `systemPatterns.md` (секция «Charts & maps»).
## TASK-404: HTTP 404 для несуществующих страниц (SEO) ## TASK-404: HTTP 404 для несуществующих страниц (SEO)
### Проблема ### Проблема

View File

@ -66,6 +66,8 @@ Dependency direction: `pages -> components/stores/services`. Pages own business
- Yandex Maps via `vue-yandex-maps` (`createYmaps` with a hardcoded API key in `main.ts`). Route polyline drawn from workout `results` coordinates. - Yandex Maps via `vue-yandex-maps` (`createYmaps` with a hardcoded API key in `main.ts`). Route polyline drawn from workout `results` coordinates.
- **Typing contract (since vue 3.5 strict checks)**: `vue-yandex-maps` `:settings` coordinates must be the `LngLat = [lon, lat, alt?]` tuple type — plain `number[]` refs fail `vue-tsc`. Pattern: `ref<LngLat>([lon, lat])` + `as LngLat` casts in templates (see `pages/routes/Route.vue`, `pages/workouts/components/WorkoutItem.vue`). - **Typing contract (since vue 3.5 strict checks)**: `vue-yandex-maps` `:settings` coordinates must be the `LngLat = [lon, lat, alt?]` tuple type — plain `number[]` refs fail `vue-tsc`. Pattern: `ref<LngLat>([lon, lat])` + `as LngLat` casts in templates (see `pages/routes/Route.vue`, `pages/workouts/components/WorkoutItem.vue`).
- Custom chart controllers extend `chart.js` controllers (`LineWithLineController` in `LineWithLineChart.ts`); the map-sync plugin is read from `chart.config.plugins` by id — use non-null assertions on `chart.config.data!` (do NOT reintroduce `@ts-ignore`, lint bans it). - Custom chart controllers extend `chart.js` controllers (`LineWithLineController` in `LineWithLineChart.ts`); the map-sync plugin is read from `chart.config.plugins` by id — use non-null assertions on `chart.config.data!` (do NOT reintroduce `@ts-ignore`, lint bans it).
- **Custom markers inside `YandexMapClusterer` disappear after any parent re-render** (e.g. selecting/clearing a track section). Root cause: a change of any reactive ref used in the template (like `clickCoordinates`, `mapX`) re-renders the template -> Vue patches every child `YandexMapMarker` VNode -> the component's `onUpdated` hook (vue-yandex-maps dist, `YandexMapMarker` setup, `clearElement()`) removes the marker DOM node, because at that moment the node is owned by the clusterer and no longer sits inside `<ymaps>` -> the clusterer's next render reuses the SAME `YMapMarker` entity (matched by id) and never recreates its element -> the single marker stays invisible until page reload. Clusters ("2+") are immune: `YandexMapClustererCluster.updateElement()` imperatively removes and recreates its children on every update. **Fix (mandatory for any `v-for` marker inside a clusterer):** (1) `v-memo="[item.id]"` on the marker element — Vue skips the VNode patch when memo deps are unchanged, so `onUpdated` never fires; (2) `:settings` from a stable per-id cache (`Map<string, { coordinates, onClick }>` built in setup) — a fresh inline `:settings="{...}"` is a new object reference each render and also trips the component's deep settings watcher -> `entity.update()` racing the clusterer. Reference: `pages/workouts/components/WorkoutItem.vue` (photo markers, TASK-F12/F13/F14). Side note: a custom marker `position` prop is a transform — `"bottom-center"` = `translate(0%, 50%)` shifts the badge DOWN from the GPS point; use `"top-center left-center"` = `translate(-50%, -50%)` to center the content exactly on the coordinates.
- **Map track-point click: never match a raw click to track nodes by exact key** (e.g. rounded-to-3-decimals `Map` lookup — a ~100 m grid a click almost never hits); the "reset on miss" fallback then fires on every click and the selected section markers vanish. Use nearest-point search with a radius (`findNearestTrackIndex`, equirectangular: `dx = Δlng * 111320 * cos(midLatRad)`, `dy = Δlat * 110540`, radius ~40 m) and push the REAL track point (`lineCoordinates[i]`) into `clickCoordinates`, not the raw click coords (snap). Reset stays only for a genuine miss (beyond radius) or the second click (toggle).
## Styling ## Styling

View File

@ -20,7 +20,11 @@ function getAssetTagsSafe(): string {
function escapeHtml(text: string | null | undefined): string { function escapeHtml(text: string | null | undefined): string {
if (!text) return ""; if (!text) return "";
return text.replace(/</g, "<").replace(/>/g, ">"); return text
.replace(/&/g, String.fromCharCode(38) + "amp;")
.replace(/</g, String.fromCharCode(38) + "lt;")
.replace(/>/g, String.fromCharCode(38) + "gt;")
.replace(/"/g, String.fromCharCode(38) + "quot;");
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@ -183,21 +187,126 @@ app.get("/public/workouts/:id", async (req: Request, res: Response) => {
return; return;
} }
// --- Build params section ---
const params: string[] = [];
if (w.workouted_at) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Дата:</div><div class="workout-item-params-value">${formatDate(
w.workouted_at,
)}</div></div>`,
);
}
if (w.distantion) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Расстояние:</div><div class="workout-item-params-value">${formatDistance(
w.distantion,
)} км</div></div>`,
);
}
if (w.heart_rate) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средний пульс:</div><div class="workout-item-params-value">${Math.floor(
w.heart_rate,
)} уд./ мин.</div></div>`,
);
}
if (w.max_heart_rate) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальный пульс:</div><div class="workout-item-params-value">${Math.floor(
w.max_heart_rate,
)} уд./ мин.</div></div>`,
);
}
if (w.speed) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средняя скорость:</div><div class="workout-item-params-value">${formatSpeed(
w.speed,
)} км / ч</div></div>`,
);
}
if (w.max_speed) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальная скорость:</div><div class="workout-item-params-value">${formatSpeed(
w.max_speed,
)} км / ч</div></div>`,
);
}
if (w.temperature) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Температура:</div><div class="workout-item-params-value">${w.temperature} °C</div></div>`,
);
}
if (w.power) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средняя мощность:</div><div class="workout-item-params-value">${Math.floor(
w.power,
)} Вт</div></div>`,
);
}
if (w.max_power) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальная мощность:</div><div class="workout-item-params-value">${Math.floor(
w.max_power,
)} Вт</div></div>`,
);
}
if (w.cadence) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Каденс:</div><div class="workout-item-params-value">${w.cadence} об/мин</div></div>`,
);
}
if (w.duraion_sec) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Длительность:</div><div class="workout-item-params-value">${formatDuration(
w.duraion_sec,
)}</div></div>`,
);
}
if (
w.external_links &&
w.external_links.values &&
w.external_links.values.length > 0
) {
const dzenUrl = w.external_links.values[0].value;
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Ссылки на описание:</div><div><a href="${escapeHtml(
dzenUrl,
)}" target="_blank">Дзен</a></div></div>`,
);
}
// --- Build photos section ---
let photosHtml = "";
if (w.photos && w.photos.length > 0) {
const photoItems = w.photos
.map(
(p: { id: string; url: string }) =>
` <div class="workout-photo"><img src="${escapeHtml(
p.url,
)}" alt="${escapeHtml(p.id)}" /></div>`,
)
.join("\n");
photosHtml = `
<div id="workout-photos">
<div class="workout-photos-grid">
${photoItems}
</div>
</div>`;
}
const content = ` const content = `
<h1>${escapeHtml(w.name)}</h1> <div id="workout-container">
<p>${escapeHtml(w.description)}</p> <div id="workout-map" style="height:350px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#999;font-family:sans-serif;">Карта (загрузится после инициализации JS)</div>
<ul class="workout-metrics"> <div id="workout-short-data">
<li>Дата: ${formatDate(w.workouted_at)}</li> <div class="workout-item-editable-title"><h3>${escapeHtml(
<li>Расстояние: ${formatDistance(w.distantion)} км</li> w.name,
<li>Средняя скорость: ${formatSpeed(w.speed)} км/ч</li> )}</h3></div>
<li>Макс. скорость: ${formatSpeed(w.max_speed)} км/ч</li> ${params.join("\n")}
<li>Средний пульс: ${w.heart_rate} уд/мин</li> </div>
<li>Макс. пульс: ${w.max_heart_rate} уд/мин</li> </div>${photosHtml}
<li>Средняя мощность: ${w.power} Вт</li> <div id="workout-charts" style="margin-top:20px;padding:20px;background:#f8f9fa;border-radius:6px;color:#999;text-align:center;font-family:sans-serif;">Графики (загрузятся после инициализации JS)</div>
<li>Макс. мощность: ${w.max_power} Вт</li> <noscript><p>Для полноценного просмотра тренировки (карта, графики) включите JavaScript.</p></noscript>`;
<li>Каденс: ${w.cadence} об/мин</li>
<li>Длительность: ${formatDuration(w.duraion_sec)}</li>
</ul>`;
const desc = w.description const desc = w.description
? w.description.slice(0, 150) + (w.description.length > 150 ? "…" : "") ? w.description.slice(0, 150) + (w.description.length > 150 ? "…" : "")
@ -210,6 +319,7 @@ app.get("/public/workouts/:id", async (req: Request, res: Response) => {
title: `${w.name} — Cycle Rider`, title: `${w.name} — Cycle Rider`,
description: `${desc} ${metrics}`, description: `${desc} ${metrics}`,
canonicalUrl: `${BASE_URL}/public/workouts/${w.id}`, canonicalUrl: `${BASE_URL}/public/workouts/${w.id}`,
ogImage: w.attachment?.url,
jsonLd: { jsonLd: {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SportsActivity", "@type": "SportsActivity",