diff --git a/docs/member-statistics/README.md b/docs/member-statistics/README.md new file mode 100644 index 000000000..af362798a --- /dev/null +++ b/docs/member-statistics/README.md @@ -0,0 +1,116 @@ + + +# Статистика участников: DEV + +База Angular: `96a7a676f7d47194fa105fb1748a5bfc28b5b544`. +База backend: `caea57a7c6f2869e7f2522e37c8fc395569b030c`. + +## Данные и архитектура + +Отдельный `GET /auth/public-users/stats/` возвращает `total`, `in_projects`, +`in_programs`, `new_last_30_days`. Общий CamelcaseInterceptor преобразует имена +в `total`, `inProjects`, `inPrograms`, `newLast30Days`; преобразование проверено +интеграционным тестом через настоящий interceptor, adapter, repository и use-case. + +Все метрики относятся к активным legacy `CustomUser` с `user_type=MEMBER`: + +| Подпись | Определение backend | +| ---------------- | ---------------------------------------------------------------------------------------------- | +| Всего участников | Число активных MEMBER | +| В проектах | Уникальные люди, являющиеся лидером или Collaborator проекта с `draft=False`, `is_public=True` | +| В программах | Уникальные люди с PartnerProgramUserProfile программы с `draft=False` | +| Новых за 30 дней | `datetime_created >= timezone.now() - timedelta(days=30)`, включая границу | + +Агрегация выполняется на сервере одним SQL-запросом; количество проверено для +10/100/1000 пользователей и множественных связей. Параметры поисковой выдачи не +передаются в endpoint. Миграций нет; React-модели не используются. + +Цепочка: `MemberStatistics` → `MemberRepositoryPort` → `MemberHttpAdapter` → +`MemberRepository` → `GetMemberStatisticsUseCase` → `MemberStatisticsFacade` → +`MemberStatisticsCardComponent`. + +Репозиторий проверяет четыре неотрицательных целых числа. Ошибочный контракт или +сетевая ошибка не превращаются в нули. Facade предоставляется на уровне страницы, +делает один запрос при открытии и отписывается при уничтожении страницы. +Поиск, фильтры, пагинация и смена layout не обновляют глобальные счётчики. + +Loading/error показывают четыре прочерка и безопасный `role=status`; реальный ноль +остаётся `0`. HTTP body не попадает в UI. Числа форматируются через `ru-RU`, разряды +разделены неразрывными пробелами. Новых зависимостей и постоянного polling нет. + +## Геометрия и responsive + +Проверка выполнена в локальном браузере на реальных Angular-компонентах страницы и +office-оболочке, с одинаковыми синтетическими участниками до/после. Снимки не +представляют реальные данные DEV. Масштаб страницы одинаков; размеры ниже — CSS px. + +| Viewport | Sidebar | Статистика | X сетки до → после | Переполнение по X | +| -------- | ------- | ---------- | ----------------------- | ----------------- | +| 1440 | 157 | 157 × 280 | 365.828125 → 365.828125 | 0 | +| 1280 | 157 | 157 × 280 | 285.828125 → 285.828125 | 0 | +| 1024 | 157 | 157 × 280 | 201.5 → 201.5 | 0 | +| 768 | нет | 721 × 172 | compact layout | 0 | +| 414 | нет | 367 × 172 | compact layout | 0 | +| 390 | нет | 343 × 172 | compact layout | 0 | +| 375 | нет | 328 × 172 | compact layout | 0 | + +На desktop фильтры, кнопка профиля и аватар первой карточки сохраняют прежние +координаты. Карточка участника остаётся 156 × 248 px, аватар — 70 × 70 px. +Sidebar не расширяется; растёт только высота заменённой заглушки статистики. + +Ниже 1000 px порядок: поиск → действия → статистика 2 × 2 → список. +Высота статистики одинакова при loading/error/zero/success: 280 px desktop, +172 px compact. Проверены 0, 9, 63, 999, 1248, 12345, 12500, 999999. +Все значения находятся внутри карточки. Длинные подписи при экстремальных значениях +переносятся, число не переносится. Исходные метрики: [geometry.json](geometry.json). + +## Скриншоты + +| Проверка | Снимок | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop до | [1440](screenshots/before-desktop-1440.jpg), [1280](screenshots/before-desktop-1280.jpg), [1024](screenshots/before-desktop-1024.jpg) | +| Desktop после | [1440](screenshots/after-desktop-1440.jpg), [1280](screenshots/after-desktop-1280.jpg), [1024](screenshots/after-desktop-1024.jpg) | +| Крупный план | [Фрагмент реального снимка 1440, увеличенный ×2](screenshots/statistics-closeup-157px.jpg) | +| Большие числа | [999 999](screenshots/large-values-999999.jpg) | +| Планшет | [768](screenshots/after-tablet-768.jpg) | +| Mobile и первая карточка | [414](screenshots/after-mobile-414.jpg), [390](screenshots/after-mobile-390.jpg), [375](screenshots/after-mobile-375.jpg) | +| Состояния | [Нули](screenshots/value-0-desktop.jpg), [Loading](screenshots/loading-desktop.jpg), [Ошибка](screenshots/error-desktop.jpg) | + +![До](screenshots/before-desktop-1440.jpg) + +![После](screenshots/after-desktop-1440.jpg) + +![Мобильное представление](screenshots/after-mobile-390.jpg) + +## Проверки 27.09.2026 + +Node 20.20.2, зависимости установлены через `npm ci`. + +- Targeted: `npm run test:ci -- --pool=forks projects/social_platform/src/app/ui/pages/members projects/social_platform/src/app/api/member projects/social_platform/src/app/infrastructure/adapters/member projects/social_platform/src/app/infrastructure/repository/member` — **50/50**, 11 файлов. +- Full: `npm run test:ci -- --pool=forks` — **1819/1819**, 391 файл, exit 0; + unhandled errors и ngx-autosize teardown error отсутствуют. +- `npm run lint:ts` — exit 0, шесть прежних предупреждений вне изменённых файлов. +- Scoped Stylelint двух затронутых SCSS — exit 0. +- Scoped Prettier изменённых файлов — exit 0. +- `npm run build:prod` — exit 0; остаются предупреждения Sass/CommonJS существующей сборки. +- `git diff --check` — exit 0. + +Общий `npm run format:check` в Windows checkout с `core.autocrlf=true` сообщает +о CRLF в 1517 файлах; чистая DEV-база в том же режиме — 1524 файла. +Тот же check на чистом экспорте базы с LF проходит. Для итогового diff выполнен +отдельный check экспортированных с LF файлов; настройки форматирования, workflows +и чужие файлы ради этой особенности Windows не менялись. + +В браузере нет console errors. Предупреждение NG0912 о двух существующих +IconComponent воспроизводится в исходной странице и в новой; shared-компоненты +ради него не менялись. + +## Границы и будущий DEV-deploy + +Заменён только SoonCard на странице Members. Общий SoonCard, карточки участников, +навыки, поиск, мобильное окно фильтров и остальные поверхности не изменены. +React, dependencies и workflows untouched. Merge/deploy не выполнялись. + +Сначала потребуется backend с новым endpoint, затем Angular. При отсутствии +endpoint UI показывает безопасные прочерки. Live-проверка нового endpoint на +развёрнутом DEV не выполнялась: работа заканчивается двумя Draft PR. diff --git a/docs/member-statistics/geometry.json b/docs/member-statistics/geometry.json new file mode 100644 index 000000000..cf7df1a3a --- /dev/null +++ b/docs/member-statistics/geometry.json @@ -0,0 +1,972 @@ +{ + "measurements": [ + { + "phase": "before", + "avatar": { + "height": 70, + "width": 70, + "x": 408.828125, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 365.828125, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 1069.828125, + "y": 152.59375 + }, + "grid": { + "height": 546, + "width": 684, + "x": 365.828125, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 162.65625, + "x": 1069.828125, + "y": 117 + }, + "sidebar": { + "height": 297.171875, + "width": 157, + "x": 1069.828125, + "y": 142.59375 + }, + "viewport": 1440 + }, + { + "phase": "before", + "avatar": { + "height": 70, + "width": 70, + "x": 328.828125, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 285.828125, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 989.828125, + "y": 152.59375 + }, + "grid": { + "height": 546, + "width": 684, + "x": 285.828125, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 162.65625, + "x": 989.828125, + "y": 117 + }, + "sidebar": { + "height": 297.171875, + "width": 157, + "x": 989.828125, + "y": 142.59375 + }, + "viewport": 1280 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 408.828125, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 365.828125, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 1069.828125, + "y": 152.59375 + }, + "grid": { + "height": 546, + "width": 684, + "x": 365.828125, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 162.65625, + "x": 1069.828125, + "y": 117 + }, + "rowBorders": ["0px", "1px", "1px", "1px"], + "sidebar": { + "height": 436.1875, + "width": 157, + "x": 1069.828125, + "y": 142.59375 + }, + "statistics": { + "height": 280, + "width": 157, + "x": 1069.828125, + "y": 298.78125 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 33.546875 + }, + { + "inside": true, + "text": "684", + "width": 22.703125 + }, + { + "inside": true, + "text": "214", + "width": 22.703125 + }, + { + "inside": true, + "text": "63", + "width": 15.140625 + } + ], + "viewport": 1440 + }, + { + "phase": "before", + "avatar": { + "height": 70, + "width": 70, + "x": 244.5, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 201.5, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 802, + "y": 152.59375 + }, + "grid": { + "height": 844, + "width": 580.5, + "x": 201.5, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 157, + "x": 802, + "y": 117 + }, + "rowBorders": [], + "sidebar": { + "height": 297.171875, + "width": 157, + "x": 802, + "y": 142.59375 + }, + "statistics": null, + "values": [], + "viewport": 1024 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 328.828125, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 285.828125, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 989.828125, + "y": 152.59375 + }, + "grid": { + "height": 546, + "width": 684, + "x": 285.828125, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 162.65625, + "x": 989.828125, + "y": 117 + }, + "rowBorders": ["0px", "1px", "1px", "1px"], + "sidebar": { + "height": 436.1875, + "width": 157, + "x": 989.828125, + "y": 142.59375 + }, + "statistics": { + "height": 280, + "width": 157, + "x": 989.828125, + "y": 298.78125 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 33.546875 + }, + { + "inside": true, + "text": "684", + "width": 22.703125 + }, + { + "inside": true, + "text": "214", + "width": 22.703125 + }, + { + "inside": true, + "text": "63", + "width": 15.140625 + } + ], + "viewport": 1280 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 244.5, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 201.5, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 802, + "y": 152.59375 + }, + "grid": { + "height": 844, + "width": 580.5, + "x": 201.5, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 157, + "x": 802, + "y": 117 + }, + "rowBorders": ["0px", "1px", "1px", "1px"], + "sidebar": { + "height": 436.1875, + "width": 157, + "x": 802, + "y": 142.59375 + }, + "statistics": { + "height": 280, + "width": 157, + "x": 802, + "y": 298.78125 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 33.546875 + }, + { + "inside": true, + "text": "684", + "width": 22.703125 + }, + { + "inside": true, + "text": "214", + "width": 22.703125 + }, + { + "inside": true, + "text": "63", + "width": 15.140625 + } + ], + "viewport": 1024 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 156.25, + "y": 434 + }, + "card": { + "height": 248, + "width": 350.5, + "x": 16, + "y": 468 + }, + "filters": null, + "grid": { + "height": 1142, + "width": 721, + "x": 16, + "y": 468 + }, + "overflow": 0, + "profile": null, + "rowBorders": ["0px", "0px", "0px", "0px"], + "sidebar": null, + "statistics": { + "height": 172, + "width": 721, + "x": 16, + "y": 241 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 315.5 + }, + { + "inside": true, + "text": "684", + "width": 315.5 + }, + { + "inside": true, + "text": "214", + "width": 315.5 + }, + { + "inside": true, + "text": "63", + "width": 315.5 + } + ], + "viewport": 768 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 164.5, + "y": 434 + }, + "card": { + "height": 248, + "width": 367, + "x": 16, + "y": 468 + }, + "filters": null, + "grid": { + "height": 2036, + "width": 367, + "x": 16, + "y": 468 + }, + "overflow": 0, + "profile": null, + "rowBorders": ["0px", "0px", "0px", "0px"], + "sidebar": null, + "statistics": { + "height": 172, + "width": 367, + "x": 16, + "y": 241 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 138.5 + }, + { + "inside": true, + "text": "684", + "width": 138.5 + }, + { + "inside": true, + "text": "214", + "width": 138.5 + }, + { + "inside": true, + "text": "63", + "width": 138.5 + } + ], + "viewport": 414 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 152.5, + "y": 434 + }, + "card": { + "height": 248, + "width": 343, + "x": 16, + "y": 468 + }, + "filters": null, + "grid": { + "height": 2036, + "width": 343, + "x": 16, + "y": 468 + }, + "overflow": 0, + "profile": null, + "rowBorders": ["0px", "0px", "0px", "0px"], + "sidebar": null, + "statistics": { + "height": 172, + "width": 343, + "x": 16, + "y": 241 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 126.5 + }, + { + "inside": true, + "text": "684", + "width": 126.5 + }, + { + "inside": true, + "text": "214", + "width": 126.5 + }, + { + "inside": true, + "text": "63", + "width": 126.5 + } + ], + "viewport": 390 + }, + { + "phase": "after", + "avatar": { + "height": 70, + "width": 70, + "x": 145, + "y": 434 + }, + "card": { + "height": 248, + "width": 328, + "x": 16, + "y": 468 + }, + "filters": null, + "grid": { + "height": 2036, + "width": 328, + "x": 16, + "y": 468 + }, + "overflow": 0, + "profile": null, + "rowBorders": ["0px", "0px", "0px", "0px"], + "sidebar": null, + "statistics": { + "height": 172, + "width": 328, + "x": 16, + "y": 241 + }, + "values": [ + { + "inside": true, + "text": "1В 248", + "width": 119 + }, + { + "inside": true, + "text": "684", + "width": 119 + }, + { + "inside": true, + "text": "214", + "width": 119 + }, + { + "inside": true, + "text": "63", + "width": 119 + } + ], + "viewport": 375 + }, + { + "phase": "large-999999", + "avatar": { + "height": 70, + "width": 70, + "x": 408.828125, + "y": 184 + }, + "card": { + "height": 248, + "width": 156, + "x": 365.828125, + "y": 218 + }, + "filters": { + "height": 146.1875, + "width": 157, + "x": 1069.828125, + "y": 152.59375 + }, + "grid": { + "height": 546, + "width": 684, + "x": 365.828125, + "y": 218 + }, + "overflow": 0, + "profile": { + "height": 25.59375, + "width": 162.65625, + "x": 1069.828125, + "y": 117 + }, + "rowBorders": ["0px", "1px", "1px", "1px"], + "sidebar": { + "height": 436.1875, + "width": 157, + "x": 1069.828125, + "y": 142.59375 + }, + "statistics": { + "height": 280, + "width": 157, + "x": 1069.828125, + "y": 298.78125 + }, + "values": [ + { + "inside": true, + "text": "999В 999", + "width": 48.6875 + }, + { + "inside": true, + "text": "999В 999", + "width": 48.6875 + }, + { + "inside": true, + "text": "999В 999", + "width": 48.6875 + }, + { + "inside": true, + "text": "999В 999", + "width": 48.6875 + } + ], + "viewport": 1440 + } + ], + "cases": [ + { + "scenario": "value-0", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-9", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "9" + }, + { + "inside": true, + "text": "9" + }, + { + "inside": true, + "text": "9" + }, + { + "inside": true, + "text": "9" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-63", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "63" + }, + { + "inside": true, + "text": "63" + }, + { + "inside": true, + "text": "63" + }, + { + "inside": true, + "text": "63" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-999", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "999" + }, + { + "inside": true, + "text": "999" + }, + { + "inside": true, + "text": "999" + }, + { + "inside": true, + "text": "999" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-1248", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "1В 248" + }, + { + "inside": true, + "text": "1В 248" + }, + { + "inside": true, + "text": "1В 248" + }, + { + "inside": true, + "text": "1В 248" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-12345", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "12В 345" + }, + { + "inside": true, + "text": "12В 345" + }, + { + "inside": true, + "text": "12В 345" + }, + { + "inside": true, + "text": "12В 345" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "value-12500", + "height": 280, + "overflow": 0, + "status": "Статистика участников загружена", + "values": [ + { + "inside": true, + "text": "12В 500" + }, + { + "inside": true, + "text": "12В 500" + }, + { + "inside": true, + "text": "12В 500" + }, + { + "inside": true, + "text": "12В 500" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "loading", + "height": 280, + "overflow": 0, + "status": "Загрузка статистики участников", + "values": [ + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + } + ], + "viewport": 1440, + "width": 157 + }, + { + "scenario": "error", + "height": 280, + "overflow": 0, + "status": "РќРµ удалось загрузить статистику участников", + "values": [ + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + } + ], + "viewport": 1440, + "width": 157 + } + ], + "mobileCases": [ + { + "scenario": "value-999999", + "beforeList": true, + "height": 172, + "overflow": 0, + "values": [ + { + "inside": true, + "text": "999 999" + }, + { + "inside": true, + "text": "999 999" + }, + { + "inside": true, + "text": "999 999" + }, + { + "inside": true, + "text": "999 999" + } + ], + "viewport": 375, + "width": 328 + }, + { + "scenario": "value-0", + "beforeList": true, + "height": 172, + "overflow": 0, + "values": [ + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + }, + { + "inside": true, + "text": "0" + } + ], + "viewport": 375, + "width": 328 + }, + { + "scenario": "loading", + "beforeList": true, + "height": 172, + "overflow": 0, + "values": [ + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + } + ], + "viewport": 375, + "width": 328 + }, + { + "scenario": "error", + "beforeList": true, + "height": 172, + "overflow": 0, + "values": [ + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + }, + { + "inside": true, + "text": "—" + } + ], + "viewport": 375, + "width": 328 + } + ] +} diff --git a/docs/member-statistics/screenshots/after-desktop-1024.jpg b/docs/member-statistics/screenshots/after-desktop-1024.jpg new file mode 100644 index 000000000..a5e87075b Binary files /dev/null and b/docs/member-statistics/screenshots/after-desktop-1024.jpg differ diff --git a/docs/member-statistics/screenshots/after-desktop-1280.jpg b/docs/member-statistics/screenshots/after-desktop-1280.jpg new file mode 100644 index 000000000..d8c4be18f Binary files /dev/null and b/docs/member-statistics/screenshots/after-desktop-1280.jpg differ diff --git a/docs/member-statistics/screenshots/after-desktop-1440.jpg b/docs/member-statistics/screenshots/after-desktop-1440.jpg new file mode 100644 index 000000000..feb23e123 Binary files /dev/null and b/docs/member-statistics/screenshots/after-desktop-1440.jpg differ diff --git a/docs/member-statistics/screenshots/after-mobile-375.jpg b/docs/member-statistics/screenshots/after-mobile-375.jpg new file mode 100644 index 000000000..b12b7a63d Binary files /dev/null and b/docs/member-statistics/screenshots/after-mobile-375.jpg differ diff --git a/docs/member-statistics/screenshots/after-mobile-390.jpg b/docs/member-statistics/screenshots/after-mobile-390.jpg new file mode 100644 index 000000000..1df948f2e Binary files /dev/null and b/docs/member-statistics/screenshots/after-mobile-390.jpg differ diff --git a/docs/member-statistics/screenshots/after-mobile-414.jpg b/docs/member-statistics/screenshots/after-mobile-414.jpg new file mode 100644 index 000000000..2b78aaf3b Binary files /dev/null and b/docs/member-statistics/screenshots/after-mobile-414.jpg differ diff --git a/docs/member-statistics/screenshots/after-tablet-768.jpg b/docs/member-statistics/screenshots/after-tablet-768.jpg new file mode 100644 index 000000000..a451fd305 Binary files /dev/null and b/docs/member-statistics/screenshots/after-tablet-768.jpg differ diff --git a/docs/member-statistics/screenshots/before-desktop-1024.jpg b/docs/member-statistics/screenshots/before-desktop-1024.jpg new file mode 100644 index 000000000..5858642e7 Binary files /dev/null and b/docs/member-statistics/screenshots/before-desktop-1024.jpg differ diff --git a/docs/member-statistics/screenshots/before-desktop-1280.jpg b/docs/member-statistics/screenshots/before-desktop-1280.jpg new file mode 100644 index 000000000..d1971f355 Binary files /dev/null and b/docs/member-statistics/screenshots/before-desktop-1280.jpg differ diff --git a/docs/member-statistics/screenshots/before-desktop-1440.jpg b/docs/member-statistics/screenshots/before-desktop-1440.jpg new file mode 100644 index 000000000..e721f511f Binary files /dev/null and b/docs/member-statistics/screenshots/before-desktop-1440.jpg differ diff --git a/docs/member-statistics/screenshots/error-desktop.jpg b/docs/member-statistics/screenshots/error-desktop.jpg new file mode 100644 index 000000000..ebce74450 Binary files /dev/null and b/docs/member-statistics/screenshots/error-desktop.jpg differ diff --git a/docs/member-statistics/screenshots/large-values-999999.jpg b/docs/member-statistics/screenshots/large-values-999999.jpg new file mode 100644 index 000000000..a40bec0ee Binary files /dev/null and b/docs/member-statistics/screenshots/large-values-999999.jpg differ diff --git a/docs/member-statistics/screenshots/loading-desktop.jpg b/docs/member-statistics/screenshots/loading-desktop.jpg new file mode 100644 index 000000000..ebce74450 Binary files /dev/null and b/docs/member-statistics/screenshots/loading-desktop.jpg differ diff --git a/docs/member-statistics/screenshots/statistics-closeup-157px.jpg b/docs/member-statistics/screenshots/statistics-closeup-157px.jpg new file mode 100644 index 000000000..e143573c2 Binary files /dev/null and b/docs/member-statistics/screenshots/statistics-closeup-157px.jpg differ diff --git a/docs/member-statistics/screenshots/value-0-desktop.jpg b/docs/member-statistics/screenshots/value-0-desktop.jpg new file mode 100644 index 000000000..dbb2374d9 Binary files /dev/null and b/docs/member-statistics/screenshots/value-0-desktop.jpg differ diff --git a/projects/social_platform/src/app/api/member/facades/member-statistics.facade.spec.ts b/projects/social_platform/src/app/api/member/facades/member-statistics.facade.spec.ts new file mode 100644 index 000000000..4556d8234 --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/member-statistics.facade.spec.ts @@ -0,0 +1,74 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { Subject, throwError } from "rxjs"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { GetMemberStatisticsUseCase } from "../use-cases/get-member-statistics.use-case"; +import { MemberStatisticsFacade } from "./member-statistics.facade"; + +describe("Состояние глобальной статистики участников", () => { + const data = { total: 1248, inProjects: 684, inPrograms: 214, newLast30Days: 63 }; + let response: Subject>; + let execute: ReturnType; + beforeEach(() => { + response = new Subject(); + execute = vi.fn(() => response); + TestBed.configureTestingModule({ + providers: [ + MemberStatisticsFacade, + { provide: GetMemberStatisticsUseCase, useValue: { execute } }, + ], + }); + }); + + it("остаётся loading до ответа и выполняет только один запрос за жизнь страницы", () => { + const facade = TestBed.inject(MemberStatisticsFacade); + expect(facade.state().status).toBe("loading"); + facade.load(); + facade.load(); + expect(execute).toHaveBeenCalledOnce(); + response.next(ok(data)); + expect(facade.state()).toEqual({ status: "success", data }); + }); + + it("сохраняет ошибку и не запускает бесконечные повторы", () => { + const facade = TestBed.inject(MemberStatisticsFacade); + facade.load(); + response.next(fail("member_statistics_error")); + facade.load(); + expect(facade.state().status).toBe("failure"); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("отписывается при уходе со страницы: поздний ответ не меняет прежнее состояние", () => { + const facade = TestBed.inject(MemberStatisticsFacade); + facade.load(); + TestBed.resetTestingModule(); + expect(response.observed).toBe(false); + response.next(ok(data)); + expect(facade.state().status).toBe("loading"); + }); + + it.each(["network", "HTTP 500 private raw body"])( + "use-case скрывает техническую ошибку %s", + message => { + TestBed.overrideProvider(GetMemberStatisticsUseCase, { + useFactory: () => new GetMemberStatisticsUseCase(), + }); + TestBed.configureTestingModule({ + providers: [ + { + provide: MemberRepositoryPort, + useValue: { getStatistics: () => throwError(() => new Error(message)) }, + }, + ], + }); + const facade = TestBed.inject(MemberStatisticsFacade); + facade.load(); + expect(facade.state()).toMatchObject({ status: "failure", error: "member_statistics_error" }); + expect(JSON.stringify(facade.state())).not.toContain(message); + }, + ); +}); diff --git a/projects/social_platform/src/app/api/member/facades/member-statistics.facade.ts b/projects/social_platform/src/app/api/member/facades/member-statistics.facade.ts new file mode 100644 index 000000000..df3e9c13c --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/member-statistics.facade.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AsyncState, failure, loading, success } from "@domain/shared/async-state"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; +import { GetMemberStatisticsUseCase } from "../use-cases/get-member-statistics.use-case"; + +/** Состояние статистики живёт вместе со страницей, отдельно от поисковой выдачи. */ +@Injectable() +export class MemberStatisticsFacade { + private readonly getStatistics = inject(GetMemberStatisticsUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly current = signal>(loading()); + readonly state = this.current.asReadonly(); + private started = false; + + /** Один запрос на открытие страницы; фильтры и смена layout не запускают его повторно. */ + load(): void { + if (this.started) return; + this.started = true; + this.getStatistics + .execute() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.current.set(result.ok ? success(result.value) : failure(result.error)); + }); + } +} diff --git a/projects/social_platform/src/app/api/member/use-cases/get-member-statistics.use-case.ts b/projects/social_platform/src/app/api/member/use-cases/get-member-statistics.use-case.ts new file mode 100644 index 000000000..59ef87715 --- /dev/null +++ b/projects/social_platform/src/app/api/member/use-cases/get-member-statistics.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +/** Сценарий чтения четырёх агрегатов с безопасной ошибкой для UI. */ +@Injectable({ providedIn: "root" }) +export class GetMemberStatisticsUseCase { + private readonly repository = inject(MemberRepositoryPort); + + /** Ошибка запроса/контракта остаётся ошибкой, а не набором нулей. */ + execute(): Observable> { + return this.repository.getStatistics().pipe( + map(data => ok(data)), + catchError(() => of(fail("member_statistics_error" as const))), + ); + } +} diff --git a/projects/social_platform/src/app/domain/member/member-statistics.model.ts b/projects/social_platform/src/app/domain/member/member-statistics.model.ts new file mode 100644 index 000000000..32c18600c --- /dev/null +++ b/projects/social_platform/src/app/domain/member/member-statistics.model.ts @@ -0,0 +1,12 @@ +/** + * Глобальные счётчики активных участников, независимые от выдачи каталога. + * + * @format + */ + +export interface MemberStatistics { + readonly total: number; + readonly inProjects: number; + readonly inPrograms: number; + readonly newLast30Days: number; +} diff --git a/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts b/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts index c833cf54a..61446d59a 100644 --- a/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts +++ b/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts @@ -3,9 +3,13 @@ import { Observable } from "rxjs"; import { ApiPagination } from "../../other/api-pagination.model"; import { User } from "../../auth/user.model"; +import { MemberStatistics } from "../member-statistics.model"; -/** Порт репозитория участников: список members (skip/take/фильтры) и mentors. */ +/** Порт каталога: фильтруемый список и отдельная глобальная статистика участников. */ export abstract class MemberRepositoryPort { + /** Получает агрегаты без параметров поиска и пагинации. */ + abstract getStatistics(): Observable; + abstract getMembers( skip: number, take: number, diff --git a/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts index 1e4de3ccc..e108f541d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts @@ -6,6 +6,7 @@ import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { User } from "@domain/auth/user.model"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; /** HTTP-адаптер участников: `/auth/public-users` (фильтр user_type разделяет members/mentors). */ @Injectable({ providedIn: "root" }) @@ -13,6 +14,11 @@ export class MemberHttpAdapter { private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; private readonly apiService = inject(ApiService); + /** Отдельный GET без фильтров; snake_case преобразует общий CamelcaseInterceptor. */ + getStatistics(): Observable { + return this.apiService.get(`${this.AUTH_PUBLIC_USERS_URL}/stats/`); + } + getMembers( skip: number, take: number, diff --git a/projects/social_platform/src/app/infrastructure/adapters/member/member-statistics-contract.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/member/member-statistics-contract.spec.ts new file mode 100644 index 000000000..392857400 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/member/member-statistics-contract.spec.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom } from "rxjs"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { API_URL } from "@core/lib/providers"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { MemberRepository } from "../../repository/member/member.repository"; +import { GetMemberStatisticsUseCase } from "@api/member/use-cases/get-member-statistics.use-case"; + +describe("Контракт глобальной статистики участников", () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "https://local.test" }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + { provide: MemberRepositoryPort, useExisting: MemberRepository }, + ], + }), + ); + afterEach(() => TestBed.inject(HttpTestingController).verify()); + + function request() { + const result = firstValueFrom(TestBed.inject(GetMemberStatisticsUseCase).execute()); + const http = TestBed.inject(HttpTestingController).expectOne( + "https://local.test/auth/public-users/stats/", + ); + expect(http.request.method).toBe("GET"); + expect(http.request.params.keys()).toEqual([]); + return { result, http }; + } + + it("передаёт четыре snake_case агрегата через interceptor, репозиторий и use-case без фильтров списка", async () => { + const { result, http } = request(); + http.flush({ total: 1248, in_projects: 684, in_programs: 214, new_last_30_days: 63 }); + expect(await result).toEqual({ + ok: true, + value: { total: 1248, inProjects: 684, inPrograms: 214, newLast30Days: 63 }, + }); + }); + + it("сохраняет реальные нули", async () => { + const { result, http } = request(); + http.flush({ total: 0, in_projects: 0, in_programs: 0, new_last_30_days: 0 }); + expect(await result).toEqual({ + ok: true, + value: { total: 0, inProjects: 0, inPrograms: 0, newLast30Days: 0 }, + }); + }); + + it.each([ + { total: 4 }, + { total: 4, in_projects: -1, in_programs: 0, new_last_30_days: 0 }, + { total: 4, in_projects: 1.5, in_programs: 0, new_last_30_days: 0 }, + { total: "4", in_projects: 0, in_programs: 0, new_last_30_days: 0 }, + ])("не подменяет неверный контракт нулями: %j", async payload => { + const { result, http } = request(); + http.flush(payload); + expect(await result).toEqual({ ok: false, error: "member_statistics_error" }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts b/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts index bb78a3717..4cdc72ed5 100644 --- a/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts @@ -7,12 +7,26 @@ import { ApiPagination } from "@domain/other/api-pagination.model"; import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; import { MemberHttpAdapter } from "../../adapters/member/member-http.adapter"; import { userFromRaw } from "@utils/userRaw"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; -/** Репозиторий участников: passthrough + `plainToInstance(User)`. */ +/** Преобразует участников в domain-модель и проверяет контракт глобальных счётчиков. */ @Injectable({ providedIn: "root" }) export class MemberRepository implements MemberRepositoryPort { private readonly memberAdapter = inject(MemberHttpAdapter); + /** Не превращает неполный или некорректный контракт в ложные нулевые показатели. */ + getStatistics(): Observable { + return this.memberAdapter.getStatistics().pipe( + map(data => { + const values = [data?.total, data?.inProjects, data?.inPrograms, data?.newLast30Days]; + if (values.some(value => !Number.isSafeInteger(value) || value < 0)) { + throw new Error("Некорректный ответ статистики участников"); + } + return data; + }), + ); + } + getMembers( skip: number, take: number, diff --git a/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.html b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.html new file mode 100644 index 000000000..4e728a2c9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.html @@ -0,0 +1,21 @@ + + +
+

