From 6a9fb82b8fb0e07eeedb12f97da5cd7806857176 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 10 Oct 2026 20:14:26 +0300 Subject: [PATCH] feat(analytics): clarify metrics and redesign program charts --- package-lock.json | 19 + package.json | 1 + .../domain/program/program-analytics.model.ts | 2 + .../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 | 639 +++++++----------- .../detail/analytics/analytics.component.scss | 239 +------ .../analytics/analytics.component.spec.ts | 178 +++-- .../detail/analytics/analytics.component.ts | 167 ++--- .../analytics-case.component.spec.ts | 3 + .../analytics-drilldown.component.html | 4 - .../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 | 182 +++++ .../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 | 15 +- .../program-role-widget.component.scss | 29 - .../program-role-widget.component.spec.ts | 7 +- .../program-role-widget.component.ts | 2 - .../assets/icons/svg/analytics-submitted.svg | 4 + .../src/assets/icons/svg/analytics-team.svg | 7 + .../assets/icons/symbol/svg/sprite.css.svg | 2 +- 28 files changed, 1844 insertions(+), 847 deletions(-) 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/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 b4dc21fe7..9e71d1564 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/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts index 55833d728..9b85cc688 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; + /** Completed platform onboarding; absent during a staggered API rollout. */ + onboardedParticipants?: number; withTeam: number; projectCreators: number; submittedProjectCreators: 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..5dd42637c --- /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); + + 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); + 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); + 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 72edc8547..e24c09b5e 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 @@ -93,9 +93,13 @@

Аналитика недоступна

} @else if (data(); as overview) {
@for (card of summary(); track card.key) { -
+
- +
@@ -116,215 +120,102 @@

{{ card.label }}

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

{{ card.title }}

- - -
- @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 }}

- } -
- } -
- -
-
-
-

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

- -
- @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) { -
  • - + @if (hasValues(attention()) || data()?.attention?.projectsNotSubmitted?.applicable) { +
      + @for (item of attention(); track item.key) { +
    • + @if (item.key === "delayed-experts") { + + } @else { + + } +
    • }
    } + @if (!hasValues(attention())) { +

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

    + }
@@ -417,104 +308,117 @@
-
-
+
+
-

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

+

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

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

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

    + } +
+ +
+
+

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

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

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

+ } +
+
+ +
+
+
+

Кейсы

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

{{ message }}

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

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

- }
@@ -536,104 +440,71 @@

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

> @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 дней активности не было

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

{{ card.title }}

