Files
anal-front/docs/review.md
Gleb Korotkiy 67ec34d619
Some checks failed
CI/CD Pipeline / Quality Gate (push) Failing after 2m37s
CI/CD Pipeline / Build and Deploy Dev via Compose (push) Has been skipped
refactor: align frontend with FSD architecture
2026-06-22 12:33:33 +03:00

66 KiB
Raw Blame History

Ревью проекта и план FSD/Vue рефакторинга

Дата: 2026-06-22
Проект: analytical-panel
Цель документа: зафиксировать техническое ревью, источники best practices, проблемы текущей реализации и пошаговый план рефакторинга без потери визуального или логического поведения приложения.

1. Главный принцип рефакторинга

Рефакторинг должен идти маленькими проверяемыми шагами. На каждом шаге нужно сохранять:

  • тот же пользовательский сценарий авторизации;
  • тот же redirect guard для защищенных страниц;
  • те же запросы к backend и параметры фильтров;
  • те же состояния загрузки, ошибки и пустых данных;
  • те же вкладки аналитики: all, sez, technoparks, clusters, режим рейтинга регионов;
  • ту же сортировку таблиц;
  • те же тултипы графиков и интерактивные элементы;
  • ту же адаптивную верстку;
  • те же публичные UI-kit импорты, пока не будет отдельного шага по оптимизации bundle.

Визуальный редизайн не входит в первый этап. Сначала нужно привести код к поддерживаемой архитектуре, не меняя внешний вид.

2. Исследованные источники и применимые выводы

2.1. Feature-Sliced Design

Официальные источники:

Ключевые выводы для проекта:

  • FSD не требует использовать все слои. Минимально нормальная структура может состоять из app, pages, shared; widgets, features, entities добавляются только когда дают реальную пользу.
  • Актуальная ментальная модель FSD v2.1: pages first. Сначала страница становится понятным слайсом, затем действительно переиспользуемая логика выносится ниже.
  • Основное правило импортов: код внутри слайса может импортировать только слайсы слоев ниже. Исключения: app и shared, потому что в них нет бизнес-слайсов.
  • Каждый слайс и каждый сегмент без слайсов должен иметь public API. Внешний код не должен импортировать внутренние файлы напрямую.
  • Внутри одного слайса лучше использовать относительные импорты напрямую к нужному файлу, а не импортировать из собственного index.ts, чтобы не создавать цикл через barrel-файл.
  • shared/ui и shared/lib не должны превращаться в одну большую бочку экспортов, если это ухудшает tree-shaking и размер bundle. Для крупных UI-наборов допустимы отдельные entrypoints.
  • DTO и generated API допустимо держать в shared/api, особенно если они связаны между доменами. Мапперы стоит держать рядом с DTO/API-оберткой или рядом со слайсом, если маппер специфичен для страницы.
  • Логика auth token storage и refresh может жить в shared/api, если она является частью общего API-клиента. UI-действия вроде logout лучше не прятать в shared/ui.
  • Layout с бизнес-виджетами можно держать в app/layouts либо строить через slots/render props, чтобы не нарушать направление зависимостей.
  • Cross-import между слайсами одного слоя является code smell и должен быть либо устранен, либо явно документирован.
  • Hardcoded routing/URLs на нижних слоях размазывает ответственность. Роутинг должен концентрироваться в app/pages, а нижним слоям лучше передавать callbacks, props или route descriptors.

2.2. Vue 3

Официальные источники:

Ключевые выводы для проекта:

  • <script setup lang="ts"> является рекомендуемым синтаксисом для Composition API в SFC.
  • Для props и emits желательно использовать типизированные объявления. В TypeScript-проектах предпочтительны defineProps<Props>(), defineEmits<...>(), defineModel<...>() по ситуации.
  • Компоненты должны быть multi-word, за исключением корневого App. Отключение vue/multi-word-component-names допустимо только временно и с понятной причиной.
  • v-for должен иметь стабильный key; v-if и v-for на одном элементе нужно избегать.
  • Стили компонентов приложения должны быть scoped или изолированы соглашением имен. Глобальные стили допустимы для root/layout/reset/design tokens, но не для всей страницы на 1900 строк.
  • Composables нужны для переиспользуемой чистой логики; если переиспользуется и логика, и layout, это уже компонент.
  • Composables должны возвращать refs/plain object так, чтобы потребитель не терял реактивность при destructuring.
  • Для производительности нужно измерять bundle size, избегать лишних абстракций, применять route-level code splitting и не тянуть тяжелые части UI-kit в главный chunk без необходимости.
  • Vue Router рекомендует dynamic imports для route components; проект это уже делает.
  • ESLint и Prettier должны быть частью проверок перед поставкой; желательно иметь единый check/CI script.
  • В security-контексте нельзя строить templates из недоверенного контента. В текущем коде есть HTML tooltip templates, поэтому нужно оставить явный escaping и покрыть это тестами.

2.3. Pinia, TanStack Query, Orval

Официальные источники:

Ключевые выводы для проекта:

  • Pinia setup stores могут использовать composables и watchers, но store лучше держать сфокусированным и в отдельном файле.
  • TanStack Query уже решает server state: кэш, stale time, retry, query keys. Ручной кэш поверх Vue Query должен быть оправдан поведением UI, иначе он усложняет модель.
  • Query keys должны включать все переменные, от которых зависит query function. Строковый ключ через join('|') работает, но структурированный ключ с объектом фильтров проще проверять и масштабировать.
  • Orval может генерировать Vue Query composables и type-safe helpers. В проекте Orval уже настроен на vue-query и custom mutator, но потребители обходят public API generated-модулей.

3. Текущее состояние проекта

3.1. Стек

Из package.json:

  • Vue ^3.5.34;
  • Vite ^8.0.12;
  • TypeScript ~6.0.2;
  • Vue Router ^5.1.0;
  • Pinia ^3.0.4;
  • TanStack Vue Query ^5.101.0;
  • TanStack Vue Table ^8.21.3;
  • Orval ^8.18.0;
  • Zod 3.25.76;
  • Vee Validate ^4.15.1;
  • Tailwind CSS ^4.1.18;
  • shadcn/reka-based local UI-kit;
  • Vitest and Playwright.

3.2. Baseline проверок

