strava-frontend/.roo/memory-bank/systemPatterns.md

10 KiB

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 <token> 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<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 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

  • 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.