From 853fdb71f91955fb9f4df82d1836ef260d07bc34 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 19:33:27 +0300 Subject: [PATCH] Release only chat analytics changes without moving program navigation --- package-lock.json | 19 + package.json | 1 + .../program-analytics-drilldown.service.ts | 17 +- .../program-analytics-info.service.spec.ts | 21 + .../detail/program-analytics-info.service.ts | 10 + .../program-case-projects.service.spec.ts | 86 +- .../detail/program-case-projects.service.ts | 49 +- .../program-widget-presentation.spec.ts | 34 +- .../program/program-widget-presentation.ts | 21 +- .../export-program-case-projects.use-case.ts | 10 +- ...program-manager-attention.use-case.spec.ts | 9 +- ...ger-participants-without-visit.use-case.ts | 44 + .../program-case-projects.use-case.spec.ts | 17 +- .../program/ports/program.repository.port.ts | 12 +- .../program-analytics-attention.model.ts | 2 +- .../domain/program/program-analytics.model.ts | 12 +- .../program/program-case-analytics.model.ts | 10 +- .../program/program-http.adapter.spec.ts | 22 +- .../adapters/program/program-http.adapter.ts | 34 +- .../repository/program/program.repository.ts | 17 +- .../activity-chart.component.html | 40 + .../activity-chart.component.scss | 79 + .../activity-chart.component.spec.ts | 139 ++ .../activity-chart.component.ts | 288 +++ .../detail/analytics/analytics.component.html | 1535 ++++--------- .../detail/analytics/analytics.component.scss | 649 ++---- .../analytics/analytics.component.spec.ts | 399 +++- .../detail/analytics/analytics.component.ts | 237 +- .../analytics-attention-route.spec.ts | 5 +- .../analytics-attention.component.spec.ts | 54 +- .../analytics-case.component.spec.ts | 58 +- .../analytics-drilldown.component.html | 1940 ++++++----------- .../analytics-drilldown.component.scss | 156 +- .../analytics-drilldown.component.spec.ts | 30 +- .../analytics-drilldown.component.ts | 11 +- .../funnel-chart/funnel-chart.component.html | 35 + .../funnel-chart/funnel-chart.component.scss | 64 + .../funnel-chart.component.spec.ts | 95 + .../funnel-chart/funnel-chart.component.ts | 198 ++ .../region-chart/region-chart.component.html | 38 + .../region-chart/region-chart.component.scss | 81 + .../region-chart.component.spec.ts | 98 + .../region-chart/region-chart.component.ts | 234 ++ .../program-role-widget.component.html | 22 +- .../program-role-widget.component.spec.ts | 44 +- .../program-role-widget.component.ts | 2 - .../src/app/utils/program-deadlines.spec.ts | 94 + .../src/app/utils/program-deadlines.ts | 97 + .../assets/icons/svg/analytics-submitted.svg | 4 + .../src/assets/icons/svg/analytics-team.svg | 7 + .../assets/icons/symbol/svg/sprite.css.svg | 2 +- 51 files changed, 3898 insertions(+), 3284 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-visit.use-case.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.ts create mode 100644 projects/social_platform/src/app/utils/program-deadlines.spec.ts create mode 100644 projects/social_platform/src/app/utils/program-deadlines.ts create mode 100644 projects/social_platform/src/assets/icons/svg/analytics-submitted.svg create mode 100644 projects/social_platform/src/assets/icons/svg/analytics-team.svg diff --git a/package-lock.json b/package-lock.json index 5c7c50643..7dc07dddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@angular/router": "^20.3.23", "@sentry/angular": "^10.55.0", "camelcase-keys": "^8.0.1", + "chart.js": "4.5.1", "class-transformer": "^0.5.1", "core-js": "^3.23.4", "dayjs": "^1.11.3", @@ -6009,6 +6010,12 @@ "tslib": "2" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -11703,6 +11710,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", diff --git a/package.json b/package.json index d93fe22ad..5b3abdd58 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@angular/router": "^20.3.23", "@sentry/angular": "^10.55.0", "camelcase-keys": "^8.0.1", + "chart.js": "4.5.1", "class-transformer": "^0.5.1", "core-js": "^3.23.4", "dayjs": "^1.11.3", diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts index 034299301..74db7de95 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts @@ -8,6 +8,7 @@ import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.m import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutVisitUseCase } from "@api/program/use-cases/get-program-manager-participants-without-visit.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { @@ -27,6 +28,7 @@ import { /** Root-списки внимания используют тот же modal context, не вложенную модалку. */ export type AnalyticsAttentionView = + | "participants-without-visit" | "participants-without-team" | "projects-awaiting-evaluation" | "projects-not-submitted"; @@ -58,6 +60,7 @@ export class ProgramAnalyticsDrilldownService { private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); + private readonly getWithoutVisit = inject(GetProgramManagerParticipantsWithoutVisitUseCase); private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase); private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase); private readonly destroyRef = inject(DestroyRef); @@ -89,14 +92,18 @@ export class ProgramAnalyticsDrilldownService { readonly notSubmittedPage = signal(null); readonly attentionPending = signal(false); readonly attentionError = signal(null); + readonly isParticipantView = computed( + () => + this.view() === "participants-without-team" || this.view() === "participants-without-visit", + ); readonly isAttentionView = computed( () => - this.view() === "participants-without-team" || + this.isParticipantView() || this.view() === "projects-awaiting-evaluation" || this.view() === "projects-not-submitted", ); readonly attentionPage = computed(() => - this.view() === "participants-without-team" + this.isParticipantView() ? this.participantsPage() : this.view() === "projects-not-submitted" ? this.notSubmittedPage() @@ -167,8 +174,10 @@ export class ProgramAnalyticsDrilldownService { limit: this.attentionLimit, offset: this.attentionOffset(), }; - if (this.view() === "participants-without-team") { - this.getParticipants + if (this.isParticipantView()) { + const useCase = + this.view() === "participants-without-visit" ? this.getWithoutVisit : this.getParticipants; + useCase .execute(this.programId, query) .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts index 9d8d2b11b..1d37320f0 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts @@ -96,6 +96,21 @@ describe("ProgramAnalyticsInfoService", () => { expect(service.error()?.kind).toBe("forbidden"); expect(getOverview.execute).not.toHaveBeenCalled(); + expect(service.scheduleDates()).toBeNull(); + }); + + it("reads schedule dates from the authorized resolved program without extra API requests", () => { + const dates = { + datetimeRegistrationEnds: "2026-10-14T18:00:00+03:00", + datetimeProjectSubmissionEnds: "", + datetimeEvaluationEnds: "2026-10-20T18:00:00+03:00", + }; + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true, ...dates }); + expect(service.scheduleDates()).toBeNull(); + service.initialize(); + TestBed.tick(); + expect(service.scheduleDates()).toEqual(dates); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); }); it("сохраняет loading до завершения manager overview", () => { @@ -129,6 +144,7 @@ describe("ProgramAnalyticsInfoService", () => { expect(old.observed).toBe(false); expect(service.programId()).toBeNull(); expect(service.data()).toBeNull(); + expect(service.scheduleDates()).toBeNull(); expect(service.pending()).toBe(true); expect(programUI.program()?.id).toBe(12); expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); @@ -138,6 +154,11 @@ describe("ProgramAnalyticsInfoService", () => { programUI.program.set({ ...Program.default(), id: 13, isUserManager: true }); TestBed.tick(); expect(service.programId()).toBe(13); + expect(service.scheduleDates()).toEqual({ + datetimeRegistrationEnds: "", + datetimeProjectSubmissionEnds: "", + datetimeEvaluationEnds: "", + }); expect(getOverview.execute).toHaveBeenCalledTimes(2); expect(getOverview.execute).toHaveBeenLastCalledWith(13); old.next( diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts index 0f1058cfd..c36a57475 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts @@ -21,6 +21,7 @@ import { import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; import { distinctUntilChanged, map, Subject, takeUntil } from "rxjs"; import { Program } from "@domain/program/program.model"; +import { ProgramScheduleDates } from "@utils/program-deadlines"; /** Состояние и загрузка внутренней вкладки аналитики программы. */ @Injectable() @@ -35,6 +36,15 @@ export class ProgramAnalyticsInfoService { private loadedProgram: Program | undefined; readonly programId = signal(null); readonly programName = computed(() => this.programUI.program()?.name ?? "program"); + readonly scheduleDates = computed(() => { + const program = this.programUI.program(); + if (!program?.isUserManager || program.id !== this.programId()) return null; + return { + datetimeRegistrationEnds: program.datetimeRegistrationEnds, + datetimeProjectSubmissionEnds: program.datetimeProjectSubmissionEnds, + datetimeEvaluationEnds: program.datetimeEvaluationEnds, + }; + }); constructor() { this.route.parent?.paramMap diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts index 125eb9d70..82e90d0d3 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts @@ -15,7 +15,7 @@ import { ProgramCaseProjectsService } from "./program-case-projects.service"; vi.mock("@utils/program-case-analytics", () => ({ saveCaseProjects: vi.fn() })); -describe("Case drilldown: контекст, поиск, страницы и независимый export", () => { +describe("Case drilldown: контекст, фильтры, страницы и export найденных", () => { const get = { execute: vi.fn() }; const download = { execute: vi.fn() }; let service: ProgramCaseProjectsService; @@ -43,7 +43,7 @@ describe("Case drilldown: контекст, поиск, страницы и не pending.next(ok(casePage())); expect(service.canExport()).toBe(true); }); - it("search сбрасывает страницу; метрики/export сохраняют полный bucket при no results", () => { + it("search сбрасывает страницу, сохраняет метрики кейса и запрещает пустую выгрузку", () => { service.open(12, selectedCase); service.offset.set(25); service.searchDraft.set(" неизвестный проект "); @@ -52,14 +52,16 @@ describe("Case drilldown: контекст, поиск, страницы и не expect(get.execute).toHaveBeenLastCalledWith(12, { selection: selectedCase, search: "неизвестный проект", + submission: "all", + presentation: "all", limit: 25, offset: 0, }); expect(service.page()?.count).toBe(0); expect(service.metrics()).toEqual(caseMetrics); - expect(service.canExport()).toBe(true); + expect(service.canExport()).toBe(false); service.download(); - expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase); + expect(download.execute).not.toHaveBeenCalled(); service.clearSearch(); expect(get.execute.mock.lastCall?.[1].search).toBe(""); }); @@ -137,7 +139,7 @@ describe("Case drilldown: контекст, поиск, страницы и не } }, ); - it("поиск отменяет предыдущий list, но не прерывает full-case export", () => { + it("поиск отменяет предыдущий list и файл с устаревшим отбором", () => { const first = new Subject(), next = new Subject(), file = new Subject(); @@ -151,9 +153,9 @@ describe("Case drilldown: контекст, поиск, страницы и не service.searchDraft.set("Новый"); service.applySearch(); expect(first.observed).toBe(false); - expect(file.observed).toBe(true); + expect(file.observed).toBe(false); file.next(ok(new Blob(["xlsx"]))); - expect(saveCaseProjects).toHaveBeenCalledTimes(1); + expect(saveCaseProjects).not.toHaveBeenCalled(); next.next(ok(casePage())); }); it("ошибка/retry сохраняет контекст, а отказ прав выключает export", () => { @@ -170,6 +172,68 @@ describe("Case drilldown: контекст, поиск, страницы и не expect(service.exportError()?.kind).toBe("forbidden"); expect(service.canExport()).toBe(false); }); + it("combines filters and search, resets pagination, and exports all matching rows", () => { + service.open(12, selectedCase); + service.offset.set(25); + service.searchDraft.set(" Проект "); + service.setSubmission("submitted"); + service.setPresentation("missing"); + const filters = { search: "Проект", submission: "submitted", presentation: "missing" }; + expect(get.execute).toHaveBeenLastCalledWith(12, { + selection: selectedCase, + ...filters, + limit: 25, + offset: 0, + }); + service.download(); + expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase, filters); + service.clearFilters(); + expect(service.hasFilters()).toBe(false); + expect(get.execute.mock.lastCall?.[1]).toMatchObject({ + search: "", + submission: "all", + presentation: "all", + offset: 0, + }); + }); + it("changing pages preserves a download with the same filters", () => { + get.execute.mockReturnValue( + of( + ok( + casePage({ + count: 26, + results: Array.from({ length: 25 }, (_, i) => caseProject({ programProjectId: i + 1 })), + }), + ), + ), + ); + service.open(12, selectedCase); + const file = new Subject(); + download.execute.mockReturnValue(file); + service.download(); + get.execute.mockReturnValue(of(ok(casePage({ count: 26, results: [caseProject()] })))); + service.changePage(1); + expect(service.offset()).toBe(25); + expect(file.observed).toBe(true); + expect(service.exportPending()).toBe(true); + file.next(ok(new Blob(["xlsx"]))); + expect(saveCaseProjects).toHaveBeenCalledTimes(1); + }); + it("filter changes cancel stale downloads and keep export disabled while loading", () => { + service.open(12, selectedCase); + const file = new Subject(); + const next = new Subject(); + download.execute.mockReturnValue(file); + service.download(); + get.execute.mockReturnValue(next); + service.setPresentation("present"); + expect(file.observed).toBe(false); + expect(service.canExport()).toBe(false); + file.next(ok(new Blob(["stale"]))); + expect(saveCaseProjects).not.toHaveBeenCalled(); + next.next(ok(casePage())); + expect(service.canExport()).toBe(true); + }); it("all проверяет одну строку, но выгружает весь scope", () => { get.execute.mockReturnValue( of(ok(casePage({ selection: { scope: "all", caseName: null }, caseMetrics: null }))), @@ -178,10 +242,16 @@ describe("Case drilldown: контекст, поиск, страницы и не expect(get.execute).toHaveBeenCalledExactlyOnceWith(12, { selection: { scope: "all" }, search: "", + submission: "all", + presentation: "all", offset: 0, limit: 1, }); service.download(); - expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, { scope: "all" }); + expect(download.execute).toHaveBeenCalledExactlyOnceWith( + 12, + { scope: "all" }, + { search: "", submission: "all", presentation: "all" }, + ); }); }); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts index d1b0dbd71..6b0602060 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts @@ -6,6 +6,8 @@ import { GetProgramCaseProjectsUseCase } from "@api/program/use-cases/get-progra import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; import { ProgramCaseError, + ProgramCasePresentationFilter, + ProgramCaseSubmissionFilter, ProgramCasePage, ProgramCaseSelection, } from "@domain/program/program-case-analytics.model"; @@ -30,14 +32,28 @@ export class ProgramCaseProjectsService { readonly error = signal(null); readonly searchDraft = signal(""); readonly search = signal(""); + readonly submission = signal("all"); + readonly presentation = signal("all"); + readonly filters = computed(() => ({ + search: this.search(), + submission: this.submission(), + presentation: this.presentation(), + })); + readonly hasFilters = computed( + () => + !!this.searchDraft() || + !!this.search() || + this.submission() !== "all" || + this.presentation() !== "all", + ); readonly offset = signal(0); readonly limit = 25; readonly exportPending = signal(false); readonly exportError = signal(null); private readonly confirmed = signal(false); - private readonly total = signal(0); readonly canExport = computed( - () => this.confirmed() && this.total() > 0 && !this.exportPending(), + () => + this.confirmed() && !this.pending() && (this.page()?.count ?? 0) > 0 && !this.exportPending(), ); readonly hasNext = computed(() => { const page = this.page(); @@ -75,13 +91,14 @@ export class ProgramCaseProjectsService { const selection = this.selection(); if (this.programId === null || !selection) return; this.cancelList.next(); + this.confirmed.set(false); this.page.set(null); this.pending.set(true); this.error.set(null); this.getProjects .execute(this.programId, { selection, - search: this.search(), + ...this.filters(), limit: selection.scope === "all" ? 1 : this.limit, offset: this.offset(), }) @@ -92,7 +109,6 @@ export class ProgramCaseProjectsService { this.page.set(result.value); this.metrics.set(result.value.caseMetrics); this.submissionApplicable.set(result.value.submissionApplicable); - this.total.set(result.value.caseMetrics?.projectsTotal ?? result.value.count); this.confirmed.set(true); } else { this.error.set(result.error); @@ -104,6 +120,9 @@ export class ProgramCaseProjectsService { } applySearch(): void { + this.cancelExport.next(); + this.exportPending.set(false); + this.exportError.set(null); this.search.set(this.searchDraft().trim()); this.offset.set(0); this.load(); @@ -112,6 +131,21 @@ export class ProgramCaseProjectsService { this.searchDraft.set(""); this.applySearch(); } + setSubmission(value: string): void { + if (value !== "all" && value !== "submitted" && value !== "not_submitted") return; + this.submission.set(value); + this.applySearch(); + } + setPresentation(value: string): void { + if (value !== "all" && value !== "present" && value !== "missing") return; + this.presentation.set(value); + this.applySearch(); + } + clearFilters(): void { + this.submission.set("all"); + this.presentation.set("all"); + this.clearSearch(); + } changePage(direction: -1 | 1): void { if (this.pending() || (direction === 1 ? !this.hasNext() : this.offset() === 0)) return; this.offset.update(offset => Math.max(0, offset + direction * this.limit)); @@ -125,7 +159,7 @@ export class ProgramCaseProjectsService { this.exportPending.set(true); this.exportError.set(null); this.exportProjects - .execute(programId, selection) + .execute(programId, selection, this.filters()) .pipe(takeUntil(this.cancelExport), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { this.exportPending.set(false); @@ -137,7 +171,7 @@ export class ProgramCaseProjectsService { }); } - /** Поиск отменяет только list; смена кейса/программы и закрытие отменяют также файл. */ + /** Смена отбора и закрытие отменяют устаревшие запросы списка и файла. */ reset(): void { this.cancelList.next(); this.cancelExport.next(); @@ -150,10 +184,11 @@ export class ProgramCaseProjectsService { this.error.set(null); this.searchDraft.set(""); this.search.set(""); + this.submission.set("all"); + this.presentation.set("all"); this.offset.set(0); this.exportPending.set(false); this.exportError.set(null); this.confirmed.set(false); - this.total.set(0); } } diff --git a/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts b/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts index 03f5fb9ca..dc476213b 100644 --- a/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts +++ b/projects/social_platform/src/app/api/program/program-widget-presentation.spec.ts @@ -126,16 +126,28 @@ describe("Program widget presentation", () => { ); expect(result.text).toBe("Свободное оценивание. Срок оценивания истёк"); }); - it.each(["none", "not_submitted", "not_applicable"] as const)( - "keeps inactive stages for %s", - stage => - expect(participantSteps(stage).every(step => !step.current && !step.completed)).toBe(true), + it.each([ + ["none", false, [false, false, false], "Создан"], + ["not_submitted", true, [true, false, false], "Отправлен"], + ["submitted", true, [true, true, false], "Проверен"], + ["review", true, [true, true, false], "Проверен"], + ["evaluated", true, [true, true, true], null], + ["not_applicable", false, [false, false, false], "Создан"], + ["not_applicable", true, [true, false, false], null], + ] as const)( + "shows completed milestones and the next step for %s (project: %s)", + (stage, hasProject, completed, current) => { + const steps = participantSteps(stage, hasProject); + expect(steps.map(step => step.label)).toEqual(["Создан", "Отправлен", "Проверен"]); + expect(steps.map(step => step.completed)).toEqual(completed); + expect(steps.filter(step => step.current).map(step => step.label)).toEqual( + current ? [current] : [], + ); + expect(steps.some(step => step.current && step.completed)).toBe(false); + }, + ); + it.each(["not_submitted", "submitted", "review", "evaluated"] as const)( + "never fabricates completed milestones without a project, even with stale stage %s", + stage => expect(participantSteps(stage, false).every(step => !step.completed)).toBe(true), ); - it("distinguishes current and completed stages", () => { - expect(participantSteps("submitted").map(s => s.completed)).toEqual([false, false, false]); - expect(participantSteps("review").map(s => s.completed)).toEqual([true, false, false]); - expect(participantSteps("evaluated").map(s => s.completed)).toEqual([true, true, true]); - for (const stage of ["submitted", "review", "evaluated"] as const) - expect(participantSteps(stage).filter(s => s.current && s.completed)).toHaveLength(0); - }); }); diff --git a/projects/social_platform/src/app/api/program/program-widget-presentation.ts b/projects/social_platform/src/app/api/program/program-widget-presentation.ts index ca1040f59..30fe68792 100644 --- a/projects/social_platform/src/app/api/program/program-widget-presentation.ts +++ b/projects/social_platform/src/app/api/program/program-widget-presentation.ts @@ -112,12 +112,17 @@ export function expertWidgetPresentation( ); } -/** Проверка остаётся текущей без галочки; серверный evaluated завершает всю цепочку. */ -export function participantSteps(stage: ParticipantStage) { - const current = ["submitted", "review", "evaluated"].indexOf(stage); - return ["Отправлен", "Проверка", "Оценён"].map((label, index) => ({ - label, - current: stage !== "evaluated" && current === index, - completed: stage === "evaluated" || index < current, - })); +/** Галочки отмечают факты: проект в программе, отправка и серверный evaluated. */ +export function participantSteps(stage: ParticipantStage, hasProject: boolean) { + const submitted = hasProject && ["submitted", "review", "evaluated"].includes(stage); + const evaluated = hasProject && stage === "evaluated"; + return [ + { label: "Создан", current: !hasProject, completed: hasProject }, + { + label: "Отправлен", + current: hasProject && !submitted && stage !== "not_applicable", + completed: submitted, + }, + { label: "Проверен", current: submitted && !evaluated, completed: evaluated }, + ]; } diff --git a/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts index 778a1af34..7c63b2f73 100644 --- a/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts @@ -3,6 +3,7 @@ import { inject, Injectable } from "@angular/core"; import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; import { ProgramCaseError, + ProgramCaseFilters, ProgramCaseSelection, } from "@domain/program/program-case-analytics.model"; import { fail, ok, Result } from "@domain/shared/result.type"; @@ -17,15 +18,18 @@ export class ExportProgramCaseProjectsUseCase { private readonly repository = inject(ProgramRepositoryPort); private readonly getProjects = inject(GetProgramCaseProjectsUseCase); - /** Повторная проверка контракта перед файлом; search/offset/limit не попадают в export. */ + /** Проверяет тот же отбор перед XLSX; файл содержит все совпадения без пагинации. */ execute( programId: number, selection: ProgramCaseSelection, + filters: ProgramCaseFilters = {}, ): Observable> { - return this.getProjects.execute(programId, { selection, limit: 1, offset: 0 }).pipe( + return this.getProjects.execute(programId, { selection, ...filters, limit: 1, offset: 0 }).pipe( switchMap(result => result.ok - ? this.repository.exportCaseProjects(programId, selection).pipe(map(blob => ok(blob))) + ? this.repository + .exportCaseProjects(programId, selection, filters) + .pipe(map(blob => ok(blob))) : of(fail(result.error)), ), catchError((error: unknown) => of(fail(caseRequestError(error)))), diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts index a196e456a..3d6036bd7 100644 --- a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts @@ -12,6 +12,7 @@ import { notSubmittedPage, } from "@domain/program/program-analytics-attention.fixture"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutVisitUseCase } from "./get-program-manager-participants-without-visit.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "./get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "./get-program-manager-projects-not-submitted.use-case"; @@ -28,6 +29,12 @@ describe("Attention: adapter → repository → use cases", () => { }); for (const [method, endpoint, useCase, page] of [ + [ + "getManagerParticipantsWithoutVisit", + "participants-without-visit", + GetProgramManagerParticipantsWithoutVisitUseCase, + participantsPage(), + ], [ "getManagerProjectsNotSubmitted", "projects-not-submitted", @@ -50,6 +57,7 @@ describe("Attention: adapter → repository → use cases", () => { const injectUseCase = () => TestBed.inject< | GetProgramManagerParticipantsWithoutTeamUseCase + | GetProgramManagerParticipantsWithoutVisitUseCase | GetProgramManagerProjectsAwaitingEvaluationUseCase | GetProgramManagerProjectsNotSubmittedUseCase >(useCase); @@ -69,7 +77,6 @@ describe("Attention: adapter → repository → use cases", () => { expect(api.get).toHaveBeenCalledTimes(2); const [url, params] = api.get.mock.lastCall! as [string, HttpParams]; expect(url).toBe(`/programs/12/project-analytics/${endpoint}/`); - expect(url).not.toContain("manager-overview"); expect(params.get("search")).toBe("Анна"); expect(params.get("limit")).toBe("25"); expect(params.get("offset")).toBe("25"); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-visit.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-visit.use-case.ts new file mode 100644 index 000000000..e401fe8a2 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-visit.use-case.ts @@ -0,0 +1,44 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Уникальные участники программы без зафиксированного посещения платформы. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerParticipantsWithoutVisitUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable< + Result< + ProgramAnalyticsAttentionPage, + ProgramAnalyticsError + > + > { + return this.repository.getManagerParticipantsWithoutVisit(programId, query).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts index f521f1954..88e4bb641 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts @@ -87,7 +87,22 @@ describe("Case analytics: проверка контракта перед list/ex limit: 1, offset: 0, }); - expect(repository.exportCaseProjects).toHaveBeenCalledExactlyOnceWith(12, selection); + expect(repository.exportCaseProjects).toHaveBeenCalledExactlyOnceWith(12, selection, {}); + }); + it("preflight and export use identical filters without the current pagination", async () => { + const filters = { search: "Проект", submission: "submitted", presentation: "missing" } as const; + await firstValueFrom(download.execute(12, selectedCase, filters)); + expect(repository.getCaseProjects).toHaveBeenCalledExactlyOnceWith(12, { + selection: selectedCase, + ...filters, + limit: 1, + offset: 0, + }); + expect(repository.exportCaseProjects).toHaveBeenCalledExactlyOnceWith( + 12, + selectedCase, + filters, + ); }); it("ошибка самого XLSX остаётся контролируемой", async () => { repository.exportCaseProjects.mockReturnValue( diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 8f52a372a..4c4044624 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -3,6 +3,7 @@ import { HttpParams } from "@angular/common/http"; import { ProgramCaseQuery, + ProgramCaseFilters, ProgramCaseSelection, } from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "../program-role-widget.model"; @@ -33,7 +34,11 @@ import { /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { abstract getCaseProjects(programId: number, query: ProgramCaseQuery): Observable; - abstract exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable; + abstract exportCaseProjects( + programId: number, + selection: ProgramCaseSelection, + filters?: ProgramCaseFilters, + ): Observable; /** Сводка текущего пользователя без обращения к manager-only API. */ abstract getRoleWidget(programId: number): Observable; @@ -47,6 +52,11 @@ export abstract class ProgramRepositoryPort { abstract getManagerOverview(programId: number): Observable; + abstract getManagerParticipantsWithoutVisit( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable>; + /** Уникальные участники без команды текущей программы, серверный поиск/пагинация. */ abstract getManagerParticipantsWithoutTeam( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts index d0f2b27d0..e9b875ef2 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts @@ -15,7 +15,7 @@ export type ProgramAnalyticsAttentionPage = Omit, "next" | " previous: string | null; }; -/** Уникальный участник без команды в этой программе, не признак «ищет команду». +/** Уникальный участник списка внимания программы, не признак «ищет команду». * registeredAt — регистрация в программе, city сохраняет legacy-значения backend. */ export interface ProgramAnalyticsAttentionParticipant { diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index 55833d728..b2629be65 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -13,6 +13,8 @@ export interface ProgramAnalyticsOverview { participantFunnel: { registrations: number; uniqueParticipants: number; + /** Recorded platform activity or login; absent during a staggered API rollout. */ + visitedParticipants?: number; withTeam: number; projectCreators: number; submittedProjectCreators: number; @@ -39,6 +41,8 @@ export interface ProgramAnalyticsOverview { }; }; attention: { + /** Registered users with neither recorded activity nor login; optional during rollout. */ + participantsWithoutVisit?: number; participantsWithoutTeam: number; projectsAwaitingEvaluation: number; /** Несданные связи: требование сдачи применимо только к конкурсной программе. */ @@ -156,8 +160,8 @@ export interface ProgramAnalyticsAssignmentScoreDetail extends ProgramAnalyticsA scores: ProgramAnalyticsAssignmentCriterion[]; } -/** Severity и порядок authoritative: warning — >=2 ожиданий 24ч, critical — >=1 48ч. - * Заглавная H соответствует преобразованию overdue_24h/overdue_48h interceptor-ом. +/** Backend включает эксперта за 5 суток до дедлайна: warning до него, critical после. + * overdue24H/overdue48H сохранены для совместимости и не определяют сигнал. */ export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmentExpert { assignmentsTotal: number; @@ -165,8 +169,8 @@ export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmen pending: number; overdue24H: number; overdue48H: number; - oldestWaitingSince: string; - oldestWaitingSeconds: number; + oldestWaitingSince: string | null; + oldestWaitingSeconds: number | null; severity: "warning" | "critical"; } diff --git a/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts index 8df122723..b1ecc7c7e 100644 --- a/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts +++ b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts @@ -5,9 +5,15 @@ export type ProgramCaseSelection = | { scope: "selected"; caseName: string } | { scope: "all"; caseName?: never } | { scope: "without_case"; caseName?: never }; -export interface ProgramCaseQuery { - selection: ProgramCaseSelection; +export type ProgramCaseSubmissionFilter = "all" | "submitted" | "not_submitted"; +export type ProgramCasePresentationFilter = "all" | "present" | "missing"; +export interface ProgramCaseFilters { search?: string; + submission?: ProgramCaseSubmissionFilter; + presentation?: ProgramCasePresentationFilter; +} +export interface ProgramCaseQuery extends ProgramCaseFilters { + selection: ProgramCaseSelection; limit?: number; offset?: number; } diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index 8f8c9e8ff..21396ead6 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -55,7 +55,7 @@ describe("ProgramHttpAdapter", () => { }); it.each(["all", "without_case", "selected"] as const)( - "case XLSX %s не принимает search/pagination", + "case XLSX %s без фильтров сохраняет прежний отбор", scope => { setup(); api.getFile.mockReturnValue(of(new Blob())); @@ -73,6 +73,26 @@ describe("ProgramHttpAdapter", () => { }, ); + it("list and export encode identical combined filters; export omits pagination", () => { + setup(); + api.get.mockReturnValue(of({})); + api.getFile.mockReturnValue(of(new Blob())); + const selection = { scope: "selected", caseName: "Кейс & A" } as const; + const filters = { + search: " Проект ", + submission: "submitted", + presentation: "missing", + } as const; + adapter.getCaseProjects(12, { selection, ...filters, limit: 25, offset: 50 }).subscribe(); + adapter.exportCaseProjects(12, selection, filters).subscribe(); + const list = api.get.mock.lastCall[1] as HttpParams; + const file = api.getFile.mock.lastCall[1] as HttpParams; + expect(file.get("search")).toBe("Проект"); + expect(file.get("submission")).toBe("submitted"); + expect(file.get("presentation")).toBe("missing"); + expect(file.has("limit") || file.has("offset")).toBe(false); + for (const key of file.keys()) expect(file.get(key)).toBe(list.get(key)); + }); it("getAll идёт в GET /programs/ c limit/offset и дополнительными фильтрами", () => { setup(); api.get.mockReturnValue(of(pagination())); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 3c29a247f..55f7747d4 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -7,6 +7,7 @@ import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ProgramCaseQuery, + ProgramCaseFilters, ProgramCaseSelection, } from "@domain/program/program-case-analytics.model"; import { ApiPagination } from "@domain/other/api-pagination.model"; @@ -36,24 +37,30 @@ import { @Injectable({ providedIn: "root" }) export class ProgramHttpAdapter { getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { - let params = this.caseParams(query.selection) + const params = this.caseParams(query.selection, query) .set("limit", query.limit ?? 25) .set("offset", query.offset ?? 0); - if (query.search?.trim()) params = params.set("search", query.search.trim()); return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/`, params); } - exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { + exportCaseProjects( + programId: number, + selection: ProgramCaseSelection, + filters: ProgramCaseFilters = {}, + ): Observable { return this.apiService.getFile( `${this.PROGRAMS_URL}/${programId}/export-projects/`, - this.caseParams(selection), + this.caseParams(selection, filters), ); } - private caseParams(selection: ProgramCaseSelection): HttpParams { - const params = new HttpParams() - .set("view", "case_analytics") - .set("case_scope", selection.scope); + private caseParams(selection: ProgramCaseSelection, filters: ProgramCaseFilters): HttpParams { + let params = new HttpParams().set("view", "case_analytics").set("case_scope", selection.scope); + if (filters.search?.trim()) params = params.set("search", filters.search.trim()); + if (filters.submission && filters.submission !== "all") + params = params.set("submission", filters.submission); + if (filters.presentation && filters.presentation !== "all") + params = params.set("presentation", filters.presentation); return selection.scope === "selected" ? params.set("case_name", selection.caseName) : params; } @@ -88,6 +95,17 @@ export class ProgramHttpAdapter { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/project-analytics/`); } + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ + getManagerParticipantsWithoutVisit( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/participants-without-visit/`, + this.attentionParams(query), + ); + } + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ getManagerParticipantsWithoutTeam( programId: number, diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 1e0b5c2d6..638b9e57a 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -3,6 +3,7 @@ import { HttpParams } from "@angular/common/http"; import { ProgramCaseQuery, + ProgramCaseFilters, ProgramCaseSelection, } from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; @@ -44,8 +45,12 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.programAdapter.getCaseProjects(programId, query); } - exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { - return this.programAdapter.exportCaseProjects(programId, selection); + exportCaseProjects( + programId: number, + selection: ProgramCaseSelection, + filters?: ProgramCaseFilters, + ): Observable { + return this.programAdapter.exportCaseProjects(programId, selection, filters); } /** Не кеширует ролевую сводку: прежние данные не должны переживать смену контекста. */ @@ -82,6 +87,14 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.programAdapter.getManagerOverview(programId); } + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ + getManagerParticipantsWithoutVisit( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.programAdapter.getManagerParticipantsWithoutVisit(programId, query); + } + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ getManagerParticipantsWithoutTeam( programId: number, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.html new file mode 100644 index 000000000..fb90ba92b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.html @@ -0,0 +1,40 @@ + + +@for (item of series; track item.key) { +
+

{{ item.label }}

+
+ +
+
+} +

+ @if (selectedPoint(); as point) { + {{ dateLabel(point.date) }} + Регистрации {{ point.registrations }} + Решения {{ point.submittedSolutions }} + } @else { + Наведите указатель или выберите дату стрелками на клавиатуре + } +

diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.scss new file mode 100644 index 000000000..d091a146e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.scss @@ -0,0 +1,79 @@ +/** @format */ + +:host { + display: block; + min-width: 0; + margin-top: 18px; +} + +.activity-chart { + &__series { + --activity-series-color: var(--accent); + --activity-series-fill: var(--ui-accent-tint-chart, rgb(109 40 255 / 8%)); + + min-width: 0; + + &--solutions { + --activity-series-color: var(--green-dark); + --activity-series-fill: rgb(41 115 115 / 8%); + + margin-top: 16px; + } + } + + &__title { + display: flex; + gap: 7px; + align-items: center; + margin: 0 0 6px; + font-size: var(--ui-font-small, 12px); + font-weight: 500; + line-height: 18px; + color: var(--black); + + span { + width: 9px; + height: 9px; + background: var(--activity-series-color); + border-radius: 3px; + } + } + + &__plot { + position: relative; + min-width: 0; + height: 130px; + border-radius: 4px; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } + } + + &__readout { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + min-height: 36px; + padding-top: 12px; + margin-top: 14px; + font-size: var(--ui-font-small, 12px); + line-height: 18px; + color: var(--grey-for-text); + border-top: 1px solid var(--medium-grey-for-outline); + + strong, + b { + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--black); + } + + @media screen and (max-width: 420px) { + strong { + flex-basis: 100%; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.spec.ts new file mode 100644 index 000000000..db98ed6cf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.spec.ts @@ -0,0 +1,139 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { Chart } from "chart.js"; +import { AnalyticsActivityChartComponent } from "./activity-chart.component"; + +vi.mock("chart.js", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + Chart: Object.assign( + vi.fn(function (_canvas, config) { + return { + data: config.data, + options: config.options, + update: vi.fn(), + draw: vi.fn(), + destroy: vi.fn(), + resize: vi.fn(), + setActiveElements: vi.fn(), + }; + }), + { register: vi.fn() }, + ), + }; +}); + +describe("AnalyticsActivityChartComponent", () => { + const points = [ + { date: "2026-08-01", registrations: 0, submittedSolutions: 1 }, + { date: "2026-08-02", registrations: 11, submittedSolutions: 2 }, + { date: "2026-08-03", registrations: 3, submittedSolutions: 0 }, + ]; + beforeEach(() => { + vi.mocked(Chart).mockClear(); + TestBed.configureTestingModule({ imports: [AnalyticsActivityChartComponent] }); + }); + async function render() { + const fixture = TestBed.createComponent(AnalyticsActivityChartComponent); + fixture.componentRef.setInput("points", points); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture; + } + it("keeps exact daily counts in two filled charts with the same zero-based integer scale", async () => { + const fixture = await render(); + const configs = vi.mocked(Chart).mock.calls.map(call => call[1]); + expect(configs).toHaveLength(2); + expect(configs.map(config => config.data.datasets[0].data)).toEqual([ + [0, 11, 3], + [1, 2, 0], + ]); + configs.forEach(config => { + expect(config.type).toBe("line"); + expect(config.data.labels).toEqual(["01.08", "02.08", "03.08"]); + expect(config.options?.scales?.["y"]).toMatchObject({ + min: 0, + max: 12, + ticks: { stepSize: 3 }, + }); + expect(config.data.datasets[0]).toMatchObject({ fill: true, pointRadius: 0, tension: 0 }); + }); + expect(fixture.nativeElement.querySelectorAll('[role="slider"]')).toHaveLength(2); + }); + it("synchronizes keyboard selection across both graphs and clamps at the date boundaries", async () => { + const fixture = await render(); + const plots = fixture.nativeElement.querySelectorAll( + '[role="slider"]', + ) as NodeListOf; + const key = async (value: string) => { + plots[0].dispatchEvent(new KeyboardEvent("keydown", { key: value })); + fixture.detectChanges(); + await fixture.whenStable(); + }; + plots[0].dispatchEvent(new FocusEvent("focus")); + await key("ArrowRight"); + expect(plots[1].getAttribute("aria-valuetext")).toContain( + "02.08: новые регистрации — 11, отправленные решения — 2", + ); + vi.mocked(Chart).mock.results.forEach(result => { + expect(result.value.setActiveElements).toHaveBeenLastCalledWith([ + { datasetIndex: 0, index: 1 }, + ]); + }); + await key("End"); + await key("ArrowRight"); + expect(plots[1].getAttribute("aria-valuenow")).toBe("3"); + await key("Home"); + await key("ArrowLeft"); + expect(plots[1].getAttribute("aria-valuenow")).toBe("1"); + await key("Escape"); + expect(fixture.nativeElement.textContent).toContain("Наведите указатель"); + vi.mocked(Chart).mock.results.forEach(result => + expect(result.value.setActiveElements).toHaveBeenLastCalledWith([]), + ); + }); + it("selects one date from pointer interaction and preserves it while live counts update", async () => { + const fixture = await render(); + const config = vi.mocked(Chart).mock.calls[1][1]; + config.options!.onHover!( + null as never, + [{ datasetIndex: 0, index: 1 }] as never, + null as never, + ); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector(".activity-chart__readout").textContent).toContain( + "02.08", + ); + fixture.componentRef.setInput( + "points", + points.map(point => ({ ...point, registrations: point.registrations + 1 })), + ); + fixture.detectChanges(); + await fixture.whenStable(); + expect(Chart).toHaveBeenCalledTimes(2); + expect(vi.mocked(Chart).mock.results[0].value.data.datasets[0].data).toEqual([1, 12, 4]); + expect( + fixture.nativeElement.querySelector('[role="slider"]').getAttribute("aria-valuetext"), + ).toContain("регистрации — 12"); + fixture.destroy(); + vi.mocked(Chart).mock.results.forEach(result => + expect(result.value.destroy).toHaveBeenCalledOnce(), + ); + }); + it("clears a removed date and keeps a single observation visible", async () => { + const fixture = await render(); + const plot = fixture.nativeElement.querySelector('[role="slider"]') as HTMLElement; + plot.dispatchEvent(new KeyboardEvent("keydown", { key: "End" })); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentRef.setInput("points", [points[0]]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain("Наведите указатель"); + expect(vi.mocked(Chart).mock.results[0].value.data.datasets[0].pointRadius).toBe(3); + expect(plot.getAttribute("aria-valuemax")).toBe("1"); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.ts new file mode 100644 index 000000000..92d191112 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/activity-chart/activity-chart.component.ts @@ -0,0 +1,288 @@ +/** @format */ + +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + NgZone, + signal, + untracked, + viewChildren, +} from "@angular/core"; +import { ProgramAnalyticsActivityPoint } from "@domain/program/program-analytics.model"; +import { + CategoryScale, + Chart, + Filler, + LinearScale, + LineController, + LineElement, + Plugin, + PointElement, + ScaleOptions, + Tooltip, +} from "chart.js"; + +Chart.register( + LineController, + LineElement, + PointElement, + Filler, + CategoryScale, + LinearScale, + Tooltip, +); + +@Component({ + selector: "app-analytics-activity-chart", + templateUrl: "./activity-chart.component.html", + styleUrl: "./activity-chart.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsActivityChartComponent { + readonly points = input.required(); + + protected readonly series = [ + { key: "registrations", label: "Новые регистрации" }, + { key: "submittedSolutions", label: "Отправленные решения" }, + ] as const; + protected readonly selectedDate = signal(null); + protected readonly selectedIndex = computed(() => { + const index = this.points().findIndex(point => point.date === this.selectedDate()); + return index < 0 ? null : index; + }); + protected readonly selectedPoint = computed(() => { + const index = this.selectedIndex(); + return index === null ? null : this.points()[index]; + }); + protected readonly selectionLabel = computed(() => { + const point = this.selectedPoint() ?? this.points()[0]; + return point + ? `${this.dateLabel(point.date)}: новые регистрации — ${point.registrations}, отправленные решения — ${point.submittedSolutions}` + : "Нет данных"; + }); + + private readonly canvases = viewChildren>("canvas"); + private readonly zone = inject(NgZone); + private readonly charts: Chart<"line", number[], string>[] = []; + + constructor() { + afterRenderEffect(() => { + const points = this.points(); + const canvases = this.canvases(); + this.zone.runOutsideAngular(() => { + untracked(() => + canvases.forEach((canvas, index) => + this.renderChart(canvas.nativeElement, points, index), + ), + ); + }); + }); + afterRenderEffect(() => { + const index = this.selectedIndex(); + this.zone.runOutsideAngular(() => { + this.charts.forEach(chart => { + chart.setActiveElements(index === null ? [] : [{ datasetIndex: 0, index }]); + if (index === null) chart.tooltip?.setActiveElements([], { x: 0, y: 0 }); + chart.draw(); + }); + }); + }); + inject(DestroyRef).onDestroy(() => this.charts.forEach(chart => chart.destroy())); + } + + protected dateLabel(date: string): string { + const [, month, day] = date.slice(0, 10).split("-"); + return `${day}.${month}`; + } + + protected selectFirstDate(): void { + if (this.selectedIndex() === null) this.selectIndex(0); + } + + protected onKeydown(event: KeyboardEvent): void { + const last = this.points().length - 1; + if (last < 0) return; + const current = this.selectedIndex() ?? 0; + let next: number; + switch (event.key) { + case "ArrowLeft": + case "ArrowDown": + next = Math.max(0, current - 1); + break; + case "ArrowRight": + case "ArrowUp": + next = Math.min(last, current + 1); + break; + case "Home": + next = 0; + break; + case "End": + next = last; + break; + case "Escape": + event.preventDefault(); + this.selectedDate.set(null); + return; + default: + return; + } + event.preventDefault(); + this.selectIndex(next); + } + + private selectIndex(index: number): void { + const point = this.points()[index]; + if (point) this.zone.run(() => this.selectedDate.set(point.date)); + } + + private renderChart( + canvas: HTMLCanvasElement, + points: readonly ProgramAnalyticsActivityPoint[], + seriesIndex: number, + ): void { + const series = this.series[seriesIndex]; + const values = points.map(point => point[series.key]); + const labels = points.map(point => this.dateLabel(point.date)); + const maximum = Math.max( + 1, + ...points.flatMap(point => [point.registrations, point.submittedSolutions]), + ); + const step = Math.max(1, Math.ceil(maximum / 4)); + const style = getComputedStyle(canvas); + const color = style.getPropertyValue(seriesIndex === 0 ? "--accent" : "--green-dark").trim(); + const fill = style.getPropertyValue("--activity-series-fill").trim(); + const muted = style.getPropertyValue("--grey-for-text").trim(); + const grid = style.getPropertyValue("--medium-grey-for-outline").trim(); + const text = style.getPropertyValue("--black").trim(); + const surface = style.getPropertyValue("--light-white").trim(); + const font = { family: style.fontFamily, size: 11 }; + const existing = this.charts[seriesIndex]; + if (existing) { + existing.data.labels = labels; + existing.data.datasets[0].data = values; + existing.data.datasets[0].pointRadius = points.length === 1 ? 3 : 0; + const y = existing.options.scales!["y"] as ScaleOptions<"linear">; + y.max = step * 4; + y.ticks!.stepSize = step; + existing.tooltip?.setActiveElements([], { x: 0, y: 0 }); + const selected = this.selectedIndex(); + existing.setActiveElements(selected === null ? [] : [{ datasetIndex: 0, index: selected }]); + existing.update("none"); + return; + } + + const guide: Plugin<"line"> = { + id: "activity-date-guide", + afterDatasetsDraw: chart => { + const index = this.selectedIndex(); + if (index === null) return; + const point = chart.getDatasetMeta(0).data[index]; + if (!point) return; + const { ctx, chartArea } = chart; + ctx.save(); + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(point.x, chartArea.top); + ctx.lineTo(point.x, chartArea.bottom); + ctx.stroke(); + ctx.restore(); + }, + }; + + const chart = new Chart(canvas, { + type: "line", + data: { + labels, + datasets: [ + { + label: series.label, + data: values, + borderColor: color, + backgroundColor: fill, + borderWidth: 2, + fill: true, + tension: 0, + pointRadius: points.length === 1 ? 3 : 0, + pointHoverRadius: 4, + pointHoverBorderWidth: 2, + pointHoverBorderColor: surface, + pointHoverBackgroundColor: color, + clip: false, + }, + ], + }, + plugins: [guide], + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + layout: { padding: { top: 6, right: 8, left: 4 } }, + interaction: { mode: "index", intersect: false, axis: "x" }, + onHover: (_event, active) => { + if (active.length) this.selectIndex(active[0].index); + }, + onClick: (_event, active) => { + if (active.length) this.selectIndex(active[0].index); + }, + plugins: { + tooltip: { + backgroundColor: text, + titleColor: surface, + bodyColor: surface, + padding: 10, + cornerRadius: 8, + displayColors: false, + titleFont: font, + bodyFont: font, + callbacks: { + title: contexts => this.dateLabel(this.points()[contexts[0].dataIndex].date), + }, + }, + }, + scales: { + x: { + border: { display: false }, + grid: { display: false }, + ticks: { + color: muted, + font, + autoSkip: false, + maxRotation: 0, + callback: (_value, index) => { + const count = this.points().length; + const interval = Math.max(1, Math.ceil((count - 1) / 3)); + return index % interval === 0 || index === count - 1 + ? this.dateLabel(this.points()[index].date) + : ""; + }, + }, + }, + y: { + type: "linear", + min: 0, + max: step * 4, + border: { display: false }, + grid: { color: grid, drawTicks: false }, + ticks: { color: muted, stepSize: step, padding: 8, font }, + title: { display: true, text: "Событий", color: muted, font, align: "end" }, + }, + }, + }, + }); + this.charts[seriesIndex] = chart; + document.fonts?.ready.then(() => { + if (canvas.isConnected) { + chart.resize(); + chart.update("none"); + } + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html index a19d2da6c..2a5932366 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html @@ -1,140 +1,111 @@ - -@if (releaseDesktop()) { -
-
-
-

Управление программой

-

Аналитика

-

- Сводка пути участников от регистрации до экспертной оценки. -

-
- @if (data()) { -
-
-

Выгрузки

- -
-
- - Все проекты - - - Сданные проекты - - - Оценки проектов - -
- @if (exportFailed()) { - - } -
- } -
+
+
+
+

Управление программой

+

Аналитика

+

+ Сводка пути участников от регистрации до экспертной оценки. +

+
- @if (pending()) { -
- -

Загружаем аналитику программы…

-
-
- @for (card of regionCards(); track card.key) { -
+ @if (pending()) { +
+ +

Загружаем аналитику программы…

+ +
+ } @else if (failed()) { + + } @else if (data(); as overview) { +
+ @for (card of summary(); track card.key) { +
+
+ +
+
-

{{ card.title }}

- +

{{ card.label }}

{{ card.title }} (hide)="hideTooltip(card.key)" >
- @if (regionExportState()[card.key].pending) { -

Подготавливаем Excel…

- } - @if (regionExportState()[card.key].failed) { - - } - @if (card.items.length > 0) { -
    - @for (region of card.items; track region.name) { -
  • - {{ region.name }} - {{ region.count }} -
  • - } -
- } @else { -

{{ card.emptyMessage }}

- } -
- } -
- -
-
-
-

Воронка участников

- + {{ card.value }}
- @if (hasValues(participantFunnel())) { -
    - @for (item of participantFunnel(); track item.key) { -
  1. - - {{ item.label }} - - - {{ item.value }} - -
  2. - } -
- } @else { -

Пока нет данных по участникам

- } -
- -
-
-

Воронка проектов

- -
- @if (hasValues(solutionFunnel())) { -
    - @for (item of solutionFunnel(); track item.key) { -
  1. - - {{ item.label }} - - - {{ item.value }} - -
  2. - } -
- } @else { -

Нет решений для отображения

- } -
- -
-
-

Кейсы

- -
- @if (caseEmptyMessage(); as message) { -

{{ message }}

- } - @if (caseRows().length) { -
    - @for (row of caseRows(); track row.key) { -
  • - -
  • - } -
- }
+ } +
-
+
+
+
-
-

Статус оценивания

-

{{ evaluationModeLabel() }}

-
+

+ + Сроки программы +

- @if (overview.evaluationStatus.projects.submitted > 0) { -
    - @for (item of evaluationStatuses(); track item.key) { -
  • - {{ item.label }} - @if (item.key === "evaluation-partial") { - + @if (deadlines().length) { +
      + @for (deadline of deadlines(); track deadline.key) { +
    • +
      +

      {{ deadline.label }}

      + + @if (deadline.usesRegistrationDeadline) { + По сроку регистрации } - - {{ item.value }} +
      + {{ deadline.status }}
    • }
    - @if (overview.evaluationStatus.maxEvaluationsPerProject !== null) { -
    - - Максимум экспертов на проект: - {{ overview.evaluationStatus.maxEvaluationsPerProject }} - - -
    - } } @else { -

    Пока нет сданных работ для оценивания

    +

    Сроки программы пока не заданы

    } -
    - -
      - @for (item of assignmentStatuses(); track item.key) { -
    • - -
    • - } -
    -
-
- -
Требует внимания
  • @if (item.key === "delayed-experts") { } @else {
  • - -
    -
    -
    -

    Динамика активности

    - @if (hasActivity()) { -

    Последние 30 дней

    - } -
    - -
    - @if (hasActivity()) { -
    - Новые - регистрации - Отправленные - решения -
    -

    - Количество событий в день. Выберите дату на графике, чтобы увидеть значения. -

    -
    -
    - @for (value of activityScale().ticks; track value; let i = $index) { - {{ value }} - } -
    -
    - - @for (point of activityChart(); track point.date) { - - } - -
    -
    -

    - @if (selectedActivity(); as point) { - {{ point.dateLabel }} · Новые регистрации: - {{ point.registrations }} · Отправленные решения: - {{ point.submittedSolutions }} - } @else { - Наведите указатель или перейдите к дате клавишей Tab - } -

    - } @else { -

    - За последние 30 дней активности не было -

    - } -
    -
    - } -
    - -} @else { -
    -
    -
    -

    Управление программой

    -

    Аналитика

    -

    - Сводка пути участников от регистрации до экспертной оценки. -

    - @if (data()) { -
    -
    -

    Выгрузки

    - +
    +
    +
    +

    Статус оценивания

    +

    {{ evaluationModeLabel() }}

    -
    - - Все проекты - - - Сданные проекты - - - Оценки проектов - -
    - @if (exportFailed()) { - - } -
    - } -
    - - @if (pending()) { -
    - -

    Загружаем аналитику программы…

    - -
    - } @else if (failed()) { - - } @else if (data(); as overview) { -
    - @for (card of summary(); track card.key) { -
    -
    - -
    -
    -
    -

    {{ card.label }}

    - -
    - {{ card.value }} -
    -
    - } -
    - -
    - @for (card of regionCards(); track card.key) { -
    -
    -

    {{ card.title }}

    - + @if (overview.evaluationStatus.projects.submitted > 0) { + + @if (overview.evaluationStatus.maxEvaluationsPerProject !== null) { +
    + + Максимум экспертов на проект: + {{ overview.evaluationStatus.maxEvaluationsPerProject }} +
    - @if (regionExportState()[card.key].pending) { - - } - @if (regionExportState()[card.key].failed) { - - } - @if (card.items.length > 0) { -
      - @for (region of card.items; track region.name) { -
    • - {{ region.name }} - {{ region.count }} -
    • - } -
    - } @else { -

    {{ card.emptyMessage }}

    + } + } @else { +

    Пока нет сданных работ для оценивания

    + } +
    + +
      + @for (item of assignmentStatuses(); track item.key) { +
    • + +
    • } -
    + + + +
    + +
    +
    +
    +

    Воронка участников

    + +
    + @if (hasValues(participantFunnel())) { + + } @else { +

    Пока нет данных по участникам

    } -
    + -
    -
    -
    -

    Воронка участников

    - -
    - @if (hasValues(participantFunnel())) { -
      - @for (item of participantFunnel(); track item.key) { -
    1. - - {{ item.label }} - - - {{ item.value }} - -
    2. - } -
    - } @else { -

    Пока нет данных по участникам

    - } -
    +
    +
    +

    Воронка проектов

    + +
    + @if (hasValues(solutionFunnel())) { + + } @else { +

    Нет решений для отображения

    + } +
    +
    -
    -
    -

    Воронка проектов

    - +
    +
    +
    +

    Кейсы

    + +
    + @if (caseEmptyMessage(); as message) { +

    {{ message }}

    + } + @if (caseRows().length) { + - @if (hasValues(solutionFunnel())) { -
      - @for (item of solutionFunnel(); track item.key) { -
    1. - - {{ item.label }} - +
        + @for (row of casesExpanded() ? caseRows() : caseRows().slice(0, 8); track row.key) { +
      • +
      • - } -
    - } @else { -

    Нет решений для отображения

    - } -
    - -
    -
    -

    Кейсы

    - -
    - @if (caseEmptyMessage(); as message) { -

    {{ message }}

    - } - @if (caseRows().length) { -
      - @for (row of caseRows(); track row.key) { -
    • - -
    • - } -
    + + + } + + @if (caseRows().length > 8) { + } -
    + } +
    -
    -
    -
    -

    Статус оценивания

    -

    {{ evaluationModeLabel() }}

    -
    - -
    - @if (overview.evaluationStatus.projects.submitted > 0) { -
      - @for (item of evaluationStatuses(); track item.key) { -
    • - {{ item.label }} - @if (item.key === "evaluation-partial") { - - } - - {{ item.value }} -
    • - } -
    - @if (overview.evaluationStatus.maxEvaluationsPerProject !== null) { -
    - - Максимум экспертов на проект: - {{ overview.evaluationStatus.maxEvaluationsPerProject }} - - -
    +
    +
    +
    +

    Динамика активности

    + @if (hasActivity()) { +

    Последние 30 дней

    } - } @else { -

    Пока нет сданных работ для оценивания

    - } -
    - -
      - @for (item of assignmentStatuses(); track item.key) { -
    • - -
    • - } -
    -
    - + + + @if (hasActivity()) { + + } @else { +

    За последние 30 дней активности не было

    + } +
    + -
    +
    + @for (card of regionCards(); track card.key) {
    -

    Требует внимания

    +

    {{ card.title }}

    +
    - @if (hasValues(attention()) || data()?.attention?.projectsNotSubmitted?.applicable) { -
      - @for (item of attention(); track item.key) { -
    • - @if (item.key === "delayed-experts") { - - } @else { - - } - -
    • - } -
    + @if (regionExportState()[card.key].pending) { + } - @if (!hasValues(attention())) { -

    - Ничего не требует внимания -

    + @if (regionExportState()[card.key].failed) { + } -
    - -
    -
    -
    -

    Динамика активности

    - @if (hasActivity()) { -

    Последние 30 дней

    - } -
    - -
    - @if (hasActivity()) { -
    - Новые - регистрации - Отправленные - решения -
    -

    - Количество событий в день. Выберите дату на графике, чтобы увидеть значения. -

    -
    -
    - @for (value of activityScale().ticks; track value; let i = $index) { - {{ value }} - } -
    -
    - - @for (point of activityChart(); track point.date) { - - } - -
    -
    -

    - @if (selectedActivity(); as point) { - {{ point.dateLabel }} · Новые регистрации: - {{ point.registrations }} · Отправленные решения: - {{ point.submittedSolutions }} - } @else { - Наведите указатель или перейдите к дате клавишей Tab - } -

    + @if (card.items.length > 0) { + } @else { -

    - За последние 30 дней активности не было -

    +

    {{ card.emptyMessage }}

    }
    -
    - } -
    - -} + } + + } +
    + diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 4bc49c8e3..082502936 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -1,4 +1,5 @@ /** @format */ + @use "styles/responsive"; .analytics { @@ -53,7 +54,7 @@ } h2 { - font-size: 16px; + font-size: var(--ui-font-h3, 16px); line-height: 22px; } } @@ -121,7 +122,7 @@ background: var(--light-white); border: 1px solid var(--medium-grey-for-outline); border-radius: 15px; - box-shadow: 0 6px 22px rgb(51 51 51 / 4%); + box-shadow: var(--ui-chart-shadow, 0 6px 22px rgb(51 51 51 / 4%)); } .regions-grid { @@ -139,37 +140,6 @@ flex-direction: column; padding: 16px 18px; - &__list { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 8px; - margin-top: 12px; - - li { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - min-width: 0; - padding: 8px 10px; - background: var(--light-gray); - border-radius: 8px; - - span { - min-width: 0; - font-weight: 500; - color: var(--black); - overflow-wrap: anywhere; - } - - strong { - flex-shrink: 0; - font-weight: 700; - color: var(--black); - } - } - } - .analytics__heading-row app-tooltip { flex-shrink: 0; } @@ -193,6 +163,11 @@ border-radius: 12px; } + &[data-metric="submitted-projects"] &__icon { + color: var(--green-dark); + background: var(--green-light); + } + &__content { min-width: 0; } @@ -207,21 +182,29 @@ &__value { display: block; margin-top: 6px; - font-size: 24px; - font-weight: 700; line-height: 1; color: var(--black); + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-h1, 24px); + font-weight: 700; + } + + @media screen and (min-width: 1000px) { + font-size: 24px; + font-weight: 700; + } } &--skeleton { min-height: 92px; - /* stylelint-disable-next-line declaration-colon-newline-after */ - background: linear-gradient( - 90deg, - var(--light-gray) 20%, - var(--medium-grey-for-outline) 50%, - var(--light-gray) 80% - ); + background: + linear-gradient( + 90deg, + var(--light-gray) 20%, + var(--medium-grey-for-outline) 50%, + var(--light-gray) 80% + ); background-size: 200% 100%; animation: analytics-skeleton 1.3s ease-in-out infinite; } @@ -236,6 +219,13 @@ } } +.analytics-priority__stack { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + .analytics-card { display: flex; flex-direction: column; @@ -243,24 +233,32 @@ padding: 18px; &__title { - font-size: 16px; + font-size: var(--ui-font-h3, 16px); font-weight: 600; line-height: 22px; color: var(--black); } &__subtitle { - font-size: 14px; + font-size: var(--ui-font-body, 14px); font-weight: 500; line-height: 20px; color: var(--dark-grey); } &__section-label { - font-size: 13px; - font-weight: 600; line-height: 18px; color: var(--black); + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-body, 14px); + font-weight: 600; + } + + @media screen and (min-width: 1000px) { + font-size: 13px; + font-weight: 600; + } } } @@ -274,44 +272,132 @@ text-align: center; } -.cases-card { - max-height: 380px; +.program-deadlines { + flex: 1 1 auto; + min-height: 0; - @include responsive.apply-desktop { - // Let the evaluation card size the row; keep the case list scrolling inside it. - align-self: stretch; - max-height: none; - contain: size; + &__title { + display: flex; + gap: 8px; + align-items: center; + + i { + flex-shrink: 0; + color: var(--accent); + } } + &__list { + display: grid; + flex: 1 1 auto; + gap: 8px; + margin-top: 12px; + } + + &__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + background: var(--light-gray); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + } + + &__content { + min-width: 0; + } + + &__label { + font-size: 13px; + font-weight: 600; + line-height: 18px; + color: var(--black); + } + + &__date, + &__hint { + display: block; + margin-top: 2px; + font-size: 12px; + line-height: 18px; + color: var(--dark-grey); + } + + &__hint { + font-size: 11px; + line-height: 16px; + } + + &__status { + font-size: 11px; + font-weight: 600; + line-height: 16px; + color: var(--dark-grey); + text-align: right; + } + + &__row--nearest { + background: var(--accent-light); + border-color: var(--accent); + + .program-deadlines__status { + color: var(--accent-dark); + } + } + + &__row--ended { + .program-deadlines__label, + .program-deadlines__date, + .program-deadlines__status { + color: var(--grey-for-text); + } + } + + @media screen and (max-width: 420px) { + &__row { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + + &__status { + text-align: left; + } + } +} + +.cases-card { &__title { - font-size: 16px; + font-size: var(--ui-font-h3, 16px); font-weight: 600; line-height: 22px; } &__list { - display: flex; - flex: 1 1 auto; - flex-direction: column; + display: grid; gap: 8px; - min-height: 0; margin-top: 12px; - overflow-y: auto; + } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } + &__columns { + display: none; + } + + &__expand { + align-self: flex-start; + margin-top: 16px; } } .case-row { - display: block; - flex-shrink: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: center; width: 100%; min-width: 0; - padding: 10px; + padding: 16px; font: inherit; text-align: left; cursor: pointer; @@ -332,53 +418,47 @@ border: 1px dashed var(--medium-grey-for-outline); } - &__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: start; + &--without-submission { + grid-template-columns: repeat(2, minmax(0, 1fr)); } &__name { - /* stylelint-disable-next-line value-no-vendor-prefix -- Required for two-line clamp. */ - display: -webkit-box; + grid-column: 1 / -1; min-width: 0; - overflow: hidden; - font-size: 12px; + font-size: 14px; font-weight: 600; - line-height: 18px; + line-height: 21px; color: var(--black); overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; } - &__total { - display: flex; - gap: 4px; - align-items: baseline; - color: var(--black); - white-space: nowrap; + &__projects, + &__metric { + display: grid; + gap: 6px; + min-width: 0; + + strong { + font-size: 18px; + font-weight: 600; + line-height: 24px; + color: var(--black); + } } - &__total-value { - font-size: 17px; - font-weight: 700; - line-height: 20px; + &__metric strong { + order: -1; } - &__total-label, - &__meta { - font-size: 10px; - font-weight: 500; - line-height: 16px; + &__mobile-label { + font-size: 12px; + line-height: 18px; color: var(--dark-grey); } &__bar { display: flex; height: 6px; - margin-top: 8px; overflow: hidden; background: var(--grey-button); border-radius: 3px; @@ -387,64 +467,34 @@ &__volume { background: var(--accent); } - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 2px 4px; - margin-top: 6px; - - strong { - font-weight: 600; - } - } } -.funnel { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 16px; - - &__item { - position: relative; +@media screen and (min-width: 700px) { + .cases-card__columns, + .case-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - min-height: 35px; - padding: 8px 10px; - overflow: hidden; - border-radius: 8px; + grid-template-columns: minmax(0, 2fr) minmax(100px, 1fr) 80px 100px; + gap: 24px; } - &__item > span:not(.funnel__bar) { - position: relative; - z-index: 1; - color: var(--black); + .cases-card__columns { + padding: 16px 16px 0; + font-size: 12px; + line-height: 18px; + color: var(--dark-grey); } - &__item > strong { - position: relative; - z-index: 1; - font-weight: 700; - color: var(--black); + .case-row__name { + grid-column: auto; } - &__label { - display: flex; - gap: 6px; - align-items: center; - min-width: 0; - font-weight: 500; + .case-row__mobile-label { + display: none; } - &__bar { - position: absolute; - top: 0; - bottom: 0; - left: 0; - background: linear-gradient(90deg, var(--accent-light), rgb(138 99 230 / 28%)); - border-radius: 8px; + .cases-card__columns--without-submission, + .case-row--without-submission { + grid-template-columns: minmax(0, 2fr) minmax(100px, 1fr) 100px; } } @@ -469,10 +519,18 @@ } strong { - font-size: 17px; - font-weight: 700; line-height: 20px; color: var(--black); + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-h2, 18px); + font-weight: 700; + } + + @media screen and (min-width: 1000px) { + font-size: 17px; + font-weight: 700; + } } } @@ -568,8 +626,15 @@ color: var(--dark-grey); strong { - font-size: 13px; color: var(--black); + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-body, 14px); + } + + @media screen and (min-width: 1000px) { + font-size: 13px; + } } .text-body-10 { @@ -592,10 +657,6 @@ .analytics-bottom { display: grid; gap: 12px; - - @include responsive.apply-desktop { - grid-template-columns: minmax(250px, 1fr) minmax(0, 2fr); - } } .attention, @@ -606,6 +667,8 @@ .attention { display: flex; flex-direction: column; + align-self: start; + width: 100%; background: var(--light-white); border-color: var(--medium-grey-for-outline); border-left: 4px solid var(--red); @@ -706,154 +769,6 @@ margin-top: 2px; color: var(--dark-grey); } - - &__chart { - display: grid; - grid-template-columns: 32px minmax(0, 1fr); - min-width: 0; - margin-top: 16px; - margin-bottom: 26px; - } - - &__plot, - &__y-axis { - position: relative; - min-width: 0; - } - - &__y-axis span { - position: absolute; - right: 8px; - font-size: 12px; - color: var(--black); - transform: translateY(-50%); - } - - &__helper, - &__readout { - margin-top: 12px; - font-size: 12px; - line-height: 18px; - color: var(--grey-for-text); - } - - &__readout { - min-height: 54px; - - strong { - color: var(--black); - } - } - - &__date-target { - position: absolute; - top: 12%; - bottom: 12%; - padding: 0; - cursor: pointer; - background: transparent; - border: 0; - transform: translateX(-50%); - - &:hover, - &--selected { - background: rgb(109 40 255 / 8%); - } - - &:focus-visible { - outline: 2px solid var(--accent); - } - } - - &__legend { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; - align-items: center; - margin-top: 12px; - - span { - display: inline-flex; - gap: 6px; - align-items: center; - font-size: 12px; - font-weight: 500; - line-height: 18px; - color: var(--black); - } - } - - &__marker { - display: inline-block; - width: 9px; - height: 9px; - border-radius: 50%; - - &--registrations { - background: var(--accent); - } - - &--solutions { - background: var(--green); - } - } - - svg { - display: block; - width: 100%; - height: 220px; - overflow: visible; - } - - &__grid-line { - stroke: var(--medium-grey-for-outline); - stroke-width: 0.6; - vector-effect: non-scaling-stroke; - } - - &__line { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 2; - vector-effect: non-scaling-stroke; - - &--registrations { - stroke: var(--accent); - } - - &--solutions { - stroke: var(--green); - stroke-dasharray: 5 3; - } - } - - &__point { - stroke: var(--light-white); - stroke-width: 1; - vector-effect: non-scaling-stroke; - - &--registrations { - fill: var(--accent); - } - - &--solutions { - fill: var(--green); - } - } - - &__axis { - position: relative; - padding-top: 6px; - color: var(--grey-for-text); - border-top: 1px solid var(--medium-grey-for-outline); - - span { - position: absolute; - font-size: 11px; - transform: translateX(-50%); - } - } } .regions__export { @@ -884,7 +799,7 @@ .regions__export-message { margin-top: 8px; - font-size: 12px; + font-size: var(--ui-font-small, 12px); line-height: 18px; } @@ -930,146 +845,7 @@ } } -// Адаптив из dev применяется только ниже desktop breakpoint. @media screen and (max-width: 999px) { - .analytics { - &__heading-row { - h2 { - font-size: var(--ui-font-h3); - } - } - } - - .summary { - grid-template-columns: 1fr; - - @media screen and (min-width: 600px) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - .summary-card, - .analytics-card, - .regions, - .attention, - .activity { - box-shadow: var(--ui-chart-shadow); - } - - .summary-card { - &__value { - font-size: var(--ui-font-h1); - } - } - - .analytics-card { - &__title { - font-size: var(--ui-font-h3); - } - - &__subtitle { - font-size: var(--ui-font-body); - } - - &__section-label { - font-size: var(--ui-font-body); - } - } - - .cases-card { - &__title { - font-size: var(--ui-font-h3); - } - } - - .case-row { - &__name { - /* stylelint-disable-next-line value-no-vendor-prefix */ - font-size: var(--ui-font-small); - } - - &__total-value { - font-size: var(--ui-font-h2); - } - - &__total-label, - &__meta { - font-size: var(--ui-font-caption); - } - } - - .funnel { - &__bar { - background: linear-gradient(90deg, var(--accent-light), var(--ui-accent-gradient-end)); - } - } - - .status-list { - li { - strong { - font-size: var(--ui-font-h2); - } - } - - &--compact { - grid-template-columns: repeat(3, minmax(0, 1fr)); - - li { - flex-direction: column; - } - - @media screen and (max-width: 520px) { - grid-template-columns: 1fr; - - li { - flex-direction: row; - } - } - } - } - - .evaluation { - &__limit { - strong { - font-size: var(--ui-font-body); - } - } - } - - .activity { - &__y-axis span { - font-size: var(--ui-font-small); - } - - &__helper, - &__readout { - font-size: var(--ui-font-small); - } - - &__date-target { - &:hover, - &--selected { - background: var(--ui-accent-tint-chart); - } - } - - &__legend { - span { - font-size: var(--ui-font-small); - } - } - - &__axis { - span { - font-size: var(--ui-font-caption); - } - } - } - - .regions__export-message { - font-size: var(--ui-font-small); - } - .attention .attention__list .attention__action.ui-button { width: 100%; padding: 14px 12px; @@ -1083,6 +859,7 @@ &:hover:not(:disabled), &:active:not(:disabled) { background: transparent; + border-color: transparent; } } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts index 6d4808c55..4b8b014de 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts @@ -22,9 +22,31 @@ import { of } from "rxjs"; import { ok } from "@domain/shared/result.type"; import { delayedExpert, scoreDetail } from "@domain/program/program-analytics-assignment.fixture"; import { AnalyticsDrilldownComponent } from "./drilldown/analytics-drilldown.component"; +import { ProgramScheduleDates } from "@utils/program-deadlines"; vi.mock("@utils/export-regions", () => ({ exportRegions: vi.fn().mockResolvedValue(undefined) })); +vi.mock("chart.js", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + Chart: Object.assign( + vi.fn(function (_canvas, config) { + return { + data: config.data, + options: config.options, + update: vi.fn(), + destroy: vi.fn(), + resize: vi.fn(), + setActiveElements: vi.fn(), + draw: vi.fn(), + }; + }), + { register: vi.fn() }, + ), + }; +}); + function activity(count = 30, allZero = false): ProgramAnalyticsOverview["activity"] { return Array.from({ length: count }, (_, index) => ({ date: `2026-08-${String(index + 1).padStart(2, "0")}`, @@ -57,6 +79,7 @@ function overview(overrides: Partial = {}): ProgramAna participantFunnel: { registrations: 20, uniqueParticipants: 18, + visitedParticipants: 16, withTeam: 12, projectCreators: 7, submittedProjectCreators: 2, @@ -102,6 +125,7 @@ describe("ProgramAnalyticsComponent", () => { const analytics = { programId: signal(12), programName: signal("Тестовая программа"), + scheduleDates: signal(null), data, pending, failed, @@ -118,7 +142,15 @@ describe("ProgramAnalyticsComponent", () => { beforeEach(() => { vi.mocked(exportRegions).mockReset().mockResolvedValue(undefined); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + })); data.set(overview()); + analytics.scheduleDates.set(null); pending.set(false); failed.set(false); error.set(null); @@ -210,14 +242,14 @@ describe("ProgramAnalyticsComponent", () => { return { fixture, card, rows: [...card.querySelectorAll(".case-row")] }; } - it("keeps cases and evaluation as adjacent cards in the same two-column grid row", () => { + it("places cases before activity while evaluation stays in the priority row", () => { const { card } = renderCases(); const grid = card.parentElement!; - expect(grid.classList.contains("analytics-grid")).toBe(true); - expect([...grid.children].indexOf(card)).toBe(2); + expect(grid.classList.contains("analytics-bottom")).toBe(true); + expect([...grid.children].indexOf(card)).toBe(0); expect(card.classList.contains("analytics-card")).toBe(true); expect(card.classList.contains("cases-card")).toBe(true); - expect(card.nextElementSibling?.matches(".analytics-card.evaluation")).toBe(true); + expect(card.nextElementSibling?.matches(".activity")).toBe(true); }); it("preserves backend counts/order/zero rows and appends withoutCase without mutations", () => { @@ -229,23 +261,19 @@ describe("ProgramAnalyticsComponent", () => { "Case C", "Без выбранного кейса", ]); - expect(rows.map(row => row.querySelector(".case-row__total-value")?.textContent)).toEqual([ - "3", - "1", - "0", - "2", - ]); - expect(rows.map(row => row.querySelector(".case-row__total-label")?.textContent)).toEqual([ - "проекта", - "проект", - "проектов", - "проекта", - ]); - expect(rows[0].querySelector(".case-row__meta")?.textContent).toMatch( - /Сдано 2\s*·\s*Не сдано 1\s*·\s*Участников 7/, + expect(rows.map(row => row.querySelector(".case-row__projects strong")?.textContent)).toEqual( + ["3", "1", "0", "2"], ); - expect(rows[0].querySelector('[role="img"]')?.getAttribute("aria-label")).toBe( - "Case A: 3 проекта, сдано 2, не сдано 1, участников 7", + expect( + rows.map( + row => row.querySelector(".case-row__projects .case-row__mobile-label")?.textContent, + ), + ).toEqual(["проекта", "проект", "проектов", "проекта"]); + expect( + [...rows[0].querySelectorAll(".case-row__metric strong")].map(el => el.textContent), + ).toEqual(["2", "7"]); + expect(rows[0].getAttribute("aria-label")).toBe( + "Case A: 3 проекта, сдано 2, участников 7. Открыть проекты кейса", ); expect(rows[3].classList.contains("case-row--without-case")).toBe(true); expect( @@ -329,7 +357,7 @@ describe("ProgramAnalyticsComponent", () => { const { fixture, card, rows } = renderCases(); expect(rows).toHaveLength(4); expect(card.textContent).toContain("Case A"); - expect(card.textContent).toContain("Участников 7"); + expect(rows[0].querySelector(".case-row__metric strong")?.textContent).toBe("7"); expect(card.textContent).not.toMatch(/Сдано|Не сдано/); expect(rows[0].querySelector(".case-row__volume")!.style.width).toBe("100%"); expect(rows[0].getAttribute("aria-label")).toBe( @@ -341,7 +369,7 @@ describe("ProgramAnalyticsComponent", () => { ).toContain("Сдано"); }); - it("retains full long names and all 20 rows inside the bounded, keyboard-scrollable list", () => { + it("shows eight readable cases and reveals all remaining cases on demand", () => { const model = overview(); const longName = "Разработка цифрового ассистента для персонализированного образовательного маршрута"; @@ -351,19 +379,19 @@ describe("ProgramAnalyticsComponent", () => { })); model.cases.withoutCase.projectsTotal = 0; data.set(model); - const { card, rows } = renderCases(); - expect(rows).toHaveLength(20); - expect(rows[0].querySelector(".case-row__header > .case-row__name")?.textContent).toBe( - `${longName} 1`, - ); - expect( - rows[0].querySelector(".case-row__header > .case-row__total .case-row__total-value"), - ).not.toBeNull(); - expect(card.querySelector(".cases-card__title")?.textContent).toBe("Кейсы"); - expect(card.querySelector(".cases-card__list")?.getAttribute("tabindex")).toBe("0"); - expect(card.querySelector(".cases-card__list")?.getAttribute("aria-labelledby")).toBe( - card.querySelector("h2")?.id, - ); + const { fixture, card, rows } = renderCases(); + expect(rows).toHaveLength(8); + expect(rows[0].querySelector(".case-row__name")?.textContent).toBe(`${longName} 1`); + const expand = card.querySelector(".cases-card__expand")!; + expect(expand.getAttribute("aria-expanded")).toBe("false"); + expect(expand.getAttribute("aria-controls")).toBe(card.querySelector("ul")?.id); + expand.click(); + fixture.detectChanges(); + expect(card.querySelectorAll(".case-row")).toHaveLength(20); + expect(expand.getAttribute("aria-expanded")).toBe("true"); + expand.click(); + fixture.detectChanges(); + expect(card.querySelectorAll(".case-row")).toHaveLength(8); }); }); @@ -379,10 +407,12 @@ describe("ProgramAnalyticsComponent", () => { expect(root.textContent?.toLowerCase()).not.toContain("обезличенная"); expect(root.querySelector('[data-testid="summary-participants"]')?.textContent).toContain("18"); expect(root.querySelector('[data-testid="summary-projects"]')?.textContent).toContain("7"); - expect(root.querySelector('[data-testid="summary-experts"]')?.textContent).toContain( - "Эксперты", + expect(root.querySelector('[data-testid="summary-submitted-projects"]')?.textContent).toContain( + "Сдано", + ); + expect(root.querySelector('[data-testid="summary-submitted-projects"]')?.textContent).toContain( + "6", ); - expect(root.querySelector('[data-testid="summary-experts"]')?.textContent).toContain("4"); expect( root.querySelector('[data-testid="summary-participants-per-project"]')?.textContent, ).toContain("Команда"); @@ -403,6 +433,101 @@ describe("ProgramAnalyticsComponent", () => { ); }); + it("puts actionable controls before funnels and geographical context in reading order", () => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + expect( + [...root.querySelectorAll("main article[data-testid]")].map(el => + el.getAttribute("data-testid"), + ), + ).toEqual([ + "summary-participants", + "summary-projects", + "summary-submitted-projects", + "summary-participants-per-project", + "program-deadlines", + "attention-block", + "evaluation-statuses", + "participant-funnel", + "solution-funnel", + "cases-card", + "activity-dynamics", + "project-regions", + "participant-regions", + ]); + }); + + describe("program deadlines", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows actual dates and the effective submission fallback above attention", () => { + analytics.scheduleDates.set({ + datetimeRegistrationEnds: "2026-10-17T18:00:00+03:00", + datetimeProjectSubmissionEnds: null, + datetimeEvaluationEnds: "invalid", + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + const card = root.querySelector('[data-testid="program-deadlines"]')!; + expect(card.querySelectorAll("time")).toHaveLength(2); + expect(card.querySelector('[data-deadline="submission"]')?.textContent).toContain( + "По сроку регистрации", + ); + expect(card.textContent).not.toContain("Конец оценивания"); + expect(card.nextElementSibling?.getAttribute("data-testid")).toBe("attention-block"); + }); + + it("does not show a submission deadline in a noncompetitive program", () => { + const base = overview(); + data.set({ ...base, cases: { ...base.cases, submissionApplicable: false } }); + analytics.scheduleDates.set({ + datetimeRegistrationEnds: "2026-10-17T18:00:00Z", + datetimeProjectSubmissionEnds: "2026-10-18T18:00:00Z", + datetimeEvaluationEnds: "2026-10-20T18:00:00Z", + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const card = fixture.nativeElement.querySelector( + '[data-testid="program-deadlines"]', + ) as HTMLElement; + expect(card.querySelectorAll("time")).toHaveLength(2); + expect(card.querySelector('[data-deadline="submission"]')).toBeNull(); + }); + + it("shows an empty state when no valid dates are available", () => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const card = fixture.nativeElement.querySelector( + '[data-testid="program-deadlines"]', + ) as HTMLElement; + expect(card.textContent).toContain("Сроки программы пока не заданы"); + expect(card.querySelector("time")).toBeNull(); + }); + + it("updates an expired deadline while the page stays open and releases the clock on destroy", () => { + vi.useFakeTimers(); + const now = new Date(2026, 9, 14, 12); + vi.setSystemTime(now); + analytics.scheduleDates.set({ datetimeRegistrationEnds: now.toISOString() }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const row = fixture.nativeElement.querySelector( + '[data-deadline="registration"]', + ) as HTMLElement; + expect(row.textContent).toContain("Сегодня"); + vi.advanceTimersByTime(60_000); + fixture.detectChanges(); + expect(row.textContent).toContain("Срок завершён"); + fixture.destroy(); + vi.runOnlyPendingTimers(); + expect(vi.getTimerCount()).toBe(0); + }); + }); + it.each([ [0, "all"], [1, "completed"], @@ -494,48 +619,95 @@ describe("ProgramAnalyticsComponent", () => { expect(open).toHaveBeenCalledTimes(1); }); - it("not-submitted: порядок, активная/нулевая строка и tooltip отдельно", () => { + it("visit attention uses the API count, opens its list, and disables zero", () => { const base = overview(); data.set({ ...base, - attention: { ...base.attention, projectsNotSubmitted: { applicable: true, total: 4 } }, + attention: { ...base.attention, participantsWithoutVisit: 7 }, }); const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); const drilldown = fixture.debugElement.query(By.directive(AnalyticsDrilldownComponent)) .componentInstance as AnalyticsDrilldownComponent; const open = vi.spyOn(drilldown, "openAttention").mockImplementation(() => {}); + const trigger = Array.from( + fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf, + ).find(button => button.textContent?.includes("Участники ещё не заходили на платформу"))!; + expect(trigger.querySelector("strong")?.textContent).toBe("7"); + expect(trigger.disabled).toBe(false); + trigger.click(); + expect(open).toHaveBeenCalledExactlyOnceWith("participants-without-visit", trigger); + data.set({ ...base, attention: { ...base.attention, participantsWithoutVisit: 0 } }); + fixture.detectChanges(); + expect(trigger.disabled).toBe(true); + trigger.click(); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("does not invent a visit attention count when the API field is absent", () => { + const model = overview(); + Object.assign(model.attention, { participantsWithoutOnboarding: 7 }); + data.set(model); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('[data-testid="attention-block"]').textContent, + ).not.toContain("Участники ещё не заходили на платформу"); + }); + + it("participants without a visit alone prevent the all-clear attention state", () => { + const base = overview(); + data.set({ + ...base, + attention: { + participantsWithoutVisit: 1, + participantsWithoutTeam: 0, + projectsAwaitingEvaluation: 0, + projectsNotSubmitted: { applicable: false, total: 0 }, + delayedExperts: { total: 0, items: [] }, + }, + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".attention__empty")).toBeNull(); + data.update(current => ({ + ...current!, + attention: { ...current!.attention, participantsWithoutVisit: 0 }, + })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(".attention__empty")?.textContent).toContain( + "Ничего не требует внимания", + ); + }); + + it("не дублирует счётчик несданных проектов в блоке внимания", () => { + const base = overview(); + data.set({ + ...base, + attention: { ...base.attention, projectsNotSubmitted: { applicable: true, total: 4 } }, + }); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); const buttons = Array.from( fixture.nativeElement.querySelectorAll(".attention__action"), ) as HTMLButtonElement[]; expect(buttons.map(button => button.querySelector("span")?.textContent)).toEqual([ "Участники без команды", "Работы ожидают оценивания", - "Проекты не сдали решение", "Эксперты задерживают оценивание", ]); - const trigger = buttons[2]; - expect(trigger.disabled).toBe(false); - expect(drilldown.notSubmittedApplicable()).toBe(true); - expect(trigger.querySelector("app-tooltip")).toBeNull(); - trigger.parentElement!.querySelector("app-tooltip")!.click(); - expect(open).not.toHaveBeenCalled(); - trigger.click(); - expect(open).toHaveBeenCalledExactlyOnceWith("projects-not-submitted", trigger); + expect(fixture.nativeElement.textContent).not.toContain("Проекты не сдали решение"); data.set({ ...base, attention: { participantsWithoutTeam: 0, projectsAwaitingEvaluation: 0, delayedExperts: { total: 0, items: [] }, - projectsNotSubmitted: { applicable: true, total: 0 }, + projectsNotSubmitted: { applicable: true, total: 4 }, }, }); fixture.detectChanges(); - expect(trigger.disabled).toBe(true); expect(fixture.nativeElement.textContent).toContain("Ничего не требует внимания"); - trigger.click(); - expect(open).toHaveBeenCalledTimes(1); }); it("non-competitive скрывает строку даже с notSubmitted > 0 и не делает detail request", () => { @@ -560,9 +732,9 @@ describe("ProgramAnalyticsComponent", () => { expect(cards[0].querySelector("h2")?.textContent).toBe("Регионы проектов"); expect(cards[1].querySelector("h2")?.textContent).toBe("Регионы участников"); const rows = (card: Element) => - Array.from(card.querySelectorAll("li"), row => [ - row.querySelector("span")?.textContent?.trim(), - row.querySelector("strong")?.textContent?.trim(), + Array.from(card.querySelectorAll("tbody tr"), row => [ + row.querySelector("th")?.textContent?.trim(), + row.querySelector("td")?.textContent?.trim(), ]); expect(rows(cards[0])).toEqual([ ["Москва", "5"], @@ -583,7 +755,7 @@ describe("ProgramAnalyticsComponent", () => { fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelectorAll(".regions .analytics-empty-state")).toHaveLength(1); - expect(root.querySelectorAll(".regions__list")).toHaveLength(1); + expect(root.querySelectorAll("app-analytics-region-chart")).toHaveLength(1); expect(root.querySelector(".regions .analytics-empty-state")?.textContent?.trim()).toBe( key === "regions" ? "У проектов пока не указаны регионы" @@ -640,6 +812,7 @@ describe("ProgramAnalyticsComponent", () => { ...model.participantFunnel, registrations: 11, uniqueParticipants: 7, + visitedParticipants: 6, withTeam: 4, projectCreators: 3, }; @@ -652,42 +825,57 @@ describe("ProgramAnalyticsComponent", () => { expect(participants).toContain("Зарегистрировались"); expect(participants).not.toContain("Уникальные участники"); - const rows = Array.from(root.querySelectorAll('[data-testid="participant-funnel"] li')); - expect(rows.map(row => row.querySelector(".funnel__label")?.textContent?.trim())).toEqual([ + const rows = Array.from(root.querySelectorAll('[data-testid="participant-funnel"] tbody tr')); + expect(rows.map(row => row.querySelector("th")?.firstChild?.textContent?.trim())).toEqual([ "Зарегистрировались", - "В команде", - "Создали проект", - ]); - expect(rows.map(row => row.querySelector("strong")?.textContent?.trim())).toEqual([ - "7", - "4", - "3", + "Заходили на платформу", + "Вступили в команду", ]); + expect(rows.map(row => row.querySelector("td")?.textContent?.trim())).toEqual(["7", "6", "4"]); expect(data()?.participantFunnel.registrations).toBe(11); const tooltips = fixture.debugElement .queryAll(By.directive(TooltipComponent)) .map(element => (element.componentInstance as TooltipComponent).text()); - expect(tooltips).toContain("Уникальные зарегистрированные участники программы."); - expect(tooltips).toContain("Путь участников от регистрации до создания проекта."); + expect(participants).toContain("Уникальные зарегистрированные участники программы."); + expect(tooltips.some(text => text.includes("зафиксированное посещение платформы"))).toBe(true); expect(tooltips.some(text => text.includes("регистрационные записи"))).toBe(false); expect(root.querySelector('[data-testid="attention-block"]')?.textContent).toContain( "Участники без команды", ); - expect(participants).toContain("В команде"); - expect(participants).toContain("Создали проект"); + expect(participants).toContain("Вступили в команду"); + expect(participants).not.toContain("Создали проект"); expect(participants).not.toContain("Сдали проект"); - const submitted = Array.from(root.querySelectorAll('[data-testid="solution-funnel"] li')).find( - row => row.querySelector(".funnel__label")?.textContent?.trim().startsWith("Сдано"), - ); - expect(submitted?.querySelector("strong")?.textContent?.trim()).toBe("6"); + const submitted = Array.from( + root.querySelectorAll('[data-testid="solution-funnel"] tbody tr'), + ).find(row => row.querySelector("th")?.textContent?.trim().startsWith("Сдано")); + expect(submitted?.querySelector("td")?.textContent?.trim()).toBe("6"); expect(solutions).toContain("Создано"); - expect(solutions).toContain("Не сдано"); + expect(solutions).not.toMatch(/не сдано|Черновик/i); expect(solutions).toContain("Сдано"); expect(solutions).toContain("Оценено"); expect(root.querySelector('[data-testid="solution-funnel"] h2')?.textContent?.trim()).toBe( "Воронка проектов", ); expect(solutions).not.toContain("Воронка решений"); + expect(root.querySelectorAll('[data-testid="solution-funnel"] tbody tr')).toHaveLength(3); + expect( + root.querySelectorAll( + '[data-testid="participant-funnel"] canvas, [data-testid="solution-funnel"] canvas', + ), + ).toHaveLength(2); + }); + + it("не выдаёт отсутствие новой метрики посещения за ноль", () => { + const model = overview(); + delete model.participantFunnel.visitedParticipants; + Object.assign(model.participantFunnel, { onboardedParticipants: 6 }); + data.set(model); + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + fixture.detectChanges(); + const row = fixture.nativeElement.querySelector( + '[data-testid="participant-funnel"] tbody tr:nth-child(2)', + ); + expect(row.querySelector("td").textContent).toBe("Данные пока недоступны"); }); it("technical registrations alone do not remove the participant zero state", () => { @@ -723,7 +911,7 @@ describe("ProgramAnalyticsComponent", () => { ".attention__action > span", ".evaluation__limit .text-body-10", ]; - expect(selectors.map(selector => root.querySelectorAll(selector).length)).toEqual([4, 3, 4, 1]); + expect(selectors.map(selector => root.querySelectorAll(selector).length)).toEqual([0, 3, 3, 1]); for (const selector of selectors) { for (const label of root.querySelectorAll(selector)) expect(label.classList.contains("text-body-10")).toBe(true); @@ -747,12 +935,19 @@ describe("ProgramAnalyticsComponent", () => { expect(evaluation?.querySelector(".analytics-card__title")?.textContent).toContain( "Статус оценивания", ); - expect(evaluation?.textContent).toContain("Ожидают оценивания"); + expect(evaluation?.textContent).not.toContain("Ожидают оценивания"); expect(evaluation?.textContent).not.toContain("Частично оценено"); expect(evaluation?.textContent).toContain("Максимум экспертов на проект"); expect(evaluation?.textContent).not.toContain("требуется 3"); - expect(evaluation?.querySelector(".status-list--projects")).not.toBeNull(); - expect(evaluation?.querySelectorAll(".status-list--projects li strong").length).toBe(3); + expect(evaluation?.querySelector("app-analytics-funnel-chart canvas")).not.toBeNull(); + expect( + [...evaluation!.querySelectorAll("app-analytics-funnel-chart tbody td")].map(el => + el.textContent?.trim(), + ), + ).toEqual(["6", "4"]); + expect(fixture.nativeElement.querySelector(".attention").textContent).toContain( + "Работы ожидают оценивания", + ); expect(evaluation?.querySelectorAll('[data-testid="assignment-statuses"] button')).toHaveLength( 3, ); @@ -781,15 +976,17 @@ describe("ProgramAnalyticsComponent", () => { expect(evaluation?.textContent).toContain("Распределённое оценивание"); expect(evaluation?.textContent).toContain("Частично оценено"); - const partialTooltip = fixture.debugElement - .queryAll(By.directive(TooltipComponent)) - .map(element => element.componentInstance as TooltipComponent) - .find(tooltip => tooltip.text().startsWith("Хотя бы один")); - expect(partialTooltip?.text()).toBe( - "Хотя бы один назначенный эксперт полностью оценил проект, но не все назначенные эксперты завершили оценивание.", + expect(evaluation?.querySelector("app-analytics-funnel-chart table")?.textContent).toContain( + "Хотя бы один назначенный эксперт полностью оценил проект", ); + expect( + [...evaluation!.querySelectorAll("app-analytics-funnel-chart tbody td")].map(el => + el.textContent?.trim(), + ), + ).toEqual(["6", "1", "3"]); + expect(evaluation?.textContent).not.toContain("Ожидают оценивания"); expect(evaluation?.querySelector('[data-testid="assignment-statuses"]')?.textContent).toContain( - "Назначений", + "Назначений всего", ); }); @@ -875,31 +1072,31 @@ describe("ProgramAnalyticsComponent", () => { expect(open).toHaveBeenCalledOnce(); }); - it("строит две серии по всем 30 точкам activity", () => { + it("показывает раздельную динамику и общие значения выбранной даты", async () => { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); + await fixture.whenStable(); const chart = (fixture.nativeElement as HTMLElement).querySelector( '[data-testid="activity-dynamics"]', ); expect(chart?.textContent).toContain("Новые регистрации"); expect(chart?.textContent).toContain("Отправленные решения"); - expect(chart?.querySelectorAll("circle").length).toBe(60); - expect(chart?.querySelectorAll("polyline").length).toBe(2); - expect(chart?.querySelector("title")?.textContent).toContain("01.08"); - expect(chart?.querySelectorAll(".activity__grid-line")).toHaveLength(5); - expect(chart?.querySelector(".activity__y-axis")?.textContent).toContain("4"); - expect(chart?.textContent).toContain("Количество событий в день"); - const date = chart?.querySelectorAll(".activity__date-target")[1]; - expect(date?.getAttribute("aria-label")).toContain( + expect(chart?.querySelectorAll("canvas")).toHaveLength(2); + const plots = chart?.querySelectorAll('[role="slider"]'); + expect(plots).toHaveLength(2); + plots?.[0].dispatchEvent(new FocusEvent("focus")); + plots?.[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); + fixture.detectChanges(); + expect(plots?.[1].getAttribute("aria-valuetext")).toContain( "02.08: новые регистрации — 1, отправленные решения — 1", ); - date?.dispatchEvent(new FocusEvent("focus")); - fixture.detectChanges(); - expect(chart?.querySelector(".activity__readout")?.textContent).toContain("02.08"); - date?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(chart?.querySelector(".activity-chart__readout")?.textContent).toContain("02.08"); + plots?.[0].dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); fixture.detectChanges(); - expect(chart?.querySelector(".activity__readout")?.textContent).toContain("Наведите указатель"); + expect(chart?.querySelector(".activity-chart__readout")?.textContent).toContain( + "Наведите указатель", + ); }); it("exports each loaded region dataset independently and prevents repeated clicks", async () => { @@ -1017,7 +1214,7 @@ describe("ProgramAnalyticsComponent", () => { const emptyStates = Array.from( (fixture.nativeElement as HTMLElement).querySelectorAll(".analytics-empty-state"), ) as HTMLElement[]; - expect(emptyStates).toHaveLength(8); + expect(emptyStates).toHaveLength(9); expect(emptyStates.every(state => state.querySelector("i") === null)).toBe(true); expect(emptyStates.every(state => !state.textContent?.trim().endsWith("."))).toBe(true); expect(fixture.nativeElement.querySelector(".attention--empty")).not.toBeNull(); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index 4db7c977b..a291a0a16 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -1,32 +1,37 @@ /** @format */ -import { inject as injectReleaseLayout } from "@angular/core"; -import { DesktopLayoutService as ReleaseDesktopLayoutService } from "../../../../../../../../ui/src/lib/services/desktop-layout.service"; - import { ButtonDirective, StateComponent } from "@uilib"; +/** @format */ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, inject, + NgZone, OnInit, signal, } from "@angular/core"; import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { DOCUMENT } from "@angular/common"; import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service"; -import { - ProgramAnalyticsActivityPoint, - ProgramAnalyticsCaseMetrics, -} from "@domain/program/program-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; import { PluralizePipe } from "@corelib"; import { isFailure, isLoading } from "@domain/shared/async-state"; import { ButtonComponent, IconComponent } from "@ui/primitives"; import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; import { exportRegions, RegionExportKind } from "@utils/export-regions"; +import { programDeadlines } from "@utils/program-deadlines"; import { AnalyticsDrilldownComponent } from "./drilldown/analytics-drilldown.component"; +import { AnalyticsRegionChartComponent } from "./region-chart/region-chart.component"; +import { AnalyticsActivityChartComponent } from "./activity-chart/activity-chart.component"; +import { + AnalyticsFunnelChartComponent, + AnalyticsFunnelStage, +} from "./funnel-chart/funnel-chart.component"; import { ProgramAnalyticsAssignmentScope } from "@domain/program/program-analytics.model"; interface AnalyticsMetric { @@ -38,13 +43,6 @@ interface AnalyticsMetric { scope?: ProgramAnalyticsAssignmentScope; } -interface ActivityChartPoint extends ProgramAnalyticsActivityPoint { - dateLabel: string; - x: number; - registrationsY: number; - submittedSolutionsY: number; -} - interface RegionExportState { pending: boolean; failed: boolean; @@ -72,15 +70,20 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { MatProgressBarModule, TooltipComponent, AnalyticsDrilldownComponent, + AnalyticsRegionChartComponent, + AnalyticsActivityChartComponent, + AnalyticsFunnelChartComponent, ], providers: [ProgramAnalyticsInfoService, ExportFileInfoService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProgramAnalyticsComponent implements OnInit { - protected readonly releaseDesktop = injectReleaseLayout(ReleaseDesktopLayoutService).desktop; - private readonly analytics = inject(ProgramAnalyticsInfoService); private readonly exports = inject(ExportFileInfoService); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + private readonly currentTime = signal(Date.now()); protected caseSelection(row: AnalyticsCaseRow): ProgramCaseSelection { return row.isWithoutCase @@ -95,9 +98,16 @@ export class ProgramAnalyticsComponent implements OnInit { protected readonly pending = this.analytics.pending; protected readonly failed = this.analytics.failed; protected readonly error = this.analytics.error; + protected readonly deadlines = computed(() => + programDeadlines( + this.analytics.scheduleDates(), + this.data()?.cases.submissionApplicable ?? false, + new Date(this.currentTime()), + ), + ); protected readonly activeTooltip = signal(null); protected readonly pinnedTooltip = signal(null); - protected readonly selectedActivityDate = signal(null); + protected readonly casesExpanded = signal(false); protected readonly regionExportState = signal>({ "project-regions": { pending: false, failed: false }, "participant-regions": { pending: false, failed: false }, @@ -136,9 +146,7 @@ export class ProgramAnalyticsComponent implements OnInit { "проекта", "проектов", ]); - const submission = cases.submissionApplicable - ? `, сдано ${row.submitted}, не сдано ${row.notSubmitted}` - : ""; + const submission = cases.submissionApplicable ? `, сдано ${row.submitted}` : ""; return { ...row, totalLabel, @@ -162,7 +170,7 @@ export class ProgramAnalyticsComponent implements OnInit { key: "participants", label: "Участники", value: overview.summary.participants.total, - icon: "team", + icon: "person", tooltip: "Уникальные пользователи, зарегистрированные в программе.", }, { @@ -173,17 +181,17 @@ export class ProgramAnalyticsComponent implements OnInit { tooltip: "Все проекты, связанные с программой.", }, { - key: "experts", - label: "Эксперты", - value: overview.summary.experts.total, - icon: "person", - tooltip: "Эксперты, добавленные в программу.", + key: "submitted-projects", + label: "Сдано", + value: overview.solutionFunnel.submitted, + icon: "analytics-submitted", + tooltip: "Проекты программы с отправленным решением.", }, { key: "participants-per-project", label: "Команда", value: averageParticipantsPerProject, - icon: "people", + icon: "analytics-team", tooltip: "Среднее количество зарегистрированных участников программы на один проект.", }, ]; @@ -206,29 +214,37 @@ export class ProgramAnalyticsComponent implements OnInit { }, ]); - protected readonly participantFunnel = computed(() => { + protected readonly participantFunnel = computed(() => { const funnel = this.data()?.participantFunnel; if (!funnel) return []; return [ - this.metric( - "registrations", - "Зарегистрировались", - funnel.uniqueParticipants, - "Уникальные зарегистрированные участники программы.", - ), - this.metric( - "with-team", - "В команде", - funnel.withTeam, - "Участники, которые являются руководителями или участниками команды проекта программы.", - ), - this.metric( - "project-creators", - "Создали проект", - funnel.projectCreators, - "Участники, ставшие руководителями проекта программы.", - ), + { + ...this.metric( + "registrations", + "Зарегистрировались", + funnel.uniqueParticipants, + "Уникальные зарегистрированные участники программы.", + ), + axisLabel: ["Регистрация"], + }, + { + key: "visited", + label: "Заходили на платформу", + value: funnel.visitedParticipants ?? null, + tooltip: + "Зарегистрированные участники программы, для которых зафиксирован вход или активность на платформе. Учитываются и посещения до регистрации в этой программе.", + axisLabel: ["Заходили на", "платформу"], + }, + { + ...this.metric( + "with-team", + "Вступили в команду", + funnel.withTeam, + "Зарегистрированные участники, которые сейчас являются руководителями или участниками команды проекта программы.", + ), + axisLabel: ["Вступили", "в команду"], + }, ]; }); @@ -238,12 +254,6 @@ export class ProgramAnalyticsComponent implements OnInit { return [ this.metric("solutions-created", "Создано", funnel.created, "Все проекты программы."), - this.metric( - "solutions-not-submitted", - "Не сдано", - funnel.notSubmitted, - "Проекты программы, решение по которым ещё не отправлено.", - ), this.metric( "solutions-submitted", "Сдано", @@ -270,12 +280,6 @@ export class ProgramAnalyticsComponent implements OnInit { status.projects.submitted, "Все проекты с отправленным решением.", ), - this.metric( - "evaluation-awaiting", - "Ожидают оценивания", - status.projects.awaitingEvaluation, - this.awaitingEvaluationTooltip(status.mode), - ), ]; if (status.mode === "distributed") { @@ -302,11 +306,24 @@ export class ProgramAnalyticsComponent implements OnInit { return metrics; }); + protected readonly evaluationChartItems = computed(() => + this.evaluationStatuses().map(item => ({ + ...item, + axisLabel: item.key === "evaluation-partial" ? ["Частично", "оценено"] : [item.label], + tone: + item.key === "evaluation-submitted" + ? "muted" + : item.key === "evaluation-evaluated" + ? "success" + : "accent", + })), + ); + protected readonly assignmentStatuses = computed(() => { const assignments = this.data()?.evaluationStatus.assignments; if (!assignments) return []; return [ - { ...this.metric("assignments-total", "Назначений", assignments.total), scope: "all" }, + { ...this.metric("assignments-total", "Назначений всего", assignments.total), scope: "all" }, { ...this.metric("assignments-evaluated", "Выполнено", assignments.evaluated), scope: "completed", @@ -324,7 +341,20 @@ export class ProgramAnalyticsComponent implements OnInit { protected readonly attention = computed(() => { const overview = this.data(); if (!overview) return []; + const visit: AnalyticsMetric[] = + overview.attention.participantsWithoutVisit == null + ? [] + : [ + { + key: "without-visit", + label: "Участники ещё не заходили на платформу", + value: overview.attention.participantsWithoutVisit, + tooltip: + "Зарегистрированные участники программы, у которых ещё не зафиксированы ни вход, ни активность на платформе. Участник исчезает из списка после первого зафиксированного посещения.", + }, + ]; return [ + ...visit, { key: "without-team", label: "Участники без команды", @@ -338,22 +368,12 @@ export class ProgramAnalyticsComponent implements OnInit { value: overview.attention.projectsAwaitingEvaluation, tooltip: this.awaitingEvaluationTooltip(overview.evaluationStatus.mode), }, - ...(overview.attention.projectsNotSubmitted.applicable - ? [ - { - key: "projects-not-submitted", - label: "Проекты не сдали решение", - value: overview.attention.projectsNotSubmitted.total, - tooltip: "Проекты конкурсной программы, которые ещё не отправили решение.", - }, - ] - : []), { key: "delayed-experts", label: "Эксперты задерживают оценивание", value: overview.attention.delayedExperts.total, tooltip: - "Эксперты с просроченными назначениями: минимум два проекта ожидают оценки 24 часа или хотя бы один проект — 48 часов.", + "Появляется за 5 суток до окончания оценивания, если у эксперта есть незавершённая оценка сданной работы. После дедлайна остаётся до завершения оценок. Без даты окончания оценивания сигнал не появляется.", }, ]; }); @@ -364,57 +384,6 @@ export class ProgramAnalyticsComponent implements OnInit { ), ); - protected readonly activityScale = computed(() => { - const activity = this.data()?.activity ?? []; - const maximum = Math.max( - 1, - ...activity.flatMap(point => [point.registrations, point.submittedSolutions]), - ); - // Integer counts, shared zero-based scale for both series; at most five grid labels. - const step = Math.max(1, Math.ceil(maximum / 4)); - return { maximum: step * 4, ticks: [4, 3, 2, 1, 0].map(index => index * step) }; - }); - - protected readonly activityChart = computed(() => { - const activity = this.data()?.activity ?? []; - const maxValue = this.activityScale().maximum; - const divisor = Math.max(activity.length - 1, 1); - - return activity.map((point, index) => ({ - ...point, - dateLabel: this.formatDate(point.date), - x: 3 + (index / divisor) * 94, - registrationsY: 88 - (point.registrations / maxValue) * 76, - submittedSolutionsY: 88 - (point.submittedSolutions / maxValue) * 76, - })); - }); - - protected readonly registrationPolyline = computed(() => - this.activityChart() - .map(point => `${point.x},${point.registrationsY}`) - .join(" "), - ); - - protected readonly submissionPolyline = computed(() => - this.activityChart() - .map(point => `${point.x},${point.submittedSolutionsY}`) - .join(" "), - ); - - protected readonly activityAxisLabels = computed(() => { - const points = this.activityChart(); - const interval = Math.max(1, Math.ceil((points.length - 1) / 3)); - return points.filter((_, index) => index % interval === 0 || index === points.length - 1); - }); - - protected readonly selectedActivity = computed(() => - this.activityChart().find(point => point.date === this.selectedActivityDate()), - ); - - protected activityPointLabel(point: ActivityChartPoint): string { - return `${point.dateLabel}: новые регистрации — ${point.registrations}, отправленные решения — ${point.submittedSolutions}`; - } - protected async downloadRegions(kind: RegionExportKind): Promise { const card = this.regionCards().find(item => item.key === kind); if (!card?.items.length || this.regionExportState()[kind].pending) return; @@ -451,6 +420,18 @@ export class ProgramAnalyticsComponent implements OnInit { ngOnInit(): void { this.analytics.initialize(); + this.zone.runOutsideAngular(() => { + const refreshTime = () => this.zone.run(() => this.currentTime.set(Date.now())); + const timer = setInterval(refreshTime, 60_000); + const onVisibility = () => { + if (this.document.visibilityState === "visible") refreshTime(); + }; + this.document.addEventListener("visibilitychange", onVisibility); + this.destroyRef.onDestroy(() => { + clearInterval(timer); + this.document.removeEventListener("visibilitychange", onVisibility); + }); + }); } protected retry(): void { @@ -482,13 +463,8 @@ export class ProgramAnalyticsComponent implements OnInit { return this.activeTooltip() === key; } - protected barWidth(value: number, metrics: AnalyticsMetric[]): number { - const max = Math.max(...metrics.map(item => item.value)); - return max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0; - } - - protected hasValues(metrics: AnalyticsMetric[]): boolean { - return metrics.some(item => item.value > 0); + protected hasValues(metrics: readonly { value: number | null }[]): boolean { + return metrics.some(item => (item.value ?? 0) > 0); } protected downloadProjects(): void { @@ -512,9 +488,4 @@ export class ProgramAnalyticsComponent implements OnInit { ? "Сданные проекты, которые ещё не получили ни одной оценки эксперта." : "Сданные проекты без выполненных назначений или с выполненной только частью назначений."; } - - private formatDate(date: string): string { - const [, month = "", day = ""] = date.split("-"); - return `${day}.${month}`; - } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts index ce465103a..b2aafa3b6 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts @@ -11,6 +11,7 @@ import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-pro import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutVisitUseCase } from "@api/program/use-cases/get-program-manager-participants-without-visit.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service"; @@ -56,6 +57,7 @@ class RouteHost { describe("Attention SPA: observable route → resolver → overview", () => { it.each([ + "participants-without-visit", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", @@ -76,6 +78,7 @@ describe("Attention SPA: observable route → resolver → overview", () => { { provide: ActivatedRoute, useValue: { parent: { paramMap: params.asObservable() } } }, { provide: GetProgramManagerOverviewUseCase, useValue: overview }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: attention }, + { provide: GetProgramManagerParticipantsWithoutVisitUseCase, useValue: attention }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: attention }, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: attention }, { provide: GetProgramManagerAssignmentsUseCase, useValue: { execute: vi.fn() } }, @@ -98,7 +101,7 @@ describe("Attention SPA: observable route → resolver → overview", () => { await fixture.whenStable(); old.next( ok( - view === "participants-without-team" + view === "participants-without-team" || view === "participants-without-visit" ? participantsPage() : view === "projects-not-submitted" ? notSubmittedPage() diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts index f8b7f8277..f6319cde2 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts @@ -11,6 +11,7 @@ import { firstValueFrom, of, Subject } from "rxjs"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerParticipantsWithoutVisitUseCase } from "@api/program/use-cases/get-program-manager-participants-without-visit.use-case"; import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; import { @@ -36,6 +37,7 @@ describe("Attention views: real overlay", () => { let modal: ModalComponent; let trigger: HTMLButtonElement; const participants = { execute: vi.fn() }; + const visit = { execute: vi.fn() }; const projects = { execute: vi.fn() }; const notSubmitted = { execute: vi.fn() }; const dialog = () => document.querySelector('[role="dialog"]')!; @@ -46,6 +48,7 @@ describe("Attention views: real overlay", () => { beforeEach(async () => { participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); + visit.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); notSubmitted.execute.mockReset().mockReturnValue(of(ok(notSubmittedPage()))); await TestBed.configureTestingModule({ @@ -58,6 +61,7 @@ describe("Attention views: real overlay", () => { provideRouter([]), { provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: { timezone: "UTC" } }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, + { provide: GetProgramManagerParticipantsWithoutVisitUseCase, useValue: visit }, { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { @@ -130,6 +134,27 @@ describe("Attention views: real overlay", () => { expect(dialog().textContent).not.toMatch(/Ищет команду|Вуз|email|Напомнить/); }); + it("visit opens the participant list with server search and pagination", async () => { + visit.execute.mockReturnValue(of(ok(participantsPage({ count: 30 })))); + await open("participants-without-visit"); + expect(dialog().textContent).toContain("Участники ещё не заходили на платформу"); + expect(dialog().textContent).toContain("Анна Петрова"); + expect(dialog().querySelector("input")?.placeholder).toBe("Поиск по имени"); + expect(dialog().querySelector("a")?.getAttribute("href")).toBe("/office/profile/123"); + expect(participants.execute).not.toHaveBeenCalled(); + button("Далее").click(); + await fixture.whenStable(); + expect(visit.execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 25 }); + state.searchDraft.set(" Анна "); + state.applyAttentionSearch(); + await fixture.whenStable(); + expect(visit.execute).toHaveBeenLastCalledWith(12, { + search: "Анна", + limit: 25, + offset: 0, + }); + }); + it("server search только submit, clear, count и pagination из detail", async () => { participants.execute.mockReturnValue( of( @@ -362,18 +387,21 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-visit", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", ] as const)("%s: loading, empty, search_empty, error и retry отдельно", async view => { const api = - view === "participants-without-team" - ? participants - : view === "projects-not-submitted" - ? notSubmitted - : projects; + view === "participants-without-visit" + ? visit + : view === "participants-without-team" + ? participants + : view === "projects-not-submitted" + ? notSubmitted + : projects; const page = - view === "participants-without-team" + view === "participants-without-team" || view === "participants-without-visit" ? participantsPage() : view === "projects-not-submitted" ? notSubmittedPage() @@ -385,11 +413,13 @@ describe("Attention views: real overlay", () => { loading.next(ok({ ...page, count: 0, results: [] })); await fixture.whenStable(); expect(dialog().textContent).toContain( - view === "participants-without-team" - ? "Все зарегистрированные участники уже состоят в командах." - : view === "projects-not-submitted" - ? "Все проекты программы уже сданы." - : "Нет работ, ожидающих оценивания.", + view === "participants-without-visit" + ? "Все зарегистрированные участники уже заходили на платформу." + : view === "participants-without-team" + ? "Все зарегистрированные участники уже состоят в командах." + : view === "projects-not-submitted" + ? "Все проекты программы уже сданы." + : "Нет работ, ожидающих оценивания.", ); api.execute.mockReturnValueOnce(of(ok({ ...page, count: 0, results: [] }))); state.searchDraft.set("нет"); @@ -415,6 +445,7 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-visit", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", @@ -450,6 +481,7 @@ describe("Attention views: real overlay", () => { }); it.each([ + "participants-without-visit", "participants-without-team", "projects-awaiting-evaluation", "projects-not-submitted", diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts index 2f1d936fe..6763ceb1d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts @@ -58,6 +58,9 @@ describe("Case drilldown в существующем Overlay", () => { it("selected: четыре колонки, реальные поля, null presentation и только один modal", async () => { await open(); expect(dialog().querySelector("h2")?.textContent).toBe("Кейс: Цифровая трансформация"); + expect( + [...dialog().querySelectorAll(".analytics-case__metrics dt")].map(el => el.textContent), + ).toEqual(["Проекты", "Участники", "Сдано"]); expect([...dialog().querySelectorAll("th")].map(el => el.textContent)).toEqual([ "Проект", "Лидер / команда", @@ -81,6 +84,45 @@ describe("Case drilldown в существующем Overlay", () => { expect(dialog().textContent).not.toContain("Назначений экспертов"); expect(dialog().querySelectorAll("td[data-label]")).toHaveLength(8); }); + it("combines submission and presentation filters with search and resets them together", async () => { + await open(); + const input = dialog().querySelector("input")!; + input.value = "Проект"; + input.dispatchEvent(new Event("input", { bubbles: true })); + const submission = dialog().querySelector("#analytics-case-submission")!; + submission.value = "submitted"; + submission.dispatchEvent(new Event("change", { bubbles: true })); + await fixture.whenStable(); + const presentation = dialog().querySelector("#analytics-case-presentation")!; + presentation.value = "missing"; + presentation.dispatchEvent(new Event("change", { bubbles: true })); + await fixture.whenStable(); + expect(repo.getCaseProjects.mock.lastCall?.[1]).toMatchObject({ + search: "Проект", + submission: "submitted", + presentation: "missing", + offset: 0, + }); + expect(dialog().textContent).toMatch(/Найдено: 2\s+из 2/); + button("Сбросить фильтры").click(); + await fixture.whenStable(); + expect(repo.getCaseProjects.mock.lastCall?.[1]).toMatchObject({ + search: "", + submission: "all", + presentation: "all", + }); + expect(submission.value).toBe("all"); + expect(presentation.value).toBe("all"); + }); + it("shows a readable name for blank projects", async () => { + const page = casePage(); + page.results[0].project.name = " "; + repo.getCaseProjects.mockReturnValue(of(page)); + await open(); + expect(dialog().querySelector("td[data-label='Проект'] a")?.textContent).toBe( + "Проект без названия", + ); + }); it("without_case не требует definition/options и не превращается в selected по label", async () => { repo.getCaseProjects.mockReturnValue( of( @@ -97,10 +139,10 @@ describe("Case drilldown в существующем Overlay", () => { await open({ scope: "without_case" }); expect(dialog().querySelector("h2")?.textContent).toBe("Без выбранного кейса"); expect(dialog().textContent).toContain("больше недоступен"); - expect(button("Выгрузить проекты кейса").disabled).toBe(false); + expect(button("Выгрузить найденные").disabled).toBe(false); expect(repo.getCaseProjects.mock.lastCall?.[1].selection).toEqual({ scope: "without_case" }); }); - it("zero case, затем search/no results сохраняет full-case export", async () => { + it("zero case и поиск без результатов запрещают export", async () => { repo.getCaseProjects.mockReturnValue( of( casePage({ @@ -112,7 +154,7 @@ describe("Case drilldown в существующем Overlay", () => { ); await open(); expect(dialog().textContent).toContain("В этом кейсе пока нет проектов"); - expect(button("Выгрузить проекты кейса").disabled).toBe(true); + expect(button("Выгрузить найденные").disabled).toBe(true); const input = dialog().querySelector("input")!; input.value = " нет совпадений "; input.dispatchEvent(new Event("input", { bubbles: true })); @@ -121,8 +163,8 @@ describe("Case drilldown в существующем Overlay", () => { await fixture.whenStable(); expect(repo.getCaseProjects.mock.lastCall?.[1].search).toBe("нет совпадений"); expect(dialog().textContent).toContain("По запросу ничего не найдено"); - expect(button("Выгрузить проекты кейса").disabled).toBe(false); - button("Очистить").click(); + expect(button("Выгрузить найденные").disabled).toBe(true); + button("Сбросить фильтры").click(); await fixture.whenStable(); expect(input.value).toBe(""); }); @@ -132,7 +174,7 @@ describe("Case drilldown в существующем Overlay", () => { await open(); expect(dialog().textContent).toContain("Загружаем список"); expect(dialog().querySelector("dl")?.textContent).toContain("Проекты2"); - expect(button("Выгрузить проекты кейса").disabled).toBe(true); + expect(button("Выгрузить найденные").disabled).toBe(true); const detached = firstValueFrom(modal.overlayRef!.detachments()); dialog() .querySelector('[aria-label="Закрыть детализацию аналитики"]')! @@ -157,7 +199,7 @@ describe("Case drilldown в существующем Overlay", () => { ); await open(); expect(dialog().querySelector('[role="alert"]')?.textContent).toContain(message); - expect(button("Выгрузить проекты кейса").disabled).toBe(true); + expect(button("Выгрузить найденные").disabled).toBe(true); if (status === 500) { repo.getCaseProjects.mockReturnValue(of(casePage())); button("Повторить загрузку").click(); @@ -169,7 +211,7 @@ describe("Case drilldown в существующем Overlay", () => { repo.getCaseProjects.mockReturnValue(of({ count: 2, results: [{ id: 73 }] })); await open(); expect(dialog().textContent).toContain("пока недоступна на сервере"); - button("Выгрузить проекты кейса").click(); + button("Выгрузить найденные").click(); expect(repo.exportCaseProjects).not.toHaveBeenCalled(); }); it("noncompetitive: сдача не требуется, evaluation не добавляется", async () => { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html index 3e9da810e..e6d313aa2 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html @@ -1,1379 +1,717 @@ - -@if (releaseDesktop()) { - + + - - - - - - - - - - - - @for (item of assignments; track item.assignmentId) { - - + + + } + +
    ЭкспертПроектСтатусОжидание
    - {{ - item.expert.fullName || "Имя не указано" - }} + + + + + + + + + + + @for (item of assignments; track item.assignmentId) { + + + + - - - - - } - -
    ЭкспертПроектСтатусОжидание
    + {{ + item.expert.fullName || "Имя не указано" + }} + {{ item.project.name }} + + {{ statusLabel(item.status) }} - {{ item.project.name }} - - {{ statusLabel(item.status) }} - @if (item.status === "completed") { - - } - - {{ waiting(item.waitingSeconds, item.status) }}
    - -} + Посмотреть оценку + + } + +
    {{ waiting(item.waitingSeconds, item.status) }}
    +
    diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss index 05cbc1018..519a42722 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss @@ -1,13 +1,20 @@ /** @format */ + @use "styles/responsive"; -// Только case-вариант: shared modal задаёт width своим stylesheet после подключения overlay. .modal__body.analytics-drilldown-body.analytics-drilldown-body--case { box-sizing: border-box; width: min(880px, calc(100% - 24px)); - max-height: calc(100dvh - 32px); padding: 20px; overflow-y: auto; + + @media screen and (max-width: 999px) { + max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); + } + + @media screen and (min-width: 1000px) { + max-height: calc(100dvh - 32px); + } } .analytics-case { @@ -28,7 +35,7 @@ } dt { - font-size: 12px; + font-size: var(--ui-font-small, 12px); color: var(--dark-grey); } @@ -51,7 +58,7 @@ display: grid; gap: 6px; margin-bottom: 16px; - font-size: 12px; + font-size: var(--ui-font-small, 12px); app-button { width: 100%; @@ -66,6 +73,42 @@ } } + &__filters { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + margin: 12px 0 20px; + + label { + display: grid; + gap: 6px; + font-size: var(--ui-font-small, 12px); + color: var(--dark-grey); + } + + select { + box-sizing: border-box; + width: 100%; + min-height: 44px; + padding: 10px 12px; + font: inherit; + font-size: 14px; + color: var(--black); + background: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + @media screen and (min-width: 600px) { + grid-template-columns: repeat(2, minmax(0, 220px)); + } + } + &__loading { display: flex; gap: 8px; @@ -76,7 +119,7 @@ } .analytics-drilldown__search-controls { - max-width: 520px; + max-width: 100%; input { flex-basis: 100%; @@ -92,7 +135,7 @@ small { display: block; margin-top: 4px; - font-size: 12px; + font-size: var(--ui-font-small, 12px); } @include responsive.apply-desktop { @@ -128,16 +171,21 @@ } } -// Overlay лежит вне host: единственное глобальное правило ограничено собственным bodyClass. .modal__body.analytics-drilldown-body { box-sizing: border-box; width: min(880px, calc(100vw - 24px)); - max-height: calc(100dvh - 32px); padding: 20px; overflow-y: auto; + + @media screen and (max-width: 999px) { + max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); + } + + @media screen and (min-width: 1000px) { + max-height: calc(100dvh - 32px); + } } -// Тот же шаблон используется в backlog: оформление меняется только у трёх списков назначений. .modal__body.analytics-drilldown-body.analytics-drilldown-body--assignments { width: min(980px, calc(100vw - 32px)); padding: 16px; @@ -151,8 +199,15 @@ margin-bottom: 24px; h2 { - font-size: 24px; line-height: 30px; + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-h1, 24px); + } + + @media screen and (min-width: 1000px) { + font-size: 24px; + } } } @@ -171,7 +226,7 @@ .analytics-drilldown { width: 100%; min-width: 0; - font-size: 14px; + font-size: var(--ui-font-body, 14px); line-height: 1.5; color: var(--black); overflow-wrap: anywhere; @@ -182,7 +237,7 @@ } h3 { - font-size: 16px; + font-size: var(--ui-font-h3, 16px); font-weight: 600; } @@ -359,7 +414,7 @@ &__badge { display: inline-block; padding: 3px 7px; - font-size: 12px; + font-size: var(--ui-font-small, 12px); font-weight: 600; line-height: 18px; color: var(--dark-grey); @@ -415,7 +470,7 @@ td::before { display: block; - font-size: 12px; + font-size: var(--ui-font-small, 12px); font-weight: 600; color: var(--dark-grey); content: attr(data-label); @@ -520,9 +575,17 @@ .analytics-drilldown__action { padding: 4px 0; - font-size: 13px; - font-weight: 500; line-height: 20px; + + @media screen and (max-width: 999px) { + font-size: var(--ui-font-body, 14px); + font-weight: 500; + } + + @media screen and (min-width: 1000px) { + font-size: 13px; + font-weight: 500; + } } @include responsive.apply-desktop { @@ -582,64 +645,3 @@ } } } - -// Адаптив из dev применяется только ниже desktop breakpoint. -@media screen and (max-width: 999px) { - .modal__body.analytics-drilldown-body.analytics-drilldown-body--case { - max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); - } - - .analytics-case { - &__metrics { - dt { - font-size: var(--ui-font-small); - } - } - - &__export { - font-size: var(--ui-font-small); - } - - .analytics-case__table { - small { - font-size: var(--ui-font-small); - } - } - } - - .modal__body.analytics-drilldown-body { - max-height: calc(var(--visual-viewport-height, 100dvh) - 32px); - } - - .modal__body.analytics-drilldown-body.analytics-drilldown-body--assignments { - .analytics-drilldown__header { - h2 { - font-size: var(--ui-font-h1); - } - } - } - - .analytics-drilldown { - font-size: var(--ui-font-body); - - h3 { - font-size: var(--ui-font-h3); - } - - &__badge { - font-size: var(--ui-font-small); - } - - &__table { - td::before { - font-size: var(--ui-font-small); - } - } - - &__table--assignments { - .analytics-drilldown__action { - font-size: var(--ui-font-body); - } - } - } -} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index 1fe36c32b..a7184a3ae 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -76,7 +76,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { modal = fixture.debugElement.query(By.directive(ModalComponent)).componentInstance; state = fixture.debugElement.injector.get(ProgramAnalyticsDrilldownService); trigger = document.createElement("button"); - trigger.textContent = "Назначений"; + trigger.textContent = "Назначений всего"; document.body.append(trigger); trigger.focus(); }); @@ -277,7 +277,7 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await attached; await fixture.whenStable(); const trap = fixture.debugElement.query(By.directive(CdkTrapFocus)).injector.get(CdkTrapFocus); - expect(dialog().textContent).toContain("Критическая задержка"); + expect(dialog().textContent).toContain("Дедлайн оценивания прошёл"); expect(dialog().textContent).toContain("Выполнено 3 из 8"); expect(dialog().textContent).toContain("2 д 4 ч"); button("Посмотреть назначения").click(); @@ -364,13 +364,35 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { fixture.componentInstance.openDelayed(trigger); await attached; await fixture.whenStable(); - expect(dialog().textContent).toContain("Требует внимания"); - expect(dialog().textContent).not.toContain("Критическая задержка"); + expect(dialog().textContent).toContain("До дедлайна ≤ 5 дней"); + expect(dialog().textContent).not.toContain("Дедлайн оценивания прошёл"); button("Посмотреть назначения").click(); await fixture.whenStable(); expect(dialog().textContent).toContain("У эксперта нет ожидающих назначений"); }); + it("сигнал по дедлайну отображается и при неизвестном времени ожидания", async () => { + fixture.componentRef.setInput("delayedExperts", { + total: 1, + items: [ + delayedExpert({ + severity: "warning", + overdue24H: 0, + overdue48H: 0, + oldestWaitingSince: null, + oldestWaitingSeconds: null, + }), + ], + }); + const attached = firstValueFrom(modal.overlayRef!.attachments()); + fixture.componentInstance.openDelayed(trigger); + await attached; + await fixture.whenStable(); + expect(dialog().textContent).toContain("До дедлайна ≤ 5 дней"); + expect(dialog().textContent).toContain("Самое долгое ожидание: Нет данных"); + expect(dialog().textContent).toContain("Не завершено: 5"); + }); + it("не выполняет запросы для отсутствующей программы и не обрабатывает Escape без attachment", async () => { fixture.componentRef.setInput("programId", null); await fixture.whenStable(); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts index 0599679a7..a09ee281e 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts @@ -1,8 +1,5 @@ /** @format */ -import { inject as injectReleaseLayout } from "@angular/core"; -import { DesktopLayoutService as ReleaseDesktopLayoutService } from "../../../../../../../../../ui/src/lib/services/desktop-layout.service"; - import { ButtonDirective, FieldDirective, @@ -11,7 +8,7 @@ import { PaginationComponent, StateComponent, } from "@uilib"; - +/** @format */ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { ProgramCaseProjectsService } from "@api/program/facades/detail/program-case-projects.service"; import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; @@ -82,8 +79,6 @@ import { encapsulation: ViewEncapsulation.None, }) export class AnalyticsDrilldownComponent implements AfterViewInit { - protected readonly releaseDesktop = injectReleaseLayout(ReleaseDesktopLayoutService).desktop; - readonly programId = input(null); readonly programName = input("program"); readonly notSubmittedApplicable = input(false); @@ -119,6 +114,8 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { } case "participants-without-team": return "Участники без команды"; + case "participants-without-visit": + return "Участники ещё не заходили на платформу"; case "projects-awaiting-evaluation": return "Работы ожидают оценивания"; case "projects-not-submitted": @@ -153,6 +150,8 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { return "Для этой программы сдача решения не требуется."; if (this.state.appliedSearch()) return "По вашему запросу ничего не найдено."; if (this.state.view() === "projects-not-submitted") return "Все проекты программы уже сданы."; + if (this.state.view() === "participants-without-visit") + return "Все зарегистрированные участники уже заходили на платформу."; return this.state.view() === "participants-without-team" ? "Все зарегистрированные участники уже состоят в командах." : "Нет работ, ожидающих оценивания."; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.html new file mode 100644 index 000000000..79cbd0238 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.html @@ -0,0 +1,35 @@ + + +
    + +
    +
    + {{ definitionsLabel() }} + + + + + + + + + + @for (item of items(); track item.key) { + + + + + } + +
    + {{ + label() + }} +
    {{ categoryLabel() }}Всего
    + {{ item.label }}{{ item.tooltip }} + {{ item.value ?? "Данные пока недоступны" }}
    +
    diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.scss new file mode 100644 index 000000000..d56f658dc --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.scss @@ -0,0 +1,64 @@ +/** @format */ + +:host { + display: block; + min-width: 0; + margin-top: 12px; +} + +.funnel-chart { + &__plot { + position: relative; + height: 230px; + } + + &__data { + margin-top: 12px; + border-top: 1px solid var(--medium-grey-for-outline); + + summary { + padding-top: 12px; + font-size: 12px; + line-height: 18px; + color: var(--accent-dark); + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + table { + width: 100%; + margin-top: 12px; + font-size: 12px; + border-collapse: collapse; + } + + caption { + text-align: left; + } + + th, + td { + padding: 10px 0; + text-align: left; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + th span { + display: block; + margin-top: 4px; + font-weight: 400; + line-height: 18px; + color: var(--grey-for-text); + } + + td, + th:last-child { + padding-left: 12px; + text-align: right; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.spec.ts new file mode 100644 index 000000000..1fbb2960d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.spec.ts @@ -0,0 +1,95 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { Chart } from "chart.js"; +import { AnalyticsFunnelChartComponent } from "./funnel-chart.component"; + +vi.mock("chart.js", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + Chart: Object.assign( + vi.fn(function (_canvas, config) { + return { + data: config.data, + options: config.options, + update: vi.fn(), + destroy: vi.fn(), + resize: vi.fn(), + }; + }), + { register: vi.fn() }, + ), + }; +}); + +describe("AnalyticsFunnelChartComponent", () => { + beforeEach(() => { + vi.mocked(Chart).mockClear(); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + TestBed.configureTestingModule({ imports: [AnalyticsFunnelChartComponent] }); + }); + + const stage = (key: string, value: number | null) => ({ + key, + label: key, + value, + tooltip: `Определение ${key}`, + }); + + async function render() { + const fixture = TestBed.createComponent(AnalyticsFunnelChartComponent); + fixture.componentRef.setInput("label", "Воронка участников"); + fixture.componentRef.setInput("items", [ + stage("Регистрация", 7), + stage("Онбординг", 2), + stage("Команда", 4), + ]); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture; + } + + it("preserves stage order and actual nonmonotonic counts on a zero-based integer scale", async () => { + const fixture = await render(); + const config = vi.mocked(Chart).mock.calls[0][1]; + expect(config.type).toBe("bar"); + expect(config.data.labels).toEqual(["Регистрация", "Онбординг", "Команда"]); + expect(config.data.datasets[0].data).toEqual([7, 2, 4]); + expect(config.options?.scales?.["y"]?.beginAtZero).toBe(true); + expect(config.options?.scales?.["y"]?.ticks?.precision).toBe(0); + const root = fixture.nativeElement as HTMLElement; + expect([...root.querySelectorAll("tbody td")].map(td => td.textContent)).toEqual([ + "7", + "2", + "4", + ]); + expect(root.querySelector("canvas")?.getAttribute("aria-label")).toContain( + "Воронка участников", + ); + expect(root.querySelector("tbody th span")?.textContent).toBe("Определение Регистрация"); + }); + + it("distinguishes unavailable metrics from genuine zero and reuses the chart on refresh", async () => { + const fixture = await render(); + const chart = vi.mocked(Chart).mock.results[0].value; + fixture.componentRef.setInput("items", [ + stage("Регистрация", 7), + stage("Онбординг", null), + stage("Команда", 0), + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(Chart).toHaveBeenCalledOnce(); + expect(chart.data.datasets[0].data).toEqual([7, null, 0]); + const root = fixture.nativeElement as HTMLElement; + expect([...root.querySelectorAll("tbody td")].map(td => td.textContent)).toEqual([ + "7", + "Данные пока недоступны", + "0", + ]); + expect(chart.update).toHaveBeenCalledOnce(); + fixture.destroy(); + expect(chart.destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.ts new file mode 100644 index 000000000..3be549246 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.ts @@ -0,0 +1,198 @@ +/** @format */ + +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + input, + NgZone, + viewChild, +} from "@angular/core"; +import { + BarController, + BarElement, + CategoryScale, + Chart, + LinearScale, + Plugin, + Tooltip, +} from "chart.js"; + +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip); + +export interface AnalyticsFunnelStage { + key: string; + label: string; + /** null means the API has not supplied this metric, rather than zero people. */ + value: number | null; + tooltip: string; + axisLabel?: string[]; + tone?: "accent" | "success" | "muted"; +} + +@Component({ + selector: "app-analytics-funnel-chart", + templateUrl: "./funnel-chart.component.html", + styleUrl: "./funnel-chart.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsFunnelChartComponent { + readonly items = input.required(); + readonly label = input.required(); + readonly kind = input<"participants" | "projects">("participants"); + readonly categoryLabel = input("Этап"); + readonly definitionsLabel = input("Данные и определения этапов"); + + private readonly canvas = viewChild.required>("canvas"); + private readonly zone = inject(NgZone); + private chart?: Chart<"bar", (number | null)[], string | string[]>; + + constructor() { + afterRenderEffect(() => { + const items = this.items(); + const kind = this.kind(); + this.zone.runOutsideAngular(() => this.renderChart(items, kind)); + }); + inject(DestroyRef).onDestroy(() => this.chart?.destroy()); + } + + private renderChart(items: readonly AnalyticsFunnelStage[], kind: string): void { + const canvas = this.canvas().nativeElement; + const style = getComputedStyle(canvas); + const color = style.getPropertyValue(kind === "projects" ? "--green-dark" : "--accent").trim(); + const colors = items.map(item => + item.tone + ? style + .getPropertyValue( + item.tone === "success" + ? "--green-dark" + : item.tone === "muted" + ? "--grey-for-text" + : "--accent", + ) + .trim() + : color, + ); + const textColor = style.getPropertyValue("--black").trim(); + const mutedColor = style.getPropertyValue("--grey-for-text").trim(); + const gridColor = style.getPropertyValue("--medium-grey-for-outline").trim(); + const labels = items.map(item => item.axisLabel ?? item.label); + const counts = items.map(item => item.value); + const maximum = Math.max(1, ...counts.map(value => value ?? 0)); + + if (this.chart) { + this.chart.data.labels = labels; + this.chart.data.datasets[0].data = counts; + this.chart.data.datasets[0].backgroundColor = colors; + this.chart.data.datasets[0].hoverBackgroundColor = colors; + this.chart.options.scales!["y"]!.suggestedMax = maximum; + this.chart.update(); + return; + } + + const values: Plugin<"bar"> = { + id: "funnel-values", + afterDatasetsDraw: chart => { + const { ctx } = chart; + ctx.save(); + ctx.font = `600 14px ${style.fontFamily}`; + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + chart.getDatasetMeta(0).data.forEach((bar, index) => { + const value = chart.data.datasets[0].data[index]; + const baseline = chart.scales["y"].getPixelForValue(0); + const y = value == null ? baseline : bar.y; + const x = chart.scales["x"].getPixelForValue(index); + ctx.fillText( + value == null ? "—" : new Intl.NumberFormat("ru-RU").format(Number(value)), + x, + y - 8, + ); + }); + ctx.restore(); + }, + }; + + this.chart = new Chart(canvas, { + type: "bar", + data: { + labels, + datasets: [ + { + data: counts, + backgroundColor: colors, + hoverBackgroundColor: colors, + borderRadius: { topLeft: 8, topRight: 8, bottomLeft: 0, bottomRight: 0 }, + borderSkipped: "bottom", + maxBarThickness: 64, + categoryPercentage: 0.68, + barPercentage: 0.82, + }, + ], + }, + plugins: [values], + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 350 }, + layout: { padding: { top: 28, right: 4 } }, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: textColor, + padding: 12, + cornerRadius: 8, + displayColors: false, + titleFont: { family: style.fontFamily }, + bodyFont: { family: style.fontFamily }, + callbacks: { + title: contexts => this.items()[contexts[0].dataIndex].label, + label: context => { + const stage = this.items()[context.dataIndex]; + return stage.value == null ? "Данные пока недоступны" : `Всего: ${stage.value}`; + }, + }, + }, + }, + scales: { + x: { + border: { display: false }, + grid: { display: false }, + ticks: { + autoSkip: false, + maxRotation: 0, + minRotation: 0, + padding: 12, + color: textColor, + font: { family: style.fontFamily, size: 11, lineHeight: 1.4 }, + }, + }, + y: { + beginAtZero: true, + suggestedMax: maximum, + border: { display: false }, + grid: { color: gridColor, drawTicks: false }, + ticks: { + precision: 0, + maxTicksLimit: 5, + padding: 8, + color: mutedColor, + font: { family: style.fontFamily, size: 10 }, + }, + }, + }, + }, + }); + document.fonts?.ready.then(() => { + if (this.chart?.canvas?.isConnected) { + this.chart.resize(); + this.chart.update("none"); + } + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.html new file mode 100644 index 000000000..ad50fd6de --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.html @@ -0,0 +1,38 @@ + + +

    {{ unit() }} по регионам · по убыванию

    +
    +
    + +
    +
    +
    + + Данные по регионам {{ sortedItems().length }} + + + + + + + + + + + @for (region of sortedItems(); track $index) { + + + + + } + +
    + {{ + label() + }} +
    Регион{{ unit() }}
    {{ region.name }}{{ region.count }}
    +
    diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.scss new file mode 100644 index 000000000..79665728c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.scss @@ -0,0 +1,81 @@ +/** @format */ + +:host { + display: block; + min-width: 0; + margin-top: 12px; +} + +.region-chart { + &__note { + margin-bottom: 8px; + font-size: 11px; + line-height: 18px; + color: var(--grey-for-text); + } + + &__scroll { + max-height: 420px; + overflow-y: auto; + scrollbar-width: thin; + + &:focus-visible { + border-radius: 4px; + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + &__plot { + position: relative; + width: 100%; + min-width: 0; + } + + &__data { + margin-top: 12px; + border-top: 1px solid var(--medium-grey-for-outline); + + summary { + padding: 12px 0 0; + font-size: 12px; + color: var(--accent-dark); + cursor: pointer; + + span { + margin-left: 6px; + color: var(--grey-for-text); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + table { + width: 100%; + margin-top: 12px; + font-size: 12px; + border-collapse: collapse; + } + + caption { + text-align: left; + } + + th, + td { + padding: 10px 0; + text-align: left; + overflow-wrap: anywhere; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + td, + th:last-child { + padding-left: 12px; + text-align: right; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.spec.ts new file mode 100644 index 000000000..3c671092f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.spec.ts @@ -0,0 +1,98 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { Chart } from "chart.js"; +import { AnalyticsRegionChartComponent, regionLabelLines } from "./region-chart.component"; + +vi.mock("chart.js", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + Chart: Object.assign( + vi.fn(function (_canvas, config) { + return { + data: config.data, + options: config.options, + update: vi.fn(), + destroy: vi.fn(), + resize: vi.fn(), + }; + }), + { register: vi.fn() }, + ), + }; +}); + +describe("AnalyticsRegionChartComponent", () => { + beforeEach(() => { + vi.mocked(Chart).mockClear(); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + TestBed.configureTestingModule({ imports: [AnalyticsRegionChartComponent] }); + }); + + async function render() { + const fixture = TestBed.createComponent(AnalyticsRegionChartComponent); + fixture.componentRef.setInput("label", "Регионы проектов"); + fixture.componentRef.setInput("items", [ + { name: "Набережные Челны", count: 2 }, + { name: "Москва", count: 12 }, + { name: "Без региона", count: 0 }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture; + } + + it("plots authoritative counts from zero in descending order with exact accessible values", async () => { + const fixture = await render(); + const root = fixture.nativeElement as HTMLElement; + expect([...root.querySelectorAll("tbody tr")].map(row => row.textContent)).toEqual([ + "Москва12", + "Набережные Челны2", + "Без региона0", + ]); + const config = vi.mocked(Chart).mock.calls[0][1]; + expect(config.type).toBe("bar"); + expect(config.data.datasets[0].data).toEqual([12, 2, 0]); + expect(config.options?.indexAxis).toBe("y"); + expect(config.options?.scales?.["x"]?.beginAtZero).toBe(true); + expect(config.options?.animation).toEqual({ duration: 0 }); + expect(root.querySelector("canvas")?.getAttribute("aria-label")).toContain("Регионы проектов"); + }); + + it("updates an existing chart when counts change and destroys it on navigation", async () => { + const fixture = await render(); + const chart = vi.mocked(Chart).mock.results[0].value; + fixture.componentRef.setInput("items", [{ name: "Казань", count: 3 }]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(Chart).toHaveBeenCalledOnce(); + expect(chart.data.datasets[0].data).toEqual([3]); + expect(chart.update).toHaveBeenCalledOnce(); + fixture.destroy(); + expect(chart.destroy).toHaveBeenCalledOnce(); + }); + + it("keeps every region and its full name for long, scrollable distributions", async () => { + const fixture = await render(); + const name = "Кабардино-Балкарская Республика"; + fixture.componentRef.setInput( + "items", + Array.from({ length: 40 }, (_, i) => ({ name: `${name} ${i}`, count: i })), + ); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelectorAll("tbody tr")).toHaveLength(40); + expect(fixture.nativeElement.querySelector("tbody tr th").textContent).toBe(`${name} 39`); + expect(regionLabelLines(name).join(" ")).toBe(name); + const unbroken = "ОченьДлинноеНазваниеБезПробелов".repeat(3); + expect(regionLabelLines(unbroken).join("")).toBe(unbroken); + expect(regionLabelLines(unbroken).every(line => line.length <= 22)).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.ts new file mode 100644 index 000000000..5f357fcc1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/region-chart/region-chart.component.ts @@ -0,0 +1,234 @@ +/** @format */ + +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + NgZone, + viewChild, +} from "@angular/core"; +import { ProgramAnalyticsRegion } from "@domain/program/program-analytics.model"; +import { + BarController, + BarElement, + CategoryScale, + Chart, + LinearScale, + Plugin, + Tooltip, +} from "chart.js"; + +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip); + +/** Wrap full region names, including unbroken legacy values, without dropping text. */ +export function regionLabelLines( + name: string, + fits = (line: string) => line.length <= 22, +): string[] { + const lines: string[] = []; + let current = ""; + for (const word of name.split(/\s+/)) { + if (current && fits(current + " " + word)) { + current += " " + word; + continue; + } + if (current) lines.push(current); + let remainder = word; + while (remainder.length > 1 && !fits(remainder)) { + let end = 1; + while (end < remainder.length && fits(remainder.slice(0, end + 1))) end++; + const hyphen = remainder.slice(0, end).lastIndexOf("-"); + if (hyphen > 0) end = hyphen + 1; + lines.push(remainder.slice(0, end)); + remainder = remainder.slice(end); + } + current = remainder; + } + if (current) lines.push(current); + return lines; +} + +@Component({ + selector: "app-analytics-region-chart", + templateUrl: "./region-chart.component.html", + styleUrl: "./region-chart.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsRegionChartComponent { + readonly items = input.required(); + readonly label = input.required(); + readonly kind = input<"projects" | "participants">("projects"); + + protected readonly sortedItems = computed(() => + [...this.items()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "ru")), + ); + protected readonly chartHeight = computed(() => { + const rowHeight = Math.max( + 52, + ...this.sortedItems().map( + item => regionLabelLines(item.name, line => line.length <= 12).length * 15 + 16, + ), + ); + return Math.max(180, this.sortedItems().length * rowHeight + 36); + }); + protected readonly unit = computed(() => (this.kind() === "projects" ? "Проекты" : "Участники")); + private readonly canvas = viewChild.required>("canvas"); + private readonly zone = inject(NgZone); + private chart?: Chart<"bar", number[], string | string[]>; + + constructor() { + afterRenderEffect(() => { + const items = this.sortedItems(); + const kind = this.kind(); + const unit = this.unit(); + this.zone.runOutsideAngular(() => this.renderChart(items, kind, unit)); + }); + inject(DestroyRef).onDestroy(() => this.chart?.destroy()); + } + + private renderChart(items: ProgramAnalyticsRegion[], kind: string, unit: string): void { + const canvas = this.canvas().nativeElement; + const style = getComputedStyle(canvas); + const color = style.getPropertyValue(kind === "projects" ? "--accent" : "--green-dark").trim(); + const textColor = style.getPropertyValue("--black").trim(); + const mutedColor = style.getPropertyValue("--grey-for-text").trim(); + const gridColor = style.getPropertyValue("--medium-grey-for-outline").trim(); + const labels = items.map(item => item.name); + const counts = items.map(item => item.count); + const maximum = Math.max(1, ...counts); + const valuePadding = Math.max( + 44, + new Intl.NumberFormat("ru-RU").format(maximum).length * 8 + 16, + ); + + if (this.chart) { + this.chart.data.labels = labels; + this.chart.data.datasets[0].data = counts; + this.chart.data.datasets[0].label = unit; + this.chart.data.datasets[0].backgroundColor = color; + this.chart.data.datasets[0].hoverBackgroundColor = color; + this.chart.options.scales!["x"]!.suggestedMax = maximum; + this.chart.options.layout!.padding = { left: 4, right: valuePadding, top: 6 }; + this.chart.update(); + return; + } + + const values: Plugin<"bar"> = { + id: "region-values", + afterDatasetsDraw: chart => { + const { ctx } = chart; + ctx.save(); + ctx.font = `600 12px ${style.fontFamily}`; + ctx.fillStyle = textColor; + ctx.textBaseline = "middle"; + chart.getDatasetMeta(0).data.forEach((bar, index) => { + const value = chart.data.datasets[0].data[index]; + ctx.fillText(new Intl.NumberFormat("ru-RU").format(Number(value)), bar.x + 8, bar.y); + }); + ctx.restore(); + }, + }; + + this.chart = new Chart(canvas, { + type: "bar", + data: { + labels, + datasets: [ + { + label: unit, + data: counts, + backgroundColor: color, + borderRadius: 6, + borderSkipped: false, + barThickness: 18, + hoverBackgroundColor: color, + }, + ], + }, + plugins: [values], + options: { + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + animation: { duration: matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 350 }, + layout: { padding: { left: 4, right: valuePadding, top: 6 } }, + interaction: { mode: "index", intersect: false }, + plugins: { + tooltip: { + backgroundColor: textColor, + padding: 12, + cornerRadius: 8, + displayColors: false, + titleFont: { family: style.fontFamily }, + bodyFont: { family: style.fontFamily }, + callbacks: { + title: contexts => this.sortedItems()[contexts[0].dataIndex].name, + }, + }, + }, + scales: { + x: { + beginAtZero: true, + suggestedMax: maximum, + border: { display: false }, + grid: { color: gridColor, drawTicks: false }, + ticks: { + precision: 0, + maxTicksLimit: 4, + color: mutedColor, + padding: 10, + font: { family: style.fontFamily, size: 10 }, + }, + }, + y: { + border: { display: false }, + grid: { display: false }, + // Font loading can change measurements cached by Chart.js. Fit the + // label column to the rendered font, including every wrapped line. + afterFit: scale => { + const ctx = scale.chart.ctx; + ctx.save(); + ctx.font = `11px ${style.fontFamily}`; + const widths = scale.ticks.flatMap(tick => { + const lines = Array.isArray(tick.label) ? tick.label : [tick.label]; + return lines.map(line => ctx.measureText(String(line)).width); + }); + ctx.restore(); + scale.width = Math.ceil(Math.max(0, ...widths)) + 24; + }, + ticks: { + autoSkip: false, + callback: function (value) { + const ctx = this.chart.ctx; + ctx.save(); + ctx.font = `11px ${style.fontFamily}`; + const width = Math.max(60, Math.min(145, this.chart.width * 0.42 - 16)); + const lines = regionLabelLines( + this.getLabelForValue(Number(value)), + line => ctx.measureText(line).width <= width, + ); + ctx.restore(); + return lines; + }, + color: textColor, + padding: 10, + font: { family: style.fontFamily, size: 11, lineHeight: 1.35 }, + }, + }, + }, + }, + }); + // Recalculate text widths once the site's font has loaded. + document.fonts?.ready.then(() => { + if (this.chart?.canvas?.isConnected) { + this.chart.resize(); + this.chart.update("none"); + } + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html index 997bb9346..9187afca6 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html @@ -59,7 +59,7 @@

    аналитика

    }
      - @for (step of steps(participant.stage); track step.label) { + @for (step of steps(participant.stage, project !== null); track step.label) {
    1. аналитика } @case ("organizer") { @let metrics = data.organizer; - @let ratio = withoutProject(metrics);
      Участники
      @@ -103,15 +102,11 @@

      аналитика

      Без проектов
      {{ count(metrics.participantsWithoutProject) }} - {{ ratio.percentage }}
      @@ -261,7 +256,7 @@

      аналитика

      }
        - @for (step of steps(participant.stage); track step.label) { + @for (step of steps(participant.stage, project !== null); track step.label) {
      1. аналитика } @case ("organizer") { @let metrics = data.organizer; - @let ratio = withoutProject(metrics);
        Участники
        @@ -305,15 +299,11 @@

        аналитика

        Без проектов
        {{ count(metrics.participantsWithoutProject) }} - {{ ratio.percentage }}
        diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts index 591a77eb1..8d7f5f33d 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.spec.ts @@ -68,9 +68,9 @@ describe("ProgramRoleWidgetComponent", () => { expect(link.getAttribute("href")).toBe("/office/projects/5?programLinkId=34"); expect(links[0].textContent).toContain(participant.participant.participantProject!.name); expect(f.nativeElement.querySelector('[aria-current="step"]').textContent).toContain( - "Проверка", + "Проверен", ); - expect(f.nativeElement.querySelectorAll(".widget__step--complete")).toHaveLength(1); + expect(f.nativeElement.querySelectorAll(".widget__step--complete")).toHaveLength(2); }); it("does not create or submit automatically and uses existing explicit action", () => { const f = render({ @@ -89,6 +89,39 @@ describe("ProgramRoleWidgetComponent", () => { expect(f.nativeElement.querySelectorAll(".widget__step--complete")).toHaveLength(3); expect(f.nativeElement.querySelector('[aria-current="step"]')).toBeNull(); }); + it.each([ + ["not_submitted", true, 1, "Отправлен"], + ["submitted", true, 2, "Проверен"], + ["none", false, 0, "Создан"], + ["not_applicable", true, 1, null], + ["not_applicable", false, 0, "Создан"], + ] as const)( + "shows participant milestones for %s (project: %s)", + (stage, hasProject, completed, current) => { + const f = render({ + ...participant, + isCompetitive: stage !== "not_applicable", + participant: { + ...participant.participant, + participantProject: hasProject ? participant.participant.participantProject : null, + stage, + }, + }); + const list = f.nativeElement.querySelector( + '[aria-label="Этапы проекта"]', + ) as HTMLOListElement; + expect( + [...list.querySelectorAll("li > span:last-child")].map(el => el.textContent?.trim()), + ).toEqual(["Создан", "Отправлен", "Проверен"]); + expect(list.querySelectorAll(".widget__step--complete")).toHaveLength(completed); + expect(list.querySelector('[aria-current="step"]')?.textContent ?? null).toEqual( + current ? expect.stringContaining(current) : null, + ); + expect(list.textContent).not.toMatch(/Проверка|Оценён/); + if (stage === "not_applicable") + expect(f.nativeElement.textContent).toContain("Сдача не предусмотрена"); + }, + ); it("has no broken action when submission is closed", () => { const f = render({ ...participant, @@ -110,7 +143,7 @@ describe("ProgramRoleWidgetComponent", () => { expect(f.nativeElement.textContent).toContain("Не отправлен"); expect(f.nativeElement.querySelectorAll("a")).toHaveLength(2); }); - it("shows four organizer counters and one inline percentage", () => { + it("shows four organizer counts without a standalone percentage", () => { const f = render({ role: "organizer", programId: 12, @@ -123,7 +156,10 @@ describe("ProgramRoleWidgetComponent", () => { }, }); expect(f.nativeElement.querySelectorAll("dd")).toHaveLength(4); - expect(f.nativeElement.textContent).toContain("7,7%"); + expect(f.nativeElement.textContent).not.toContain("%"); + expect( + [...f.nativeElement.querySelectorAll("dd")].map((el: HTMLElement) => el.textContent?.trim()), + ).toEqual(["248", "61", "54", "19"]); expect(f.nativeElement.querySelector("a").getAttribute("href")).toBe( "/office/program/12/analytics", ); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts index 717a4e986..299e1b80c 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.ts @@ -20,7 +20,6 @@ import { ProgramRoleWidgetService } from "@api/program/facades/detail/program-ro import { expertWidgetPresentation, participantSteps, - withoutProjectPresentation, } from "@api/program/program-widget-presentation"; import { DetailProgramInfoService } from "@ui/widgets/detail/services/program/detail-program-info.service"; @@ -47,7 +46,6 @@ export class ProgramRoleWidgetComponent { readonly roleNames = { organizer: "организатор", expert: "эксперт", participant: "участник" }; readonly routes = AppRoutes; readonly steps = participantSteps; - readonly withoutProject = withoutProjectPresentation; readonly now = signal(Date.now()); readonly expertState = computed(() => { const data = this.data(); diff --git a/projects/social_platform/src/app/utils/program-deadlines.spec.ts b/projects/social_platform/src/app/utils/program-deadlines.spec.ts new file mode 100644 index 000000000..0e8f16c9e --- /dev/null +++ b/projects/social_platform/src/app/utils/program-deadlines.spec.ts @@ -0,0 +1,94 @@ +/** @format */ + +import { programDeadlines, ProgramScheduleDates } from "./program-deadlines"; + +describe("programDeadlines", () => { + const now = new Date(2026, 9, 14, 12, 0); + const at = (day: number, hour = 18, minute = 0) => + new Date(2026, 9, day, hour, minute).toISOString(); + const dates: ProgramScheduleDates = { + datetimeRegistrationEnds: at(13), + datetimeProjectSubmissionEnds: at(17), + datetimeEvaluationEnds: at(24), + }; + + it("keeps stage order and highlights the earliest remaining deadline, even out of stage order", () => { + const rows = programDeadlines({ ...dates, datetimeEvaluationEnds: at(16) }, true, now); + expect(rows.map(row => row.key)).toEqual(["registration", "submission", "evaluation"]); + expect(rows.map(row => row.status)).toEqual([ + "Срок завершён", + "Осталось 3 дня", + "Осталось 2 дня", + ]); + expect(rows.filter(row => row.nearest).map(row => row.key)).toEqual(["evaluation"]); + expect(rows[0].ended).toBe(true); + expect(rows[1].datetime).toBe(at(17)); + expect(rows[1].dateLabel).toContain("17.10.2026"); + expect(rows[1].dateLabel).toContain("18:00"); + }); + + it.each([ + [at(14, 11, 59), "Срок завершён", true], + [at(14, 12), "Сегодня", false], + [at(14, 23, 59), "Сегодня", false], + [at(15, 0), "Завтра", false], + ])("uses the exact timestamp before calendar-day labels: %s", (datetime, status, ended) => { + const row = programDeadlines({ datetimeRegistrationEnds: datetime }, false, now)[0]; + expect(row.status).toBe(status); + expect(row.ended).toBe(ended); + expect(row.nearest).toBe(!ended); + }); + + it.each([ + [21, "Остался 21 день"], + [22, "Осталось 22 дня"], + [25, "Осталось 25 дней"], + [11, "Осталось 11 дней"], + ])("uses Russian day plurals for %s calendar days", (days, status) => { + const datetime = new Date(2026, 9, 14 + days, 18).toISOString(); + expect(programDeadlines({ datetimeEvaluationEnds: datetime }, false, now)[0].status).toBe( + status, + ); + }); + + it.each([null, undefined, "", " "])( + "uses the API registration fallback for an absent submission deadline %s", + missing => { + const rows = programDeadlines( + { ...dates, datetimeProjectSubmissionEnds: missing }, + true, + now, + ); + expect(rows[1].datetime).toBe(rows[0].datetime); + expect(rows[1].usesRegistrationDeadline).toBe(true); + expect(rows[1].status).toBe("Срок завершён"); + }, + ); + + it("highlights both stages when they share the same effective next deadline", () => { + const rows = programDeadlines({ datetimeRegistrationEnds: at(15) }, true, now); + expect(rows.map(row => row.nearest)).toEqual([true, true]); + expect(rows.map(row => row.key)).toEqual(["registration", "submission"]); + }); + + it("hides the submission stage when submission is not applicable", () => { + const rows = programDeadlines(dates, false, now); + expect(rows.map(row => row.key)).toEqual(["registration", "evaluation"]); + expect(rows[1].nearest).toBe(true); + }); + + it("skips missing and invalid dates without inventing an evaluation or replacing an invalid explicit date", () => { + expect(programDeadlines(null, true, now)).toEqual([]); + expect(programDeadlines({}, true, now)).toEqual([]); + expect( + programDeadlines({ datetimeRegistrationEnds: "bad", datetimeEvaluationEnds: " " }, true, now), + ).toEqual([]); + const rows = programDeadlines({ ...dates, datetimeProjectSubmissionEnds: "bad" }, true, now); + expect(rows.map(row => row.key)).toEqual(["registration", "evaluation"]); + }); + + it("does not highlight any deadline when all are past", () => { + const rows = programDeadlines(dates, true, new Date(2026, 10, 1)); + expect(rows.every(row => row.ended && !row.nearest)).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/utils/program-deadlines.ts b/projects/social_platform/src/app/utils/program-deadlines.ts new file mode 100644 index 000000000..8693f5e79 --- /dev/null +++ b/projects/social_platform/src/app/utils/program-deadlines.ts @@ -0,0 +1,97 @@ +/** @format */ + +export interface ProgramScheduleDates { + datetimeRegistrationEnds?: string | null; + datetimeProjectSubmissionEnds?: string | null; + datetimeEvaluationEnds?: string | null; +} + +export interface ProgramDeadline { + key: "registration" | "submission" | "evaluation"; + label: string; + datetime: string; + dateLabel: string; + status: string; + ended: boolean; + nearest: boolean; + usesRegistrationDeadline: boolean; +} + +const dateFormat = new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); +const plural = new Intl.PluralRules("ru-RU"); + +/** Local calendar days, without assuming every day is exactly 24 hours long. */ +function calendarDay(date: Date): number { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000; +} + +function deadlineStatus(date: Date, now: Date): string { + if (date.getTime() < now.getTime()) return "Срок завершён"; + const days = calendarDay(date) - calendarDay(now); + if (days === 0) return "Сегодня"; + if (days === 1) return "Завтра"; + const form = plural.select(days); + const word = form === "one" ? "день" : form === "few" ? "дня" : "дней"; + return `${form === "one" ? "Остался" : "Осталось"} ${days} ${word}`; +} + +export function programDeadlines( + dates: ProgramScheduleDates | null, + submissionApplicable: boolean, + now: Date, +): ProgramDeadline[] { + if (!dates) return []; + const explicitSubmission = dates.datetimeProjectSubmissionEnds?.trim(); + const stages = [ + { + key: "registration" as const, + label: "Конец регистрации", + datetime: dates.datetimeRegistrationEnds, + applicable: true, + usesRegistrationDeadline: false, + }, + { + key: "submission" as const, + label: "Сдача решений", + // Matches PartnerProgram.get_project_submission_deadline() on the API. + datetime: explicitSubmission || dates.datetimeRegistrationEnds, + applicable: submissionApplicable, + usesRegistrationDeadline: !explicitSubmission, + }, + { + key: "evaluation" as const, + label: "Конец оценивания", + datetime: dates.datetimeEvaluationEnds, + applicable: true, + usesRegistrationDeadline: false, + }, + ]; + const valid = stages.flatMap(stage => { + if (!stage.applicable || !stage.datetime?.trim()) return []; + const date = new Date(stage.datetime); + if (!Number.isFinite(date.getTime())) return []; + return [{ ...stage, date }]; + }); + const nearest = Math.min( + ...valid + .filter(stage => stage.date.getTime() >= now.getTime()) + .map(stage => stage.date.getTime()), + ); + + return valid.map(stage => ({ + key: stage.key, + label: stage.label, + datetime: stage.date.toISOString(), + dateLabel: dateFormat.format(stage.date), + status: deadlineStatus(stage.date, now), + ended: stage.date.getTime() < now.getTime(), + nearest: stage.date.getTime() === nearest, + usesRegistrationDeadline: stage.usesRegistrationDeadline, + })); +} diff --git a/projects/social_platform/src/assets/icons/svg/analytics-submitted.svg b/projects/social_platform/src/assets/icons/svg/analytics-submitted.svg new file mode 100644 index 000000000..f3e2695c6 --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/analytics-submitted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/social_platform/src/assets/icons/svg/analytics-team.svg b/projects/social_platform/src/assets/icons/svg/analytics-team.svg new file mode 100644 index 000000000..bc08f636f --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/analytics-team.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg index d3c7d6784..2988ccd11 100644 --- a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file