# System Patterns — Strava Frontend ## Architecture (SPA layering) ``` src/ main.ts - app bootstrap, axios instances, providers App.vue - root component router/ - vue-router config (lazy pages) layouts/ - AppLayout (authenticated shell), AuthLayout, RouterBypass pages/ - route components (workouts/, routes/, auth/, preferences/, admin/) components/ - reusable UI (navbar/, sidebar/, icons/, app-layout-navigation/) stores/ - Pinia stores (user-store, global-store) i18n/ - vue-i18n setup + locales/*.json services/ - axios helpers, vuestic-ui config (global-config, themes) scss/ - global styles, vuestic-sass, icon fonts server/ - SSR Express server (NOT type-checked by vue-tsc, excluded from tsconfig include) index.ts - Express app: SSR routes, helpers, port 3001 template.ts - HTML shell with SEO meta, OG tags, JSON-LD, asset tags api.ts - Axios client for backend API (getPublicWorkouts, getPublicWorkout) nginx.conf - Reverse proxy: SSR routes → :3001, everything else → SPA static files ``` Dependency direction: `pages -> components/stores/services`. Pages own business logic; components are presentational. `server/` is standalone (Express, runs via `tsx`). ## API access pattern - Two Axios instances created in `src/main.ts`: `axiosAuth` (adds `Authorization: Bearer ` from `localStorage` on every request) and `axiosPublic`. Both have a shared response error interceptor: 401 -> `localStorage.clear()` + redirect to `login` route. - Instances are provided app-wide: `app.provide('axiosAuth', ...)` / `app.provide('axiosPublic', ...)`. Components consume with `inject('axiosAuth') as AxiosInstance`. - Base URL is a hardcoded `HOST` constant in `src/main.ts` (`https://cycle-rider.ru`; localhost variant commented out). All backend paths are under `/api/v0`. - Auth check on startup: if `localStorage.token` exists, GET `/api/v0/auth/check`; on failure clear storage. ## Data fetching pattern - No central API layer: components/composable `.ts` files (e.g. `src/pages/workouts/components/GetWorkout.ts`) call `axiosAuth.get(url)` directly and map the response into view state. - Workout detail data shape: `{ workout, results: [{ timestamp, longitude, latitude, elevation, power, heart_rate, speed }, ...] }` — mapped into Chart.js datasets + map line coordinates. ## State management - Pinia with options-style stores. `useUserStore` hydrates from `localStorage` keys `user`, `profile`, `attachments` on store init (no persistence plugin). `useGlobalStore` holds sidebar state. - Auth data lives in `localStorage` (keys: `token`, `user`, `profile`, `attachments`). ## Routing - History-mode router (`createWebHistory`), all pages lazy-imported. - Two layouts: `AppLayout` (sidebar + navbar shell) for authenticated pages; `AuthLayout` for auth pages. Catch-all redirects to `explore` (= `pages/workouts/Feed.vue`). - Legacy redirect: `/dashboard` → `explore`. - Top-level route `workout_public_item` at `/public/workouts/:id` — NOT nested under AppLayout (accessible without auth, SSR-rendered). - No global auth guard — unauthenticated handling is done via the 401 interceptor. ## SSR (Server-Side Rendering) - Express server (`server/index.ts`) on port 3001 (env `SSR_PORT`). - SSR routes: `GET /` (landing, 302→`/explore` if `token` cookie present), `GET /explore`, `GET /public/workouts/:id`, `GET /routes`, `GET /sitemap.xml`, `GET /robots.txt`, `GET /health`. - HTML rendered via `renderTemplate()` from `server/template.ts` — full SEO meta (title, description, canonical, OG, JSON-LD, yandex-verification). - Asset tags extracted from `dist/index.html` (hashed filenames); falls back to dev script tag if `dist/` absent. - API data fetched server-side via `server/api.ts` (Axios to `VITE_APP_API_URL` || `https://cycle-rider.ru`). - Nginx proxies SSR routes to `:3001`; all other paths serve `dist/index.html` (SPA fallback `try_files`). - `SSR_BASE_URL` env controls canonical/OG URLs (default `https://cycle-rider.ru`). ## Charts & maps - Chart.js via `vue-chartjs` (+ `chartjs-adapter-moment`, `chartjs-plugin-zoom`, `chartjs-chart-geo` for elevation). Chart building lives in `src/pages/workouts/components/*.ts`. - 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([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 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 `` -> 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` 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 - Vuestic UI (config in `src/services/vuestic-ui/global-config.ts`, themes in `themes.ts`, custom icons registered in `icons-config/`). - Tailwind CSS with Vuestic CSS variables as color tokens (`--va-primary` etc. in `tailwind.config.js`). Custom font-size tokens: `tag`, `regularSmall/Medium/Large`. - Global SCSS in `src/scss/main.scss`; icon fonts in `src/scss/icon-fonts/`. ## i18n - `vue-i18n` in composition mode (`legacy: false`), locale and fallback = `ru`. Locales auto-loaded from `src/i18n/locales/*.json` via `import.meta.glob` + `@intlify/unplugin-vue-i18n/vite` plugin. ## Error handling - Central axios interceptor logs and re-throws; 401 triggers logout redirect. Components typically use `.then/.catch` chains (callback style, not async/await) — keep the existing style when modifying. ## Lint / type-check pipeline - **Flat ESLint config** (`eslint.config.mjs`, eslint 9 + typescript-eslint 8): `tseslint.configs.recommended` + `pluginVue.configs["flat/essential"]`. CRITICAL: the vue flat preset leaves espree as inner parser — the TS parser MUST be attached for `**/*.vue` via `languageOptions.parserOptions.parser` (from the `typescript-eslint` CJS default import). - Override style: file-targeted blocks with justifying comments ONLY (no global rule disabling, no inline eslint-disable). Existing exceptions: `src/main.ts` (any/unused — interceptor debt), `src/pages/**/*.vue` (multi-word names), `Logout.vue` (valid-template-root), `workouts/components/WorkoutItem.vue` (no-mutating-props — local-state extraction pending). - `ban-ts-comment` is enforced: `@ts-ignore` is not allowed; use `@ts-expect-error` or proper typing (non-null assertions). - `no-unused-vars` honors `argsIgnorePattern: "^_"`. - `yarn lint` runs `prelint: prettier --write .` — running lint auto-reformats ~30 files; commit the formatting as part of the change. ## Build / deploy - Vite 5 build with `vue-tsc 2 --noEmit` type-check in `yarn build`. Dev and Docker both use **yarn v1** (Docker: `node:20-alpine`, `COPY package.json yarn.lock`, `yarn install --frozen-lockfile`). - **Docker multi-stage**: Stage 1 (build) — `node:20-alpine`, yarn install, `yarn build`. Stage 2 (runtime) — `node:20-alpine` + nginx, copies `dist/` to nginx html root, copies `server/` + `node_modules/`, CMD runs `npx tsx server/index.ts & nginx -g 'daemon off;'`. - `nginx.conf`: SSR proxy for `/explore|/routes|/sitemap.xml|/robots.txt|/public/workouts/|/` → `:3001`; `/assets/` 30d immutable; SPA fallback `try_files $uri /index.html`. - `run.sh` launcher for local: `cd /app && npx tsx server/index.ts & nginx -g 'daemon off;'`. - `serve -s ./dist` for CI preview (static only, no SSR). - `tsx` is in `dependencies` (not devDependencies) — needed in the runtime container. - ⚠️ Legacy `package-lock.json` is STALE and unused (Docker no longer copies it) — do not run `npm install` in this repo; removal is a separate cleanup task. - Version ceilings (learned the hard way): **vue-tsc 2.x supports TypeScript up to 5.8 — TS 5.9 crashes it** (`Search string not found: "supportedTSExtensions"`); pin `"typescript": "5.8"` no-caret. **sass ≥1.7x latest requires node ≥20.19** — verify local dev node before bumping. - yarn 1.22 has no `up` alias — use `yarn upgrade`.