На момент ревью:

  • bun run typecheck проходит;
  • bun run lint проходит;
  • bun run test:unit проходит: 4 test files, 18 tests;
  • bun run build проходит.

Предупреждения build:

  • основной dist/assets/index-*.js после минификации около 725.42 kB, gzip около 234.10 kB;
  • Vite/Rolldown выводит предупреждения INVALID_ANNOTATION по #__PURE__ в node_modules/@vueuse/core/dist/index.js;
  • Vite предупреждает о chunk больше 500 kB.

3.3. Структура, важная для FSD

Сейчас есть:

  • src/app/layouts, src/app/providers;
  • src/app/router;
  • src/app/styles;
  • src/pages/auth, src/pages/dashboard;
  • src/widgets/protected-app-layout;
  • src/entities/user;
  • src/shared/api, src/shared/lib, src/shared/model, src/shared/ui, src/shared/ui-kit;
  • root entrypoints вне слоев: src/main.ts, src/App.vue, src/style.css.

Крупные файлы:

  • src/pages/dashboard/ui/DashboardPage.vue: около 487 строк;
  • src/pages/dashboard/model/dashboardStore.ts: около 205 строк;
  • src/pages/dashboard/ui/CompetenceMapFilters.vue: около 284 строк;
  • src/pages/dashboard/ui/components/CompetenceMapFilterField.vue: около 98 строк;
  • src/pages/dashboard/ui/components/TrendCard.vue: около 261 строк;
  • src/pages/dashboard/ui/components/TopIndustriesCard.vue: около 197 строк;
  • src/style.css: 2 строки, только CSS entrypoint;
  • src/pages/dashboard/ui/styles/sections/layout.css: около 495 строк;
  • src/pages/dashboard/ui/styles/sections/tables.css: около 415 строк;
  • src/pages/dashboard/ui/styles/sections/composition.css: около 367 строк;
  • src/pages/dashboard/ui/styles/sections/charts-and-industries.css: около 324 строк;
  • src/pages/dashboard/ui/ReportsView.vue: около 243 строк.

Практические пороги размера файлов для этого проекта:

  • page component: целевой диапазон 150-250 строк, после 300 строк нужен план декомпозиции, после 500 строк это архитектурный smell;
  • widget component: целевой диапазон 150-300 строк, после 350 строк нужно проверить, не смешаны ли UI, model и адаптеры данных;
  • feature/entity component: целевой диапазон 100-250 строк, после 300 строк нужно выносить подкомпоненты или composable;
  • shared/ui component: целевой диапазон 80-180 строк, после 250 строк компонент обычно становится слишком специализированным для shared;
  • composable: целевой диапазон 80-180 строк, после 220 строк нужно разделять orchestration, derived state и side effects;
  • store/model file: целевой диапазон 150-300 строк, после 350 строк нужно отделять query keys, API adapters, pure mappers и UI state;
  • pure helper/mapper: целевой диапазон 50-150 строк, после 200 строк нужно проверить cohesion и тестируемые единицы;
  • CSS-файл компонента или feature: целевой диапазон 150-300 строк, после 400 строк нужно дробить по зонам ответственности;
  • глобальный CSS entrypoint: только reset, tokens, base layers и импорты; страничные стили не должны жить в root style.css;
  • generated, vendor, icon packs и локальный shared/ui-kit, если он осознанно хранится как design system/vendor layer, не оцениваются по этим порогам.

Это не жесткий лимит ради числа строк. Файл может быть длиннее, если он механически сгенерирован или содержит однотипную таблицу конфигурации. Для ручного прикладного кода превышение порога означает обязательное ревью ответственности файла.

3.4. Диагностика Steiger

Команда первичного аудита: bunx steiger src
Результат: 12 errors, 1 warning, без автофиксов.

