Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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 { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "@api/program/use-cases/get-program-manager-participants-without-onboarding.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 {
Expand All @@ -28,7 +28,7 @@ import {

/** Root-списки внимания используют тот же modal context, не вложенную модалку. */
export type AnalyticsAttentionView =
| "participants-without-onboarding"
| "participants-without-visit"
| "participants-without-team"
| "projects-awaiting-evaluation"
| "projects-not-submitted";
Expand Down Expand Up @@ -60,9 +60,7 @@ export class ProgramAnalyticsDrilldownService {
private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase);
private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase);
private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase);
private readonly getWithoutOnboarding = inject(
GetProgramManagerParticipantsWithoutOnboardingUseCase,
);
private readonly getWithoutVisit = inject(GetProgramManagerParticipantsWithoutVisitUseCase);
private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase);
private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase);
private readonly destroyRef = inject(DestroyRef);
Expand Down Expand Up @@ -96,8 +94,7 @@ export class ProgramAnalyticsDrilldownService {
readonly attentionError = signal<ProgramAnalyticsError | null>(null);
readonly isParticipantView = computed(
() =>
this.view() === "participants-without-team" ||
this.view() === "participants-without-onboarding",
this.view() === "participants-without-team" || this.view() === "participants-without-visit",
);
readonly isAttentionView = computed(
() =>
Expand Down Expand Up @@ -179,9 +176,7 @@ export class ProgramAnalyticsDrilldownService {
};
if (this.isParticipantView()) {
const useCase =
this.view() === "participants-without-onboarding"
? this.getWithoutOnboarding
: this.getParticipants;
this.view() === "participants-without-visit" ? this.getWithoutVisit : this.getParticipants;
useCase
.execute(this.programId, query)
.pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
notSubmittedPage,
} from "@domain/program/program-analytics-attention.fixture";
import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case";
import { GetProgramManagerParticipantsWithoutOnboardingUseCase } from "./get-program-manager-participants-without-onboarding.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";

Expand All @@ -30,9 +30,9 @@ describe("Attention: adapter → repository → use cases", () => {

for (const [method, endpoint, useCase, page] of [
[
"getManagerParticipantsWithoutOnboarding",
"participants-without-onboarding",
GetProgramManagerParticipantsWithoutOnboardingUseCase,
"getManagerParticipantsWithoutVisit",
"participants-without-visit",
GetProgramManagerParticipantsWithoutVisitUseCase,
participantsPage(),
],
[
Expand All @@ -57,7 +57,7 @@ describe("Attention: adapter → repository → use cases", () => {
const injectUseCase = () =>
TestBed.inject<
| GetProgramManagerParticipantsWithoutTeamUseCase
| GetProgramManagerParticipantsWithoutOnboardingUseCase
| GetProgramManagerParticipantsWithoutVisitUseCase
| GetProgramManagerProjectsAwaitingEvaluationUseCase
| GetProgramManagerProjectsNotSubmittedUseCase
>(useCase);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ 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 GetProgramManagerParticipantsWithoutOnboardingUseCase {
export class GetProgramManagerParticipantsWithoutVisitUseCase {
private readonly repository = inject(ProgramRepositoryPort);

execute(
Expand All @@ -25,7 +25,7 @@ export class GetProgramManagerParticipantsWithoutOnboardingUseCase {
ProgramAnalyticsError
>
> {
return this.repository.getManagerParticipantsWithoutOnboarding(programId, query).pipe(
return this.repository.getManagerParticipantsWithoutVisit(programId, query).pipe(
map(value => ok(value)),
catchError((error: unknown) => {
const status = error instanceof HttpErrorResponse ? error.status : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export abstract class ProgramRepositoryPort {

abstract getManagerOverview(programId: number): Observable<ProgramAnalyticsOverview>;

abstract getManagerParticipantsWithoutOnboarding(
abstract getManagerParticipantsWithoutVisit(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export interface ProgramAnalyticsOverview {
participantFunnel: {
registrations: number;
uniqueParticipants: number;
/** Completed platform onboarding; absent during a staggered API rollout. */
onboardedParticipants?: number;
/** Recorded platform activity or login; absent during a staggered API rollout. */
visitedParticipants?: number;
withTeam: number;
projectCreators: number;
submittedProjectCreators: number;
Expand All @@ -41,8 +41,8 @@ export interface ProgramAnalyticsOverview {
};
};
attention: {
/** Absent until the API deployment provides the onboarding attention list. */
participantsWithoutOnboarding?: number;
/** Registered users with neither recorded activity nor login; optional during rollout. */
participantsWithoutVisit?: number;
participantsWithoutTeam: number;
projectsAwaitingEvaluation: number;
/** Несданные связи: требование сдачи применимо только к конкурсной программе. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ export class ProgramHttpAdapter {
}

/** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */
getManagerParticipantsWithoutOnboarding(
getManagerParticipantsWithoutVisit(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>> {
return this.apiService.get(
`${this.PROGRAMS_URL}/${programId}/manager-overview/participants-without-onboarding/`,
`${this.PROGRAMS_URL}/${programId}/manager-overview/participants-without-visit/`,
this.attentionParams(query),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export class ProgramRepository implements ProgramRepositoryPort {
}

/** Свежий manager-список при каждом открытии/поиске, без EntityCache. */
getManagerParticipantsWithoutOnboarding(
getManagerParticipantsWithoutVisit(
programId: number,
query: ProgramAnalyticsAttentionQuery,
): Observable<ProgramAnalyticsAttentionPage<ProgramAnalyticsAttentionParticipant>> {
return this.programAdapter.getManagerParticipantsWithoutOnboarding(programId, query);
return this.programAdapter.getManagerParticipantsWithoutVisit(programId, query);
}

/** Свежий manager-список при каждом открытии/поиске, без EntityCache. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ <h2 class="text-body-12">Требует внимания</h2>
[disabled]="item.value === 0"
(click)="
drilldown.openAttention(
item.key === 'without-onboarding'
? 'participants-without-onboarding'
item.key === 'without-visit'
? 'participants-without-visit'
: item.key === 'without-team'
? 'participants-without-team'
: 'projects-awaiting-evaluation',
Expand Down Expand Up @@ -315,7 +315,7 @@ <h3 class="analytics-card__section-label">Назначения эксперто
<div class="analytics__heading-row">
<h2 class="text-body-12">Воронка участников</h2>
<app-tooltip
text="Регистрация в программе → завершение онбординга платформы → участие в команде. Показано текущее состояние участников: команда может появиться до завершения онбординга."
text="Регистрация в программе → зафиксированное посещение платформы → участие в команде. Показано текущее состояние участников; посещение учитывается и до регистрации в этой программе."
[isVisible]="isTooltipVisible('participant-funnel')"
[useOverlay]="true"
(click)="toggleTooltip('participant-funnel')"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function overview(overrides: Partial<ProgramAnalyticsOverview> = {}): ProgramAna
participantFunnel: {
registrations: 20,
uniqueParticipants: 18,
onboardedParticipants: 16,
visitedParticipants: 16,
withTeam: 12,
projectCreators: 7,
submittedProjectCreators: 2,
Expand Down Expand Up @@ -549,11 +549,11 @@ describe("ProgramAnalyticsComponent", () => {
expect(open).toHaveBeenCalledTimes(1);
});

it("onboarding attention uses the API count, opens its list, and disables zero", () => {
it("visit attention uses the API count, opens its list, and disables zero", () => {
const base = overview();
data.set({
...base,
attention: { ...base.attention, participantsWithoutOnboarding: 7 },
attention: { ...base.attention, participantsWithoutVisit: 7 },
});
const fixture = TestBed.createComponent(ProgramAnalyticsComponent);
fixture.detectChanges();
Expand All @@ -562,32 +562,35 @@ describe("ProgramAnalyticsComponent", () => {
const open = vi.spyOn(drilldown, "openAttention").mockImplementation(() => {});
const trigger = Array.from(
fixture.nativeElement.querySelectorAll(".attention__action") as NodeListOf<HTMLButtonElement>,
).find(button => button.textContent?.includes("Участники не завершили онбординг"))!;
).find(button => button.textContent?.includes("Участники ещё не заходили на платформу"))!;
expect(trigger.querySelector("strong")?.textContent).toBe("7");
expect(trigger.disabled).toBe(false);
trigger.click();
expect(open).toHaveBeenCalledExactlyOnceWith("participants-without-onboarding", trigger);
data.set({ ...base, attention: { ...base.attention, participantsWithoutOnboarding: 0 } });
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 an onboarding attention count when the API field is absent", () => {
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("Участники не завершили онбординг");
).not.toContain("Участники ещё не заходили на платформу");
});

it("unfinished onboarding alone prevents the all-clear attention state", () => {
it("participants without a visit alone prevent the all-clear attention state", () => {
const base = overview();
data.set({
...base,
attention: {
participantsWithoutOnboarding: 1,
participantsWithoutVisit: 1,
participantsWithoutTeam: 0,
projectsAwaitingEvaluation: 0,
projectsNotSubmitted: { applicable: false, total: 0 },
Expand All @@ -599,7 +602,7 @@ describe("ProgramAnalyticsComponent", () => {
expect(fixture.nativeElement.querySelector(".attention__empty")).toBeNull();
data.update(current => ({
...current!,
attention: { ...current!.attention, participantsWithoutOnboarding: 0 },
attention: { ...current!.attention, participantsWithoutVisit: 0 },
}));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector(".attention__empty")?.textContent).toContain(
Expand Down Expand Up @@ -739,7 +742,7 @@ describe("ProgramAnalyticsComponent", () => {
...model.participantFunnel,
registrations: 11,
uniqueParticipants: 7,
onboardedParticipants: 6,
visitedParticipants: 6,
withTeam: 4,
projectCreators: 3,
};
Expand All @@ -755,7 +758,7 @@ describe("ProgramAnalyticsComponent", () => {
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("td")?.textContent?.trim())).toEqual(["7", "6", "4"]);
Expand All @@ -764,7 +767,7 @@ describe("ProgramAnalyticsComponent", () => {
.queryAll(By.directive(TooltipComponent))
.map(element => (element.componentInstance as TooltipComponent).text());
expect(participants).toContain("Уникальные зарегистрированные участники программы.");
expect(tooltips.some(text => text.includes("завершение онбординга платформы"))).toBe(true);
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(
"Участники без команды",
Expand All @@ -788,9 +791,10 @@ describe("ProgramAnalyticsComponent", () => {
expect(root.querySelectorAll("app-analytics-funnel-chart canvas")).toHaveLength(2);
});

it("не выдаёт отсутствие новой метрики онбординга за ноль", () => {
it("не выдаёт отсутствие новой метрики посещения за ноль", () => {
const model = overview();
delete model.participantFunnel.onboardedParticipants;
delete model.participantFunnel.visitedParticipants;
Object.assign(model.participantFunnel, { onboardedParticipants: 6 });
data.set(model);
const fixture = TestBed.createComponent(ProgramAnalyticsComponent);
fixture.detectChanges();
Expand Down
Loading