+ + +
+ @if (regionExportState()[card.key].pending) { + + } + @if (regionExportState()[card.key].failed) { + + } + @if (card.items.length > 0) { + + } @else { +

{{ card.emptyMessage }}

+ } +
+ } +
} span:not(.funnel__bar) { - position: relative; - z-index: 1; - color: var(--black); - } - - &__item > strong { - position: relative; - z-index: 1; - font-weight: 700; - color: var(--black); - } - - &__label { - display: flex; - gap: 6px; - align-items: center; - min-width: 0; - font-weight: 500; - } - - &__bar { - position: absolute; - top: 0; - bottom: 0; - left: 0; - background: linear-gradient(90deg, var(--accent-light), var(--ui-accent-gradient-end)); - border-radius: 8px; - } -} - .status-list { display: grid; gap: 10px; @@ -753,161 +679,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: var(--ui-font-small); - color: var(--black); - transform: translateY(-50%); - } - - &__helper, - &__readout { - margin-top: 12px; - font-size: var(--ui-font-small); - 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: var(--ui-accent-tint-chart); - } - - &: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: var(--ui-font-small); - 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; - transform: translateX(-50%); - - @media screen and (max-width: 999px) { - font-size: var(--ui-font-caption); - } - - @media screen and (min-width: 1000px) { - font-size: 11px; - } - } - } } .regions__export { 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 48f5b2cb0..3bd36170f 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 @@ -25,6 +25,27 @@ import { AnalyticsDrilldownComponent } from "./drilldown/analytics-drilldown.com 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 +78,7 @@ function overview(overrides: Partial = {}): ProgramAna participantFunnel: { registrations: 20, uniqueParticipants: 18, + onboardedParticipants: 16, withTeam: 12, projectCreators: 7, submittedProjectCreators: 2, @@ -118,6 +140,13 @@ describe("ProgramAnalyticsComponent", () => { beforeEach(() => { vi.mocked(exportRegions).mockReset().mockResolvedValue(undefined); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + })); data.set(overview()); pending.set(false); failed.set(false); @@ -210,14 +239,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("keeps cases beside 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", () => { @@ -242,10 +271,10 @@ describe("ProgramAnalyticsComponent", () => { "проекта", ]); expect(rows[0].querySelector(".case-row__meta")?.textContent).toMatch( - /Сдано 2\s*·\s*Не сдано 1\s*·\s*Участников 7/, + /Сдано 2\s*·\s*Участников 7/, ); expect(rows[0].querySelector('[role="img"]')?.getAttribute("aria-label")).toBe( - "Case A: 3 проекта, сдано 2, не сдано 1, участников 7", + "Case A: 3 проекта, сдано 2, участников 7", ); expect(rows[3].classList.contains("case-row--without-case")).toBe(true); expect( @@ -379,10 +408,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 +434,30 @@ 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 > section > article")].map(el => + el.getAttribute("data-testid"), + ), + ).toEqual([ + "summary-participants", + "summary-projects", + "summary-submitted-projects", + "summary-participants-per-project", + "attention-block", + "evaluation-statuses", + "participant-funnel", + "solution-funnel", + "cases-card", + "activity-dynamics", + "project-regions", + "participant-regions", + ]); + }); + it.each([ [0, "all"], [1, "completed"], @@ -494,7 +549,7 @@ describe("ProgramAnalyticsComponent", () => { expect(open).toHaveBeenCalledTimes(1); }); - it("not-submitted: порядок, активная/нулевая строка и tooltip отдельно", () => { + it("не дублирует счётчик несданных проектов в блоке внимания", () => { const base = overview(); data.set({ ...base, @@ -502,40 +557,26 @@ describe("ProgramAnalyticsComponent", () => { }); 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 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 +601,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 +624,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 +681,7 @@ describe("ProgramAnalyticsComponent", () => { ...model.participantFunnel, registrations: 11, uniqueParticipants: 7, + onboardedParticipants: 6, withTeam: 4, projectCreators: 3, }; @@ -652,42 +694,52 @@ 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("app-analytics-funnel-chart canvas")).toHaveLength(2); + }); + + it("не выдаёт отсутствие новой метрики онбординга за ноль", () => { + const model = overview(); + delete model.participantFunnel.onboardedParticipants; + 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 +775,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([4, 3, 3, 1]); for (const selector of selectors) { for (const label of root.querySelectorAll(selector)) expect(label.classList.contains("text-body-10")).toBe(true); @@ -875,31 +927,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")); + 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("02.08"); - date?.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 () => { 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 e0ecfa626..acaed4325 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 @@ -15,16 +15,19 @@ import { MatProgressBarModule } from "@angular/material/progress-bar"; 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 { 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 { @@ -36,13 +39,6 @@ interface AnalyticsMetric { scope?: ProgramAnalyticsAssignmentScope; } -interface ActivityChartPoint extends ProgramAnalyticsActivityPoint { - dateLabel: string; - x: number; - registrationsY: number; - submittedSolutionsY: number; -} - interface RegionExportState { pending: boolean; failed: boolean; @@ -70,6 +66,9 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { MatProgressBarModule, TooltipComponent, AnalyticsDrilldownComponent, + AnalyticsRegionChartComponent, + AnalyticsActivityChartComponent, + AnalyticsFunnelChartComponent, ], providers: [ProgramAnalyticsInfoService, ExportFileInfoService], changeDetection: ChangeDetectionStrategy.OnPush, @@ -93,7 +92,6 @@ export class ProgramAnalyticsComponent implements OnInit { protected readonly error = this.analytics.error; protected readonly activeTooltip = signal(null); protected readonly pinnedTooltip = signal(null); - protected readonly selectedActivityDate = signal(null); protected readonly regionExportState = signal>({ "project-regions": { pending: false, failed: false }, "participant-regions": { pending: false, failed: false }, @@ -132,9 +130,7 @@ export class ProgramAnalyticsComponent implements OnInit { "проекта", "проектов", ]); - const submission = cases.submissionApplicable - ? `, сдано ${row.submitted}, не сдано ${row.notSubmitted}` - : ""; + const submission = cases.submissionApplicable ? `, сдано ${row.submitted}` : ""; return { ...row, totalLabel, @@ -158,7 +154,7 @@ export class ProgramAnalyticsComponent implements OnInit { key: "participants", label: "Участники", value: overview.summary.participants.total, - icon: "team", + icon: "person", tooltip: "Уникальные пользователи, зарегистрированные в программе.", }, { @@ -169,17 +165,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: "Среднее количество зарегистрированных участников программы на один проект.", }, ]; @@ -202,29 +198,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: "onboarded", + label: "Завершили онбординг", + value: funnel.onboardedParticipants ?? null, + tooltip: + "Зарегистрированные участники, завершившие стартовый сценарий платформы. Шаги можно пропустить; это не показатель заполненности профиля или регулярного использования.", + axisLabel: ["Завершили", "онбординг"], + }, + { + ...this.metric( + "with-team", + "Вступили в команду", + funnel.withTeam, + "Зарегистрированные участники, которые сейчас являются руководителями или участниками команды проекта программы. Учитываются и те, кто ещё не завершил онбординг.", + ), + axisLabel: ["Вступили", "в команду"], + }, ]; }); @@ -234,12 +238,6 @@ export class ProgramAnalyticsComponent implements OnInit { return [ this.metric("solutions-created", "Создано", funnel.created, "Все проекты программы."), - this.metric( - "solutions-not-submitted", - "Черновик / не сдано", - funnel.notSubmitted, - "Проекты программы, решение по которым ещё не отправлено.", - ), this.metric( "solutions-submitted", "Сдано", @@ -334,16 +332,6 @@ 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: "Эксперты задерживают оценивание", @@ -360,57 +348,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; @@ -478,13 +415,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 { @@ -508,9 +440,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-case.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-case.component.spec.ts index 2f1d936fe..8cce4812e 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([ "Проект", "Лидер / команда", 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 ad7d330e2..aa1bd54d8 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 @@ -97,10 +97,6 @@

{{ title()
Сдано
{{ metrics.submitted }}
-
-
Не сдано
-
{{ metrics.notSubmitted }}
-
} } 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..b9be9d7b9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.html @@ -0,0 +1,35 @@ + + +
+ +
+
+ Данные и определения этапов + + + + + + + + + + @for (item of items(); track item.key) { + + + + + } + +
+ {{ + label() + }} +
ЭтапВсего
+ {{ 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..6de43ef18 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/funnel-chart/funnel-chart.component.ts @@ -0,0 +1,182 @@ +/** @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[]; +} + +@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"); + + 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 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 = color; + this.chart.data.datasets[0].hoverBackgroundColor = color; + 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: color, + hoverBackgroundColor: color, + 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 424f2b78f..95b27613b 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 @@ -72,7 +72,6 @@

аналитика

} @case ("organizer") { @let metrics = data.organizer; - @let ratio = withoutProject(metrics);
Участники
@@ -100,15 +99,15 @@

аналитика

Без проектов
- {{ count(metrics.participantsWithoutProject) }} - {{ ratio.percentage }} + {{ count(metrics.participantsWithoutProject) }}
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss index 3861bff23..2ffcc6369 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.scss @@ -199,35 +199,6 @@ min-height: 24px; } - &__without span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - } - - &__without { - display: flex; - gap: 4px; - align-items: center; - } - - &__without small { - display: flex; - flex-shrink: 0; - gap: 3px; - align-items: center; - font-size: 9px; - font-weight: 400; - line-height: 12px; - } - - &__without i { - width: 3px; - height: 3px; - background: currentcolor; - border-radius: 50%; - } - &__expert-state { display: grid; align-content: center; 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..adf567dca 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 @@ -110,7 +110,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 +123,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 d280eb70f..73a38e8fd 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 @@ -18,7 +18,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"; @@ -43,7 +42,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/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..0a6220c4d 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