Статистика

+
+ @for (row of rows(); track row.key) { +
+ +
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ {{ status() }} +
diff --git a/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.scss b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.scss new file mode 100644 index 000000000..f720f7def --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.scss @@ -0,0 +1,130 @@ +/** @format */ + +@use "styles/responsive"; + +:host { + box-sizing: border-box; + display: block; + width: 100%; + min-width: 0; + max-width: 157px; +} + +.statistics { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 157px; + padding: 10px; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + &__title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + line-height: 20px; + } + + &__metrics { + margin: 0; + } + + &__row { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 4px; + align-items: center; + min-width: 0; + min-height: 58px; + } + + &__row + &__row { + border-top: 1px solid var(--medium-grey-for-outline); + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + color: var(--accent-dark); + background: var(--accent-light); + border-radius: var(--rounded-lg); + } + + &__label { + min-width: 0; + font-size: 9px; + line-height: 13px; + color: var(--grey-for-text); + overflow-wrap: anywhere; + } + + &__value { + min-width: 0; + margin: 0; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 18px; + color: var(--accent-dark); + text-align: right; + white-space: nowrap; + } + + &__status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} + +@media (max-width: #{responsive.$desktop - 1px}) { + :host, + .statistics { + max-width: none; + } + + .statistics { + &__metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + } + + &__row { + grid-template-columns: 22px minmax(0, 1fr); + row-gap: 2px; + column-gap: 6px; + padding: 6px 0; + border: 0; + } + + &__row + &__row { + border-top: 0; + } + + &__icon { + grid-row: 1 / 3; + } + + &__value { + grid-row: 1; + grid-column: 2; + text-align: left; + } + + &__label { + grid-row: 2; + grid-column: 2; + font-size: 10px; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.spec.ts new file mode 100644 index 000000000..67fd4897a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.spec.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { failure, loading, success } from "@domain/shared/async-state"; +import { MemberStatisticsCardComponent } from "./member-statistics-card.component"; + +describe("Компактная статистика участников", () => { + function card(state: ReturnType | ReturnType) { + const fixture = TestBed.createComponent(MemberStatisticsCardComponent); + fixture.componentRef.setInput("state", state); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + return { + fixture, + root, + values: () => Array.from(root.querySelectorAll("dd"), item => item.textContent!.trim()), + }; + } + + it.each([loading(), failure("raw secret HTTP body")])( + "показывает прочерки и безопасное доступное состояние: $status", + state => { + const { root, values } = card(state); + expect(values()).toEqual(["—", "—", "—", "—"]); + expect(root.textContent).not.toContain("raw secret"); + expect(root.querySelector('[role="status"]')!.textContent).toContain( + state.status === "failure" ? "Не удалось загрузить" : "Загрузка", + ); + expect(root.querySelector("section")!.getAttribute("aria-busy")).toBe( + String(state.status === "loading"), + ); + }, + ); + + it.each([0, 9, 63, 999, 1248, 12345, 12500, 999999])( + "выводит четыре реальных значения %i, без процентов и лишних действий", + value => { + const { root, values } = card( + success({ total: value, inProjects: value, inPrograms: value, newLast30Days: value }), + ); + expect(values()).toEqual(Array(4).fill(new Intl.NumberFormat("ru-RU").format(value))); + expect(Array.from(root.querySelectorAll("dt"), item => item.textContent!.trim())).toEqual([ + "Всего участников", + "В проектах", + "В программах", + "Новых за 30 дней", + ]); + expect(root.querySelectorAll("button, a")).toHaveLength(0); + expect(root.textContent).not.toMatch(/скоро|Подробнее|эффективность|%/); + }, + ); + + it("обновляет данные в той же структуре карточки после loading и ошибки", () => { + const { fixture, root, values } = card(loading()); + const section = root.querySelector("section"); + fixture.componentRef.setInput( + "state", + success({ total: 0, inProjects: 9, inPrograms: 63, newLast30Days: 999 }), + ); + fixture.detectChanges(); + expect(values()).toEqual(["0", "9", "63", "999"]); + fixture.componentRef.setInput("state", failure("hidden")); + fixture.detectChanges(); + expect(values()).toEqual(["—", "—", "—", "—"]); + expect(root.querySelector("section")).toBe(section); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.ts b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.ts new file mode 100644 index 000000000..a426ded16 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/member-statistics-card/member-statistics-card.component.ts @@ -0,0 +1,64 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; +import { MemberStatistics } from "@domain/member/member-statistics.model"; +import { AsyncState } from "@domain/shared/async-state"; +import { IconComponent } from "@ui/primitives/icon/icon.component"; + +/** Четыре глобальные метрики: одна разметка для узкого sidebar и мобильной сетки. */ +@Component({ + selector: "app-member-statistics-card", + imports: [IconComponent], + templateUrl: "./member-statistics-card.component.html", + styleUrl: "./member-statistics-card.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MemberStatisticsCardComponent { + readonly state = input.required>(); + private readonly formatter = new Intl.NumberFormat("ru-RU"); + protected readonly rows = computed(() => { + const state = this.state(); + const value = (key: keyof MemberStatistics) => + state.status === "success" ? this.formatter.format(state.data[key]) : "—"; + return [ + { + key: "total", + label: "Всего участников", + icon: "people", + viewBox: "0 0 24 24", + value: value("total"), + }, + { + key: "projects", + label: "В проектах", + icon: "projects", + viewBox: "0 0 10 11", + value: value("inProjects"), + }, + { + key: "programs", + label: "В программах", + icon: "academic-hat", + viewBox: "0 0 28 15", + value: value("inPrograms"), + }, + { + key: "new", + label: "Новых за 30 дней", + icon: "graph", + viewBox: "0 0 12 12", + value: value("newLast30Days"), + }, + ]; + }); + protected readonly status = computed(() => { + switch (this.state().status) { + case "success": + return "Статистика участников загружена"; + case "failure": + return "Не удалось загрузить статистику участников"; + default: + return "Загрузка статистики участников"; + } + }); +} diff --git a/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts b/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts index 07e8d1020..eaa6b045b 100644 --- a/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts +++ b/projects/social_platform/src/app/ui/pages/members/members-mobile.spec.ts @@ -6,7 +6,10 @@ import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { provideRouter, Router } from "@angular/router"; import { BreakpointObserver } from "@angular/cdk/layout"; import { OverlayContainer } from "@angular/cdk/overlay"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of } from "rxjs"; +import { MemberStatisticsFacade } from "@api/member/facades/member-statistics.facade"; +import { GetMemberStatisticsUseCase } from "@api/member/use-cases/get-member-statistics.use-case"; +import { ok } from "@domain/shared/result.type"; import { MembersInfoService } from "@api/member/facades/members-info.service"; import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; @@ -20,12 +23,16 @@ import { MembersFiltersComponent } from "./members-filters/members-filters.compo describe("Мобильное представление участников", () => { const layout = new BehaviorSubject({ matches: true, breakpoints: {} }); const redirect = vi.fn(); + const statistics = { total: 1248, inProjects: 684, inPrograms: 214, newLast30Days: 63 }; + const loadStatistics = vi.fn(); beforeEach(async () => { layout.next({ matches: true, breakpoints: {} }); redirect.mockReset(); + loadStatistics.mockReset().mockReturnValue(of(ok(statistics))); await TestBed.configureTestingModule({ imports: [MembersComponent], providers: [ + { provide: GetMemberStatisticsUseCase, useValue: { execute: loadStatistics } }, provideRouter([]), provideNoopAnimations(), { provide: BreakpointObserver, useValue: { observe: () => layout, isMatched: () => true } }, @@ -43,6 +50,7 @@ describe("Мобильное представление участников", ( .overrideComponent(MembersComponent, { set: { providers: [ + MemberStatisticsFacade, MembersUIInfoService, { provide: MembersInfoService, @@ -89,6 +97,29 @@ describe("Мобильное представление участников", ( return primitive; } + it("держит статистику перед мобильным списком, под desktop-фильтрами и не перезагружает её при поиске", async () => { + const f = await page(); + const ui = f.debugElement.injector.get(MembersUIInfoService); + const card = f.nativeElement.querySelector("app-member-statistics-card") as HTMLElement; + const list = f.nativeElement.querySelector(".page__list") as HTMLElement; + expect(card.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(card.textContent).toContain("1 248"); + expect(f.nativeElement.querySelector("app-soon-card")).toBeNull(); + ui.searchForm.patchValue({ search: "Иван" }); + ui.filterForm.patchValue({ keySkill: "Java", speciality: "Разработчик" }); + layout.next({ matches: false, breakpoints: {} }); + await f.whenStable(); + const sidebar = f.nativeElement.querySelector(".page__left") as HTMLElement; + const filters = sidebar.querySelector("app-members-filters")!; + const desktopCard = sidebar.querySelector("app-member-statistics-card")!; + expect( + filters.compareDocumentPosition(desktopCard) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(desktopCard.textContent).toContain("1 248"); + expect(loadStatistics).toHaveBeenCalledExactlyOnceWith(); + await finish(f); + }); + it("использует прежний search control и переход в профиль, не держит фильтры рядом со списком", async () => { const f = await page(); const ui = f.debugElement.injector.get(MembersUIInfoService); diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.html b/projects/social_platform/src/app/ui/pages/members/members.component.html index d16afe0fb..201dad06f 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.html +++ b/projects/social_platform/src/app/ui/pages/members/members.component.html @@ -28,8 +28,8 @@ Перейти в профиль - + } @else {
@@ -70,7 +70,7 @@
- +
@if (filtersOpen() && compactLayout()) { diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.scss b/projects/social_platform/src/app/ui/pages/members/members.component.scss index 2fbc8d98c..909e5a4c5 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.scss +++ b/projects/social_platform/src/app/ui/pages/members/members.component.scss @@ -1,3 +1,5 @@ +/** @format */ + @use "styles/responsive"; .page { @@ -125,8 +127,9 @@ } &__statistics { - width: 157px; - margin: 4px auto 0; + width: 100%; + min-width: 0; + margin: 4px 0 0; } } } diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts b/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts index 79776770d..a07b84cbf 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts @@ -13,6 +13,8 @@ import { initial } from "@domain/shared/async-state"; import { FormBuilder } from "@angular/forms"; import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; +import { GetMemberStatisticsUseCase } from "@api/member/use-cases/get-member-statistics.use-case"; +import { ok } from "@domain/shared/result.type"; describe("MembersComponent", () => { let component: MembersComponent; @@ -52,6 +54,12 @@ describe("MembersComponent", () => { await TestBed.configureTestingModule({ imports: [MembersComponent], providers: [ + { + provide: GetMemberStatisticsUseCase, + useValue: { + execute: () => of(ok({ total: 0, inProjects: 0, inPrograms: 0, newLast30Days: 0 })), + }, + }, { provide: AuthRepositoryPort, useValue: authPortSpy }, { provide: SkillsRepositoryPort, diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.ts b/projects/social_platform/src/app/ui/pages/members/members.component.ts index a81a3ec41..9c79340e5 100644 --- a/projects/social_platform/src/app/ui/pages/members/members.component.ts +++ b/projects/social_platform/src/app/ui/pages/members/members.component.ts @@ -22,7 +22,8 @@ import { MembersFiltersComponent } from "./members-filters/members-filters.compo import { MemberCardComponent } from "./member-card/member-card.component"; import { BackComponent } from "@uilib"; import { ButtonComponent } from "@ui/primitives"; -import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { MemberStatisticsCardComponent } from "./member-statistics-card/member-statistics-card.component"; +import { MemberStatisticsFacade } from "@api/member/facades/member-statistics.facade"; import { MembersInfoService } from "@api/member/facades/members-info.service"; import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; import { AppRoutes } from "@api/paths/app-routes"; @@ -43,16 +44,23 @@ import { MemberFiltersDialogComponent } from "./member-filters-dialog/member-fil MemberCardComponent, BackComponent, ButtonComponent, - SoonCardComponent, + MemberStatisticsCardComponent, MemberFiltersDialogComponent, ], - providers: [MembersInfoService, MembersUIInfoService, ProfileDetailUIInfoService], + providers: [ + MembersInfoService, + MembersUIInfoService, + ProfileDetailUIInfoService, + MemberStatisticsFacade, + ], }) export class MembersComponent implements OnInit, AfterViewInit { readonly membersRoot = viewChild | undefined>("membersRoot"); // Ссылка на корневой элемент списка private readonly membersInfoService = inject(MembersInfoService); private readonly membersUIInfoService = inject(MembersUIInfoService); + private readonly statisticsFacade = inject(MemberStatisticsFacade); + protected readonly statisticsState = this.statisticsFacade.state; protected readonly members = this.membersUIInfoService.members; @@ -86,6 +94,7 @@ export class MembersComponent implements OnInit, AfterViewInit { ngOnInit(): void { this.membersInfoService.initializationMembers(); + this.statisticsFacade.load(); } ngAfterViewInit(): void {