Для дальнейшей работы добавлена воспроизводимая команда bun run lint:fsd и конфиг steiger.config.ts. В конфиге осознанно исключены generated/vendor-зоны:

  • src/shared/api/generated-api/**;
  • src/shared/model/generated-zod/**;
  • src/shared/ui-kit/**.

Эти исключения не скрывают архитектурные проблемы прикладного кода. Они отделяют ручной FSD-код от generated API, generated zod-моделей и локального UI-kit, который сейчас фактически живет как отдельный design system.

Текущий результат bun run lint:fsd: проходит без ошибок.

Точечное архитектурное исключение:

  • src/entities/user: правило fsd/insignificant-slice отключено только для этого слайса, потому что user является доменной entity, даже если в текущем приложении используется только в widgets/protected-app-layout.

Уже устранено:

  • src/pages/dashboard больше не является segmentless slice: real page component перенесен в src/pages/dashboard/ui/DashboardPage.vue;
  • src/widgets/competence-map-filters и src/widgets/competence-map-toolbar слиты в src/pages/dashboard/ui, потому что сейчас они используются только dashboard-страницей;
  • src/stores/dashboard.ts перенесен в src/pages/dashboard/model/dashboardStore.ts, src/stores удален;
  • src/views удален, потому что самостоятельных потребителей не осталось;
  • src/router перенесен в src/app/router и разделен на routes.ts, guards.ts, index.ts;
  • прямые импорты generated API из прикладного кода заменены на public facade @/shared/api;
  • src/style.css превращен в entrypoint, а базовые и dashboard-стили разнесены по src/app/styles и src/pages/dashboard/ui/styles.

Найденные категории:

  • src/entities/user: слайс используется только в widgets/protected-app-layout; сохранен осознанно как доменная entity с точечным исключением fsd/insignificant-slice;
  • direct imports из @/shared/api/generated-api/... устранены в ручном app/pages/widgets/entities коде, но это нужно закрепить lint-правилом;
  • src/shared/model/generated-zod/** исключен как generated area;
  • внутренности src/shared/ui-kit/lib/... конфликтуют с зарезервированными именами FSD (lib, model, ui), что для локального design system нужно либо исключить осознанно, либо переупаковать.

4. Главные проблемы текущего кода

P0. Архитектурные нарушения, влияющие на поддержку

  1. dashboardStore.ts уже разделен и больше не содержит ручной response cache.

    Выполнено: типы, константы, фильтры, empty state, error resolving, chart data, форматтеры, сортировка, query keys и tooltip helpers вынесены в отдельные model-файлы. Store стал orchestration layer примерно на 205 строк. Ручные analyticsResponseCache/filtersResponseCache удалены; server state теперь опирается на TanStack Query cache со структурированными query keys.

  2. DashboardPage.vue стал тоньше, но все еще выше целевого диапазона.

    Выполнено: KPI, state blocks, tabs, composition panels, ranking, industries, trend, employee stats и tooltip portal вынесены в компоненты src/pages/dashboard/ui/components. Файл уменьшен примерно с 1712 до 487 строк. Это уже не критический монолит, но он все еще выше целевого диапазона page component 150-250 строк и требует второго прохода после декомпозиции фильтров/toolbar.

  3. CompetenceMapFilters.vue больше не является крупным монолитом, но требует полного component-test flow.

    Выполнено: draft mapping вынесен в model/filterDraft.ts, option definitions вынесены в model/filterDefinitions.ts, select field вынесен в ui/components/CompetenceMapFilterField.vue. CompetenceMapFilters.vue уменьшен примерно с 470 до 284 строк. Добавлен component test для CompetenceMapFilterField. Оставшийся риск: нет component tests на полный apply/reset/cancel flow и disabled/loading states диалога.

  4. CSS больше не лежит целиком в root, но dashboard CSS пока разделен крупными section-файлами.

    Выполнено: src/style.css теперь только импортирует src/app/styles/base.css и src/pages/dashboard/ui/styles/dashboard.css. Оставшийся риск: layout.css, tables.css, composition.css, charts-and-industries.css все еще крупные и используют общие class selectors. Их нужно дробить рядом с компонентами только после стабилизации UI-декомпозиции.

  5. shared/ui очищен от прямой app/router logic в header и sidebar.

    Примеры:

    • выполнено: AppHeaderActions.vue больше не импортирует useLogout, а эмитит logout;
    • выполнено: AppHeaderBranding.vue больше не использует router/smoothScroll напрямую, а получает props и эмитит mainPageClick;
    • выполнено: ProtectedAppLayout.vue стал owner для logout redirect, route state и smooth scroll;
    • выполнено: AppSidebar больше не импортирует router/preload/localStorage logic;
    • выполнено: route matching, navigation, route preload и persisted expanded groups перенесены в widgets/protected-app-layout/model/useProtectedAppSidebarNavigation.ts.

    Выполнено: logout orchestration больше не живет в shared/api; оно перенесено в widgets/protected-app-layout/model/useProtectedLogout.ts. В shared/api остались low-level endpoint facade и token primitives.

  6. Public API generated API исправлен на уровне импортов, но нужна защита от регрессий.

    В src/shared/api/index.ts добавлен facade для используемых endpoint functions и типов. Ручной прикладной код больше не импортирует @/shared/api/generated-api/... напрямую. Следующий шаг: закрепить это ESLint/Steiger convention и добавить README для generated area.

  7. Auth page приведен ближе к FSD/Vue best practices.

    Выполнено: AuthLoginForm.vue импортирует useAuthLogin из локального model-сегмента, а не из @/pages/auth; login schema вынесена в pages/auth/model/loginSchema.ts; safe redirect helper вынесен в pages/auth/model/redirect.ts и покрыт unit-тестом.

P1. Риски логики и тестируемости

  1. Тестовое покрытие недостаточно для безопасного рефакторинга dashboard.

    Сейчас есть:

    • unit-тесты для normalizeAnalyticsFilters, fallback association type, цветов, toApiParams, structured query keys, empty analytics response;
    • unit-тесты для dashboard filter draft mapping и filter definitions;
    • unit-тесты для chart adapters, сортировки, sort direction, pluralization/growth formatting и escapeHtml;
    • unit-тест entrypoint UI-kit;
    • E2E только для redirect на auth и локальной валидации login form.

    Все еще нет тестов на:

    • component behavior для dashboard cards/tables;
    • component-level draft/apply/reset/cancel flow фильтров;
    • tab switching;
    • ranking report;
    • mocked authorized dashboard load;
    • визуальные состояния dashboard.
  2. Loading/placeholder UX после смены фильтров не закреплен тестами.

    Ручной cache layer удален в пользу TanStack Query cache. Следующий риск теперь не архитектурный cache, а UX: нужно компонентно или E2E проверить, что при смене фильтров loading/empty/fetching states остаются ожидаемыми.

  3. Query key переведен со строки на структурированный объект.

Выполнено: добавлен model/queryKeys.ts, query keys строятся как ['competence-map-analytics', normalizeAnalyticsFilters(filters)] и ['competence-map-analytics-filters', normalizeAnalyticsFilters(filters)].

  1. Error handling dashboard дублирует общую error-модель.

resolveDashboardApiErrorMessage вынесен из store в src/pages/dashboard/model/error.ts, но все еще не переиспользует общую error-модель из shared/api/error. Следующий шаг: унифицировать, если сообщения и контракты ошибок совпадают.

  1. Tooltip HTML templates строятся строками.

Сейчас есть escapeHtml, это правильно. Но безопасность и корректность нужно закрепить unit-тестами, потому что при будущих правках легко случайно убрать escaping.

  1. src/shared/model/generated-zod остается generated area без ручного public API.

Steiger исключает src/shared/model/generated-zod/**, но для передачи заказчику нужно явно задокументировать, что это generated-код и он не редактируется вручную.

P2. Качество сборки и tooling

  1. build не запускает ESLint, но добавлен отдельный check.

build по-прежнему делает typecheck && vite build, что нормально для runtime-сборки. Для сдачи заказчику добавлен check, который запускает lint, lint:fsd, typecheck, unit tests и build. В CI отдельно стоит запускать test:e2e или check:full.

  1. vue/multi-word-component-names полностью выключен.

Это может скрывать реальные нарушения style guide. Нужно либо включить правило и исправить компоненты, либо оставить точечные overrides для App.vue/generated/vendor.

  1. TypeScript ослаблен.

В tsconfig.app.json:

  • noUnusedLocals: false;
  • noUnusedParameters: false;
  • erasableSyntaxOnly: false.

Включать все сразу рискованно, но в плане нужно постепенное усиление.

  1. shared/ui-kit исключен из ESLint и tsconfig.

Это может быть осознанно, если UI-kit является импортированным/vendor-кодом. Но перед сдачей нужно описать это в README или проверить отдельно.

  1. Bundle size требует анализа.

Главный chunk 725 KB minified. Вероятные причины:

  • общий UI-kit entrypoint;
  • heavy chart components;
  • barrel exports;
  • dashboard импортирует много компонентов и данных в одном route chunk.

P3. Навигация, naming и документация

  1. Report-tab сейчас является частью dashboard.

ReportsView.vue перенесен в src/pages/dashboard/ui. Если позже появится самостоятельный route /reports, его нужно выделять в отдельный pages/reports слайс, а не держать как скрытую вкладку dashboard.

  1. docs/components/heder.css содержит опечатку в имени heder.

Если эти файлы являются reference layout docs, нужно оставить как есть или переименовать с учетом ссылок.

  1. Sidebar localStorage key мигрирован с legacy namespace.

Выполнено: новый ключ analytical-panel:app-sidebar:expanded-group-ids живет в widgets/protected-app-layout/model/useProtectedAppSidebarNavigation.ts; старый mostovik:app-sidebar:expanded-group-ids читается только как migration fallback.

  1. README слишком короткий для передачи заказчику.

Нужны разделы:

  • архитектура;
  • команды;
  • env;
  • generated API;
  • тестирование;
  • known limitations;
  • FSD rules.

5. Целевая FSD-структура

Цель не в том, чтобы механически создать все возможные слои, а в том, чтобы у каждой папки была понятная ответственность.

src/
  app/
    layouts/
      protected-route/
        ui/
        index.ts
    providers/
      query/
      store/
      router/
    router/
      routes.ts
      guards.ts
      index.ts
    styles/
      global.css
      tokens.css
    index.ts

  pages/
    auth/
      model/
        loginSchema.ts
        useAuthLogin.ts
      ui/
        AuthPage/
        AuthLoginForm/
      index.ts

    dashboard/
      api/
        competenceMapAnalyticsApi.ts
      model/
        filters.ts
        queryKeys.ts
        types.ts
        useDashboardAnalytics.ts
        useDashboardFilters.ts
        useDashboardTabs.ts
        sortRows.ts
        formatters.ts
        tooltip.ts
        adapters.ts
      ui/
        DashboardPage.vue
        DashboardToolbar.vue
        DashboardTabs.vue
        DashboardKpiGrid.vue
        DashboardStatePanel.vue
        DashboardSkeletonGrid.vue
        DashboardAnalyticsGrid.vue
        AssociationCompositionCard.vue
        SezPriorityDirectionsCard.vue
        TechnoparkAreaDistributionCard.vue
        ClusterSpecializationsCard.vue
        EmployeeStatsCard.vue
        TrendCard.vue
        RegionRankingCard.vue
        TopIndustriesCard.vue
        RegionRatingReport.vue
        CompetenceMapFiltersDialog.vue
        ChartTooltipPortal.vue
      index.ts

  widgets/
    protected-app-layout/
      model/
      ui/
      index.ts

  features/
    logout-session/
      model/
      ui/          # только если появится самостоятельная кнопка/меню
      index.ts

  entities/
    user/
      model/
      index.ts

  shared/
    api/
      client/
      auth/
      competence-map-analytics/
      generated-api/
      index.ts
    config/
      routes.ts
      env.ts
    lib/
      number/
      text/
      navigation/
    model/
      generated-zod/
      index.ts
    ui/
      app-header/      # только pure UI или перенести в widget
      app-sidebar/     # только pure UI или перенести в widget
      breadcrumb/
      logo/
      index.ts
    ui-kit/
      README.md
      index.ts

Возможные корректировки после анализа переиспользования:

  • features/logout-session можно не создавать, если logout останется только деталью protected-app-layout.
  • entities/user можно оставить как доменную сущность, несмотря на warning Steiger, если ожидаются роли, профиль, права доступа и дальнейшее использование. Если это не планируется, useCurrentUser можно перенести ближе к protected-app-layout.
  • widgets/competence-map-filters и widgets/competence-map-toolbar уже слиты в pages/dashboard/ui, потому что сейчас используются только dashboard-страницей.
  • src/shared/ui-kit лучше трактовать как локальный design-system/vendor package. Его не нужно ломать ради буквального FSD, но нужно документировать исключение.

6. Детальный пошаговый план рефакторинга

Этап 0. Зафиксировать baseline и защиту от регрессий

  1. Зафиксировать текущий список проверок:

    bun run lint
    bun run typecheck
    bun run test:unit
    bun run build
    bun run test:e2e
    
  2. Проверить bun run test:e2e отдельно и зафиксировать, проходит ли он на текущей машине.

  3. Добавить временный документ docs/refactoring-regression-checklist.md или секцию в этом файле с ручными сценариями:

    • переход /dashboard без токена ведет на /auth;
    • login form показывает ошибки пустых полей;
    • успешный login редиректит на dashboard;
    • dashboard all state;
    • вкладка ОЭЗ;
    • вкладка Технопарки;
    • вкладка Кластеры;
    • фильтры: открыть, выбрать, отменить, сбросить, применить;
    • сортировка ranking;
    • сортировка detail tables;
    • region rating tab;
    • loading state;
    • error state;
    • mobile viewport.
  4. Добавить Playwright route mocks для analytics endpoints, чтобы E2E dashboard не зависел от внешнего backend.

  5. Снять baseline screenshots для desktop и mobile:

    • auth page;
    • dashboard all;
    • dashboard sez;
    • dashboard technoparks;
    • dashboard clusters;
    • filters dialog;
    • report/rating table.
  6. Зафиксировать bundle baseline:

    • размер index-*.js;
    • размер route chunk dashboard-*.js;
    • CSS sizes;
    • build warnings.

Этап 1. Настроить архитектурные проверки

Статус: выполнено для текущего объема.

  1. Выполнено: Steiger добавлен в dev tooling:

    {
      "scripts": {
        "lint:fsd": "steiger src"
      }
    }
    
  2. Выполнено: добавлен steiger.config.ts:

    • исключить src/shared/ui-kit/** как локальный vendor/design-system;
    • исключить generated files, но не исключать public API wrappers;
    • оставить strict checks для pages, widgets, features, entities, shared/api, shared/ui.
  3. Выполнено: добавлены check и check:full:

    {
      "scripts": {
        "check": "bun run lint && bun run lint:fsd && bun run typecheck && bun run test:unit && bun run build"
      }
    }
    
  4. Выполнено: lint:fsd включен в check, потому что текущие FSD-нарушения ручного прикладного кода устранены.

  5. Оставшийся план по усилению ESLint:

    • убрать глобальное отключение vue/multi-word-component-names;
    • заменить его точечными exceptions;
    • добавить правила для import boundaries, если Steiger не покрывает нужные Vue-сценарии;
    • добавить правило против прямых импортов из shared/api/generated-api вне shared/api.

Этап 2. Привести app слой в порядок

Статус: выполнено.

  1. Выполнено: src/router/index.ts перенесен в src/app/router/index.ts.

  2. Выполнено: router разделен:

    • routes.ts: route records и lazy imports;
    • guards.ts: auth guard и title guard;
    • index.ts: создание router.
  3. Выполнено: импорт в src/main.ts обновлен на ./app/router.

  4. Выполнено: compatibility re-export не оставлялся, потому что импорт был единственный и безопасно обновлен.

  5. Выполнено: route-level lazy loading сохранен в routes.ts.

  6. Выполнено: app-level styles перенесены из src/style.css в src/app/styles/base.css:

    • reset;
    • html/body/#app;
    • tokens;
    • global font-family;
    • минимальные общие helpers.
  7. Выполнено: src/style.css оставлен entrypoint, который импортирует app/styles/base.css и dashboard CSS.

Этап 3. Сформировать настоящий pages/dashboard слайс

Статус: базовое перемещение выполнено.

  1. Создать сегменты:

    • src/pages/dashboard/ui;
    • src/pages/dashboard/model на следующем этапе;
    • src/pages/dashboard/api при выделении dashboard API wrappers;
    • src/pages/dashboard/lib при выделении pure helpers.
  2. Выполнено: src/views/DashboardView.vue перенесен в src/pages/dashboard/ui/DashboardPage.vue.

  3. Выполнено: src/views/ReportsView.vue перенесен в src/pages/dashboard/ui/ReportsView.vue, потому что это вкладка dashboard, а не самостоятельная route page.

  4. Выполнено: src/pages/dashboard/index.ts обновлен:

    export { DashboardPage } from './ui'
    
  5. Выполнено: зависимость pages/dashboard от src/views удалена.

  6. Выполнено: src/views удален, потому что других потребителей не осталось.

  7. Выполнено: на этом шаге логика не дробилась, были только механические переносы и обновление импортов.

  8. Проверки после шага:

    bun run lint
    bun run typecheck
    bun run test:unit
    bun run build
    

Этап 4. Перенести и разделить dashboard model

Статус: выполнено для первого безопасного прохода.

  1. Выполнено: src/stores/dashboard.ts перенесен в src/pages/dashboard/model/dashboardStore.ts.

  2. Выполнено: API useDashboardStore сохранен, чтобы не менять всю страницу одновременно.

  3. Выполнено: импорты обновлены:

    • DashboardPage.vue;
    • ReportsView.vue;
    • CompetenceMapFilters.vue;
    • tests.
  4. Выполнено: src/stores удален.

  5. Выполнено: dashboardStore.ts разделен на отдельные model-файлы:

    • types.ts: domain/view types;
    • constants.ts: цветовые карты и константы;
    • filters.ts: defaultAnalyticsFilters, normalizeAnalyticsFilters, toApiParams, cache key;
    • emptyState.ts: empty response factories;
    • error.ts: dashboard error resolving;
    • chartData.ts: chart/donut adapters;
    • formatters.ts: number, metric, area, growth, plural forms;
    • sorting.ts: sorting helpers;
    • tooltip.ts: tooltip builders and escaping.
  6. Выполнено: dashboard API wrappers вынесены в src/pages/dashboard/api/competenceMapAnalyticsApi.ts.

  7. Выполнено: unit tests перенесены рядом с model и расширены до 15 dashboard-кейсов:

    • filter normalization;
    • filter draft mapping;
    • filter definitions;
    • API params;
    • cache key;
    • empty state;
    • chart adapters;
    • sorting;
    • formatting/pluralization;
    • tooltip escaping.
  8. Оставшийся подшаг: проверить, можно ли отказаться от Pinia для dashboard.

    Предпочтительный target:

    • server state: TanStack Query;
    • page UI state: composables/ref внутри pages/dashboard/model;
    • Pinia оставить только если состояние нужно глобально между routes.
  9. Ручной cache layer уже удален; если отказ от Pinia слишком рискованный, оставить setup store на один этап и покрыть page state/component behavior тестами.

Этап 5. Убрать direct imports generated API

Статус: выполнен минимальный безопасный facade, осталась документация и возможная детализация entrypoints.

  1. Выполнено: создан public facade src/shared/api/index.ts, который экспортирует используемые endpoint functions и типы.

  2. Возможный следующий уровень: создать более явные public API wrappers:

    src/shared/api/authentication/
      index.ts
    src/shared/api/user/
      index.ts
    src/shared/api/competence-map-analytics/
      index.ts
    src/shared/api/generated/
      README.md
    
  3. В wrappers экспортировать только нужные функции и типы:

    • login mutation;
    • token refresh URL/handler, если нужен;
    • current user endpoint;
    • analytics endpoints;
    • endpoint schemas/types, которые реально нужны приложению.
  4. Выполнено: потребители переведены на public facade:

    • entities/user/model/useCurrentUser.ts;
    • pages/auth/model/useAuthLogin.ts;
    • pages/auth/ui/AuthPage/AuthLoginForm/AuthLoginForm.vue;
    • dashboard model.
  5. Запретить прямые импорты из shared/api/generated-api вне shared/api через ESLint/Steiger convention.

  6. Добавить README в shared/api/generated-api:

    • generated by Orval;
    • do not edit manually;
    • run bun run apigen;
    • public wrappers live one level above.

Этап 6. Разобрать DashboardPage.vue на компоненты

Статус: выполнен первый безопасный проход. DashboardPage.vue уменьшен примерно с 1712 до 487 строк без изменения визуального поведения.

Порядок дробления шел от простого к сложному.

  1. Выполнено частично: shell оставлен в DashboardPage.vue, tabs вынесены:

    • DashboardTabs.vue.
  2. Выполнено: KPI вынесены:

    • DashboardKpiGrid.vue;
    • отдельный DashboardKpiCard.vue пока не создавался, чтобы не дробить простую разметку без необходимости.
  3. Выполнено: state blocks вынесены:

    • DashboardStatePanel.vue;
    • DashboardSkeletonGrid.vue;
    • отдельные error/empty components пока не нужны.
  4. Выполнено: primary cards/panels вынесены:

    • CompositionCard.vue;
    • AssociationsCompositionPanel.vue;
    • SezCompositionPanel.vue;
    • TechnoparkCompositionPanel.vue;
    • ClusterSpecializationsPanel.vue.
  5. Выполнено: secondary cards вынесены:

    • RegionRankingCard.vue;
    • TopIndustriesCard.vue.
  6. Выполнено: trend вынесен:

    • TrendCard.vue;
    • отдельные ClusterCreationTrendChart.vue и OpkParticipationTrendChart.vue пока не создавались, потому что общий TrendCard.vue сохраняет связную ответственность.
  7. Выполнено: employee stats вынесены:

    • EmployeeStatsCard.vue.
  8. Выполнено: tooltip portal вынесен:

    • ChartTooltipPortal.vue;
    • tooltip builders и escaping вынесены в model/tooltip.ts.
  9. Оставшийся возможный шаг: вынести table primitives только если повторяются:

    • SortableHeaderButton.vue;
    • AnalyticsTable.vue;
    • RankingTable.vue.
  10. Не создавать слишком мелкие компоненты ради количества файлов. Компонент должен появляться, если:

  • у него отдельная ответственность;
  • его template читается независимо;
  • его props API естественный;
  • он уменьшает размер родителя;
  • он покрывается тестом или визуально проверяется.
  1. После каждого выноса:
  • проверить props typing;
  • не потерять aria-*;
  • не потерять key;
  • не изменить CSS class names до отдельного CSS шага;
  • прогнать проверки.

Этап 7. Перенести фильтры и toolbar в правильное место

Статус: ownership исправлен, первый проход внутреннего разбиения фильтров выполнен.

  1. Выполнено: ownership решен в пользу dashboard page:

    • widgets/competence-map-filters перенесен в pages/dashboard/ui/CompetenceMapFilters.vue;
    • widgets/competence-map-toolbar перенесен в pages/dashboard/ui/CompetenceMapToolbar.vue.
  2. Обоснование: фильтры и toolbar сейчас завязаны на конкретную dashboard page.

    Причина: фильтры завязаны на конкретные AnalyticsFilters, dashboard.filtersResponse, dashboard.applyFilters.

  3. Выполнено: логика фильтров разделена:

    • filterDraft.ts: draft mapping и nullable conversion;
    • filterDefinitions.ts: option definitions;
    • CompetenceMapFilterField.vue: select field UI.
  4. Выполнено: competence-map-toolbar перенесен в dashboard page, потому что он только про breadcrumb этой страницы.

  5. Если toolbar станет общим, сделать pure shared component:

    • props: breadcrumbItems;
    • no dashboard imports;
    • no route assumptions.

Этап 8. Очистить shared/ui от app/business logic

Статус: AppHeader и AppSidebar очищены в первом проходе.

  1. Выполнено: AppHeader разделен по ответственности:

    • shared/ui/app-header: только layout, slots, display props;
    • widgets/protected-app-layout/ui: current user, logout, router callbacks.
  2. Выполнено: logout orchestration перенесен из shared/api:

    Возможные варианты:

    • текущий выбранный вариант: widgets/protected-app-layout/model/useProtectedLogout;
    • features/logout-session не создавался, потому что Steiger корректно считает отдельный feature slice преждевременным при одном потребителе;
    • token primitives и low-level logout endpoint facade остались в shared/api.

    Если logout начнет переиспользоваться несколькими flows, тогда его можно поднять в features/logout-session.

  3. Оставить token primitives в shared/api, потому что API client и refresh interceptor используют их глобально.

  4. Выполнено: AppSidebar сделан более чистым:

    • route-specific preload убран из shared component;
    • callbacks на click/hover передаются из widget owner;
    • route matching/navigation строятся в widget model.
  5. Выполнено: localStorage key перенесен из shared UI в widget model.

  6. Выполнено: mostovik:app-sidebar:expanded-group-ids исправлен миграционно:

    • новый ключ: analytical-panel:app-sidebar:expanded-group-ids;
    • при чтении есть fallback на старый ключ, чтобы не ломать существующее состояние.

Этап 9. Привести auth page к FSD и Vue best practices

Статус: выполнено для первого безопасного прохода.

  1. Выполнено: schema перенесена из AuthLoginForm.vue в pages/auth/model/loginSchema.ts.

  2. Выполнено: self-import убран:

    • было: import { useAuthLogin } from '@/pages/auth';
    • стало: импорт из локального model-сегмента.
  3. Выполнено: тип Login импортируется через public facade @/shared/api, а не generated path.

  4. Выполнено: redirect safety вынесен в pages/auth/model/redirect.ts:

    • текущая проверка / и не // правильная;
    • добавлен unit test на внешний/protocol-relative redirect.
  5. Улучшить submit flow:

    • оставить защиту от double submit;
    • проверить disabled state для username input тоже, если UX требует;
    • оставить accessible error messages.
  6. Выполнено частично:

    • empty validation;
    • whitespace validation;
    • safe redirect.
  7. Оставшийся подшаг:

    • API error message;
    • full submit flow component/E2E tests.

Этап 10. Перенести и изолировать CSS

Статус: первый безопасный перенос выполнен, компонентная изоляция осталась.

  1. Выполнено: src/style.css разделен:

    • src/app/styles/base.css;
    • src/pages/dashboard/ui/styles/dashboard.css;
    • src/pages/dashboard/ui/styles/sections/layout.css;
    • src/pages/dashboard/ui/styles/sections/composition.css;
    • src/pages/dashboard/ui/styles/sections/tables.css;
    • src/pages/dashboard/ui/styles/sections/charts-and-industries.css;
    • src/pages/dashboard/ui/styles/sections/responsive.css.
  2. Выполнено: dashboard-specific CSS перенесен без переписывания selectors.

  3. Следующий шаг: распределить CSS по компонентам:

    • tabs styles рядом с DashboardTabs.vue;
    • KPI styles рядом с DashboardKpiGrid.vue;
    • card styles рядом с конкретными cards;
    • table styles рядом с table components;
    • tooltip styles рядом с ChartTooltipPortal.vue;
    • reports styles рядом с RegionRatingReport.vue.
  4. Избегать изменения class names одновременно с переносом template.

  5. Если используется :deep, оставить комментарий только там, где это действительно override UI-kit internals.

  6. Проверить mobile breakpoints после каждого крупного переноса.

Этап 11. Улучшить server state и dashboard data model

Статус: первый проход выполнен.

  1. Выполнено: query key factories вынесены:

    export const competenceMapAnalyticsQueryKeys = {
      all: ['competence-map-analytics'] as const,
      list: (filters: AnalyticsFilters) =>
        [...competenceMapAnalyticsQueryKeys.all, normalizeAnalyticsFilters(filters)] as const,
      filters: (filters: AnalyticsFilters) =>
        ['competence-map-analytics-filters', normalizeAnalyticsFilters(filters)] as const,
    }
    
  2. Проверить сериализуемость фильтров и отсутствие undefined.

  3. Выполнено: createAnalyticsCacheKey удален, строковый key больше не используется.

  4. Выполнено: ручной response cache удален, server state опирается на Vue Query cache.

  5. Оставшийся подшаг: решить, как показывать прошлые данные при смене фильтров:

    • использовать Vue Query cache;
    • использовать placeholderData, если нужно;
    • явно покрыть loading UX тестами.
  6. Перенести resolveApiErrorMessage dashboard на общий shared/api/error.

  7. Вынести DTO-to-view adapters:

    • toCompositionDonutData;
    • toTrendChartData;
    • toRegionReportRows;
    • toEmployeeStatItems.
  8. Добавить unit tests на все adapters.

Этап 12. Оптимизировать public APIs и barrel exports

  1. Проверить все index.ts:

    • public API должен экспортировать только нужный контракт;
    • не использовать export * для всего подряд, если это открывает internals;
    • внутри слайса не импортировать из собственного public API.
  2. Для shared/ui рассмотреть subpath imports:

    • @/shared/ui/breadcrumb;
    • @/shared/ui/logo;
    • @/shared/ui/app-header;
    • @/shared/ui/app-sidebar.
  3. Для shared/ui-kit оценить:

    • оставить единый @gisp/ui-kit-shadcn-vue, если tree-shaking нормальный;
    • либо добавить component-level entrypoints, если bundle analysis подтвердит проблему.
  4. Добавить architecture notes в src/shared/ui-kit/lib/README.md или src/shared/ui-kit/README.md:

    • почему папка исключена из FSD lint;
    • как импортировать компоненты;
    • как добавлять новые компоненты;
    • как не ломать public API.

Этап 13. Усилить тесты

  1. Unit tests:

    • normalizeAnalyticsFilters;
    • toApiParams;
    • query keys;
    • sortRows with null/string/number/tie by rank;
    • formatNumber;
    • formatMetricValue;
    • getRuPluralForm;
    • formatOrganizationsText;
    • escapeHtml;
    • tooltip builders;
    • dashboard adapters;
    • auth schema;
    • safe redirect helper.
  2. Component tests:

    • DashboardTabs emits/apply behavior;
    • CompetenceMapFiltersDialog draft/apply/reset/cancel;
    • RegionRankingCard sorting UI;
    • TopIndustriesCard empty and filled states;
    • ChartTooltipPortal visibility.
  3. E2E tests:

    • auth redirect;
    • login validation;
    • mocked authorized dashboard load;
    • tab switch all/sez/technoparks/clusters;
    • filter dialog;
    • sort buttons;
    • region rating tab;
    • mobile layout smoke.
  4. Visual regression:

    • either Playwright screenshots with controlled mock data;
    • or at minimum saved screenshots before/after refactor during manual validation.
  5. Accessibility checks:

    • role/aria для tables;
    • focusable chart segments;
    • keyboard focus in dialog;
    • aria-busy;
    • tooltips not blocking focus.

Этап 14. Bundle/performance pass

  1. Добавить bundle analyzer или Vite visualizer.

  2. Сравнить:

    • before/after main chunk;
    • dashboard route chunk;
    • UI-kit chunk;
    • chart chunk.
  3. Проверить, почему index-*.js 725 KB:

    • общий import UI-kit;
    • route preload;
    • shared barrel;
    • chart components;
    • TanStack table/chart libraries.
  4. Возможные оптимизации:

    • лениво грузить тяжелые chart cards;
    • разделить dashboard report tab;
    • component-level imports из UI-kit;
    • manual chunks для UI-kit/charts;
    • отложенная загрузка report table.
  5. Не применять оптимизации до архитектурного переноса, чтобы не смешивать причины регрессий.

Этап 15. Документация для передачи заказчику

  1. Расширить README:

    • стек;
    • требования к Node/Bun;
    • env variables;
    • запуск dev;
    • build/preview;
    • tests;
    • generated API;
    • FSD structure;
    • deployment notes;
    • known warnings.
  2. Добавить docs/architecture.md:

    • слои;
    • правила импортов;
    • public API;
    • где хранить API;
    • где хранить UI;
    • как добавлять новую страницу;
    • как добавлять новый analytics block.
  3. Добавить docs/testing.md:

    • unit;
    • e2e;
    • visual checks;
    • mock data policy.
  4. Добавить docs/generated-api.md:

    • Orval config;
    • OpenAPI source;
    • команда bun run apigen;
    • что нельзя редактировать руками.

7. Предлагаемый порядок выполнения PR/коммитов

  1. Tooling and baseline:

    • выполнено: добавить lint:fsd/Steiger config;
    • выполнено: добавить check и check:full;
    • добавить dashboard E2E mocks skeleton;
    • выполнено: runtime не менялся на этом шаге.
  2. App/router cleanup:

    • выполнено: перенести router в app;
    • выполнено: разделить routes/guards;
    • выполнено: проверки.
  3. Dashboard page relocation:

    • выполнено: views/DashboardView.vue -> pages/dashboard/ui/DashboardPage.vue;
    • выполнено: views/ReportsView.vue -> pages/dashboard/ui/ReportsView.vue;
    • выполнено: dashboard-only widgets перенесены в pages/dashboard/ui;
    • выполнено: src/views удален;
    • выполнено: проверки.
  4. Dashboard model relocation:

    • выполнено: stores/dashboard.ts -> pages/dashboard/model/dashboardStore.ts;
    • выполнено: model split на filters/constants/types/chartData/formatters/sorting/tooltip/error/emptyState;
    • выполнено: API wrappers в pages/dashboard/api;
    • выполнено: временно сохранен useDashboardStore API;
    • выполнено: удален src/stores;
    • выполнено: dashboard unit-тесты расширены до 15 кейсов;
    • выполнено: проверки.
  5. Public API wrappers:

    • выполнено: shared API facade;
    • выполнено: убрать generated direct imports из ручного прикладного кода;
    • выполнено: исправить Steiger public-api issues в текущем объеме;
    • выполнено: проверки.
  6. Auth cleanup:

    • выполнено: schema в model;
    • выполнено: убрать self-import;
    • выполнено: тесты redirect/schema;
    • выполнено: проверки.
  7. Dashboard pure model extraction:

    • выполнено: filters/sort/formatters/tooltip/adapters;
    • выполнено: queryKeys заменены на структурированные ключи;
    • выполнено: unit tests;
    • выполнено: проверки.
  8. Dashboard UI decomposition:

    • выполнено: карточки, панели, tooltip portal, tabs, states;
    • выполнено: фильтры, draft helpers и select field;
    • выполнено: проверки после блока.
  9. Shared UI cleanup:

    • выполнено частично: AppHeader стал ближе к pure UI;
    • выполнено: AppSidebar стал pure UI;
    • выполнено: logout orchestration перенесен в widget model;
    • выполнено: protected layout composes header behavior;
    • выполнено: проверки.
  10. CSS relocation:

    • выполнено: global split;
    • выполнено: dashboard CSS split;
    • screenshots;
    • выполнено: проверки.
  11. Bundle/performance:

    • analyzer;
    • chunk strategy;
    • UI-kit import strategy;
    • проверки.
  12. Final docs and handoff:

    • README;
    • architecture docs;
    • generated API docs;
    • final full check.

8. Definition of Done

Рефакторинг можно считать завершенным, когда:

  • src/views удален;
  • src/stores удален или обоснован как отдельный слой не нужен;
  • src/router перенесен в app;
  • pages/dashboard имеет реальные сегменты ui, model, api;
  • DashboardPage.vue стал тонкой композицией, а не файлом на 1700 строк;
  • dashboard pure logic покрыта unit-тестами;
  • generated API не импортируется напрямую из app/pages/widgets/entities;
  • shared/ui не содержит бизнес-логики logout/current user/router-specific behavior;
  • src/style.css больше не содержит весь dashboard;
  • Steiger либо проходит, либо имеет задокументированные исключения для generated/vendor UI-kit;
  • bun run lint проходит;
  • bun run typecheck проходит;
  • bun run test:unit проходит;
  • bun run build проходит;
  • bun run test:e2e проходит;
  • визуальные screenshots ключевых dashboard states совпадают или изменения явно согласованы;
  • README и архитектурная документация достаточны для передачи заказчику.

9. Что не делать на первом проходе

  • Не менять дизайн, размеры, цвета и сетку без отдельного согласования.
  • Не менять backend contract и названия query params.
  • Не удалять Pinia одномоментно, если это смешает слишком много причин регрессий.
  • Не переписывать UI-kit внутренности до bundle/performance этапа.
  • Не добавлять features ради формального наличия слоя.
  • Не делать массовое переименование CSS classes одновременно с переносом компонентов.
  • Не включать все strict TS flags одним коммитом.

10. Статус первого большого refactor-pass

Защитная инфраструктура, FSD-перенос и первая декомпозиция выполнены:

  1. Добавлен Steiger script/config с осознанными исключениями для generated/vendor-зон.
  2. Добавлены check и check:full.
  3. Добавлены file-size thresholds и FSD policy в docs/review.md.
  4. Router перенесен в src/app/router и разделен на routes.ts, guards.ts, index.ts.
  5. Dashboard page перенесен из src/views в src/pages/dashboard/ui.
  6. ReportsView перенесен в dashboard page slice как вкладка, а не самостоятельная route page.
  7. Dashboard-only widgets перенесены из src/widgets в src/pages/dashboard/ui.
  8. Dashboard store перенесен из src/stores в src/pages/dashboard/model.
  9. Dashboard model разделен на типы, константы, фильтры, empty state, error, chart data, форматтеры, сортировку и tooltip helpers.
  10. Dashboard API wrappers вынесены в src/pages/dashboard/api.
  11. Dashboard UI разделен на композиционные компоненты в src/pages/dashboard/ui/components.
  12. src/style.css превращен в entrypoint, dashboard CSS разнесен по section-файлам.
  13. Generated API imports закрыты public facade @/shared/api.
  14. AppHeader очищен от прямого router/logout ownership, поведение перенесено в ProtectedAppLayout.
  15. Фильтры dashboard разделены на filterDraft.ts, filterDefinitions.ts и CompetenceMapFilterField.vue.
  16. AppSidebar очищен от router/preload/localStorage ownership; поведение перенесено в useProtectedAppSidebarNavigation.
  17. Sidebar storage key мигрирован на analytical-panel:app-sidebar:expanded-group-ids с fallback на legacy key.
  18. Dashboard server state переведен на структурированные Vue Query keys.
  19. Ручной analyticsResponseCache/filtersResponseCache удален.
  20. Auth schema и safe redirect helper вынесены в pages/auth/model.
  21. Logout orchestration перенесен из shared/api в widgets/protected-app-layout/model/useProtectedLogout.ts.
  22. Handoff-документация добавлена: docs/architecture.md, docs/generated-api.md, docs/testing.md, расширен README.md.
  23. Unit/component tests расширены до 18 кейсов.
  24. bun run lint:fsd проходит без ошибок.

Следующий безопасный этап:

  1. Добавить полный component-test flow для CompetenceMapFilters.vue:

    • open/sync draft;
    • apply;
    • reset;
    • cancel;
    • disabled/loading state.
  2. Закрепить dashboard loading/placeholder UX тестами после удаления ручного cache layer.

  3. Продолжить CSS-изоляцию:

    • разнести layout.css, tables.css, composition.css, charts-and-industries.css ближе к компонентам;
    • не менять class names одновременно с переносом.
  4. Добавить dashboard E2E с mocked authorized state и визуальные screenshots для desktop/mobile.